diff --git a/.github/workflows/component-tests.yaml b/.github/workflows/component-tests.yaml index 86612b8053..4077ba98b5 100644 --- a/.github/workflows/component-tests.yaml +++ b/.github/workflows/component-tests.yaml @@ -71,7 +71,8 @@ jobs: Test_21_AlertOnPartialThenLearnNetworkTest, Test_22_AlertOnPartialNetworkProfileTest, Test_23_RuleCooldownTest, - Test_24_ProcessTreeDepthTest + Test_24_ProcessTreeDepthTest, + Test_32_UnexpectedProcessArguments ] steps: - name: Checkout code diff --git a/go.mod b/go.mod index 68ee6ab378..1500a3fdcb 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,9 @@ go 1.25.8 require ( github.com/DmitriyVTitov/size v1.5.0 github.com/Masterminds/semver/v3 v3.4.0 - github.com/anchore/syft v1.32.0 + github.com/anchore/syft v1.42.3 github.com/aquilax/truncate v1.0.0 - github.com/armosec/armoapi-go v0.0.694 + github.com/armosec/armoapi-go v0.0.696 github.com/armosec/utils-k8s-go v0.0.35 github.com/cenkalti/backoff v2.2.1+incompatible github.com/cenkalti/backoff/v4 v4.3.0 @@ -24,7 +24,7 @@ require ( github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb github.com/go-openapi/strfmt v0.23.0 github.com/google/cel-go v0.26.1 - github.com/google/go-containerregistry v0.20.7 + github.com/google/go-containerregistry v0.21.2 github.com/google/uuid v1.6.0 github.com/goradd/maps v1.3.0 github.com/grafana/pyroscope-go v1.2.2 @@ -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.213 - github.com/kubescape/storage v0.0.258 + github.com/kubescape/storage v0.0.278 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 @@ -47,7 +47,7 @@ require ( github.com/prometheus/alertmanager v0.27.0 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/procfs v0.20.1 - github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af + github.com/sirupsen/logrus v1.9.4 github.com/spf13/afero v1.15.0 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 @@ -57,6 +57,7 @@ require ( go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/prometheus v0.65.0 go.opentelemetry.io/otel/log v0.19.0 + go.opentelemetry.io/otel/log/logtest v0.19.0 go.opentelemetry.io/otel/metric v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/sdk/metric v1.43.0 @@ -77,7 +78,7 @@ require ( k8s.io/cri-api v0.35.0 k8s.io/kubectl v0.34.1 k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 - modernc.org/sqlite v1.38.2 + modernc.org/sqlite v1.46.1 oras.land/oras-go/v2 v2.6.0 sigs.k8s.io/yaml v1.6.0 ) @@ -107,9 +108,9 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/Microsoft/hcsshim v0.13.0 // indirect + github.com/Microsoft/hcsshim v0.14.0-rc.1 // indirect github.com/OneOfOne/xxhash v1.2.8 // indirect - github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/ProtonMail/go-crypto v1.4.0 // indirect github.com/STARRY-S/zip v0.2.3 // indirect github.com/SergJa/jsonhash v0.0.0-20210531165746-fc45f346aa74 // indirect github.com/acobaugh/osrelease v0.1.0 // indirect @@ -129,7 +130,7 @@ require ( github.com/anchore/go-sync v0.0.0-20250714163430-add63db73ad1 // indirect github.com/anchore/go-version v1.2.2-0.20210903204242-51efa5b487c4 // indirect github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115 // indirect - github.com/anchore/stereoscope v0.1.9 // indirect + github.com/anchore/stereoscope v0.1.22 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect @@ -167,7 +168,7 @@ require ( github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/blang/semver/v4 v4.0.0 // indirect - github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/bodgit/plumbing v1.3.0 // indirect github.com/bodgit/sevenzip v1.6.1 // indirect github.com/bodgit/windows v1.0.1 // indirect @@ -182,17 +183,17 @@ require ( github.com/cloudflare/cbpfc v0.0.0-20240920015331-ff978e94500b // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect - github.com/containerd/cgroups/v3 v3.0.5 // indirect + github.com/containerd/cgroups/v3 v3.1.2 // indirect github.com/containerd/containerd v1.7.32 // indirect - github.com/containerd/containerd/api v1.9.0 // indirect + github.com/containerd/containerd/api v1.10.0 // indirect github.com/containerd/continuity v0.4.5 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/fifo v1.1.0 // indirect github.com/containerd/log v0.1.0 // indirect - github.com/containerd/nri v0.9.0 // indirect - github.com/containerd/platforms v0.2.1 // indirect - github.com/containerd/stargz-snapshotter/estargz v0.18.1 // indirect + github.com/containerd/nri v0.11.0 // indirect + github.com/containerd/platforms v1.0.0-rc.2 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect github.com/containerd/ttrpc v1.2.7 // indirect github.com/containerd/typeurl/v2 v2.2.3 // indirect github.com/containers/common v0.64.2 // indirect @@ -202,16 +203,16 @@ require ( github.com/deitch/magic v0.0.0-20240306090643-c67ab88f10cb // indirect github.com/diskfs/go-diskfs v1.7.0 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/cli v29.2.0+incompatible // indirect + github.com/docker/cli v29.3.0+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker v28.5.2+incompatible // indirect - github.com/docker/docker-credential-helpers v0.9.3 // indirect + github.com/docker/docker-credential-helpers v0.9.5 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-events v0.0.0-20250114142523-c867878c5e32 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/elliotchance/phpserialize v1.4.0 // indirect - github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect @@ -223,9 +224,9 @@ require ( github.com/francoispqt/gojay v1.2.13 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.10 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gammazero/deque v1.0.0 // indirect - github.com/github/go-spdx/v2 v2.3.3 // indirect + github.com/github/go-spdx/v2 v2.4.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect github.com/go-errors/errors v1.5.1 // indirect github.com/go-fonts/liberation v0.3.2 // indirect @@ -248,12 +249,12 @@ require ( github.com/go-openapi/validate v0.24.0 // indirect github.com/go-pdf/fpdf v0.9.0 // indirect github.com/go-restruct/restruct v1.2.0-alpha // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/godbus/dbus/v5 v5.2.0 // indirect github.com/gofrs/flock v0.13.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/gohugoio/hashstructure v0.5.0 // indirect + github.com/gohugoio/hashstructure v0.6.0 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect @@ -327,7 +328,7 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncw/directio v1.0.5 // indirect github.com/nix-community/go-nix v0.0.0-20250101154619-4bdde671e0a1 // indirect github.com/notaryproject/notation-core-go v1.3.0 // indirect @@ -342,7 +343,7 @@ require ( github.com/olekukonko/tablewriter v1.0.9 // indirect github.com/olvrng/ujson v1.1.0 // indirect github.com/opcoder0/capabilities v0.0.0-20221222060822-17fd73bffd2a // indirect - github.com/opencontainers/runtime-spec v1.2.1 // indirect + github.com/opencontainers/runtime-spec v1.3.0 // indirect github.com/opencontainers/selinux v1.13.1 // indirect github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect github.com/packetcap/go-pcap v0.0.0-20250723190045-d00b185f30b7 // indirect @@ -391,7 +392,7 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/stripe/stripe-go/v74 v74.30.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/sylabs/sif/v2 v2.22.0 // indirect + github.com/sylabs/sif/v2 v2.24.0 // indirect github.com/sylabs/squashfs v1.0.6 // indirect github.com/therootcompany/xz v1.0.1 // indirect github.com/ulikunitz/xz v0.5.15 // indirect @@ -405,7 +406,7 @@ require ( github.com/vishvananda/netlink v1.3.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 // indirect - github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 // indirect + github.com/wagoodman/go-progress v0.0.0-20260303201901-10176f79b2c0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect @@ -429,7 +430,6 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 // indirect - go.opentelemetry.io/otel/log/logtest v0.19.0 // indirect go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/zap v1.27.1 // indirect @@ -462,7 +462,7 @@ require ( k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect k8s.io/kubelet v0.35.0 // indirect - modernc.org/libc v1.66.3 // indirect + modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect sigs.k8s.io/controller-runtime v0.21.0 // indirect @@ -479,3 +479,13 @@ 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/anchore/stereoscope => github.com/anchore/stereoscope v0.1.9 + +replace github.com/opencontainers/runtime-spec => github.com/opencontainers/runtime-spec v1.2.1 + +// TEMP (remove before NA #805 final): pin storage to the fork commit that +// carries the dynamicpathdetector.MatchExecArgs path-aware argv matcher +// (embedded "⋯"/"*" path tokens) moved out of NA into storage. Swap for the +// real released tag once the storage PR merges. +replace github.com/kubescape/storage => github.com/k8sstormcenter/storage v0.0.240-0.20260601171344-787cc4376971 diff --git a/go.sum b/go.sum index 5cbde20563..d456521abc 100644 --- a/go.sum +++ b/go.sum @@ -121,13 +121,13 @@ github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSC github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/Microsoft/hcsshim v0.13.0 h1:/BcXOiS6Qi7N9XqUcv27vkIuVOkBEcWstd2pMlWSeaA= -github.com/Microsoft/hcsshim v0.13.0/go.mod h1:9KWJ/8DgU+QzYGupX4tzMhRQE8h6w90lH6HAaclpEok= +github.com/Microsoft/hcsshim v0.14.0-rc.1 h1:qAPXKwGOkVn8LlqgBN8GS0bxZ83hOJpcjxzmlQKxKsQ= +github.com/Microsoft/hcsshim v0.14.0-rc.1/go.mod h1:hTKFGbnDtQb1wHiOWv4v0eN+7boSWAHyK/tNAaYZL0c= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= -github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= -github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ= +github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4= github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk= github.com/SergJa/jsonhash v0.0.0-20210531165746-fc45f346aa74 h1:zZX7V5abnOB0VTEFnwYxwbuot0GCZUjQZQpjHKnG1Kk= @@ -203,8 +203,8 @@ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/armosec/armoapi-go v0.0.694 h1:LDScWAzikv7mJdDhO+VM0DfNoMhQbhA6do6LWXHRIQs= -github.com/armosec/armoapi-go v0.0.694/go.mod h1:9jAH0g8ZsryhiBDd/aNMX4+n10bGwTx/doWCyyjSxts= +github.com/armosec/armoapi-go v0.0.696 h1:+0Ll7y4oWNaKEO47qbGDFIQLxkSJeKYzylS0FwI84XE= +github.com/armosec/armoapi-go v0.0.696/go.mod h1:9jAH0g8ZsryhiBDd/aNMX4+n10bGwTx/doWCyyjSxts= github.com/armosec/gojay v1.2.17 h1:VSkLBQzD1c2V+FMtlGFKqWXNsdNvIKygTKJI9ysY8eM= github.com/armosec/gojay v1.2.17/go.mod h1:vuvX3DlY0nbVrJ0qCklSS733AWMoQboq3cFyuQW9ybc= github.com/armosec/utils-go v0.0.58 h1:g9RnRkxZAmzTfPe2ruMo2OXSYLwVSegQSkSavOfmaIE= @@ -276,8 +276,8 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= -github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4= @@ -351,12 +351,12 @@ github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/T github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= -github.com/containerd/cgroups/v3 v3.0.5 h1:44na7Ud+VwyE7LIoJ8JTNQOa549a8543BmzaJHo6Bzo= -github.com/containerd/cgroups/v3 v3.0.5/go.mod h1:SA5DLYnXO8pTGYiAHXz94qvLQTKfVM5GEVisn4jpins= +github.com/containerd/cgroups/v3 v3.1.2 h1:OSosXMtkhI6Qove637tg1XgK4q+DhR0mX8Wi8EhrHa4= +github.com/containerd/cgroups/v3 v3.1.2/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw= github.com/containerd/containerd v1.7.32 h1:S54xuVcPxeLaYgaRABtpJ2VyVUVsy0IGf7qHBs+sbY8= github.com/containerd/containerd v1.7.32/go.mod h1:jdwD6s/BhV4XVJGrvtziNPVA+83n66TwptVaPKprq4E= -github.com/containerd/containerd/api v1.9.0 h1:HZ/licowTRazus+wt9fM6r/9BQO7S0vD5lMcWspGIg0= -github.com/containerd/containerd/api v1.9.0/go.mod h1:GhghKFmTR3hNtyznBoQ0EMWr9ju5AqHjcZPsSpTKutI= +github.com/containerd/containerd/api v1.10.0 h1:5n0oHYVBwN4VhoX9fFykCV9dF1/BvAXeg2F8W6UYq1o= +github.com/containerd/containerd/api v1.10.0/go.mod h1:NBm1OAk8ZL+LG8R0ceObGxT5hbUYj7CzTmR3xh0DlMM= github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -367,12 +367,12 @@ github.com/containerd/fifo v1.1.0 h1:4I2mbh5stb1u6ycIABlBw9zgtlK8viPI9QkQNRQEEmY github.com/containerd/fifo v1.1.0/go.mod h1:bmC4NWMbXlt2EZ0Hc7Fx7QzTFxgPID13eH0Qu+MAb2o= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/containerd/nri v0.9.0 h1:jribDJs/oQ95vLO4Yn19HKFYriZGWKiG6nKWjl9Y/x4= -github.com/containerd/nri v0.9.0/go.mod h1:sDRoMy5U4YolsWthg7TjTffAwPb6LEr//83O+D3xVU4= -github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= -github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= -github.com/containerd/stargz-snapshotter/estargz v0.18.1 h1:cy2/lpgBXDA3cDKSyEfNOFMA/c10O1axL69EU7iirO8= -github.com/containerd/stargz-snapshotter/estargz v0.18.1/go.mod h1:ALIEqa7B6oVDsrF37GkGN20SuvG/pIMm7FwP7ZmRb0Q= +github.com/containerd/nri v0.11.0 h1:26mcQwNG58AZn0YkOrlJQ0yxQVmyZooflnVWJTqQrqQ= +github.com/containerd/nri v0.11.0/go.mod h1:bjGTLdUA58WgghKHg8azFMGXr05n1wDHrt3NSVBHiGI= +github.com/containerd/platforms v1.0.0-rc.2 h1:0SPgaNZPVWGEi4grZdV8VRYQn78y+nm6acgLGv/QzE4= +github.com/containerd/platforms v1.0.0-rc.2/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4= +github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= +github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= github.com/containerd/ttrpc v1.2.7 h1:qIrroQvuOL9HQ1X6KHe2ohc7p+HP/0VE6XPU7elJRqQ= github.com/containerd/ttrpc v1.2.7/go.mod h1:YCXHsb32f+Sq5/72xHubdiJRQY9inL4a4ZQrAbN1q9o= github.com/containerd/typeurl/v2 v2.2.3 h1:yNA/94zxWdvYACdYO8zofhrTVuQY73fFU1y++dYSw40= @@ -414,14 +414,14 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= -github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM= -github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.3.0+incompatible h1:z3iWveU7h19Pqx7alZES8j+IeFQZ1lhTwb2F+V9SVvk= +github.com/docker/cli v29.3.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= -github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= +github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= +github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-events v0.0.0-20250114142523-c867878c5e32 h1:EHZfspsnLAz8Hzccd67D5abwLiqoqym2jz/jOS39mCk= @@ -442,8 +442,8 @@ github.com/elliotchance/phpserialize v1.4.0 h1:cAp/9+KSnEbUC8oYCE32n2n84BeW8HOY3 github.com/elliotchance/phpserialize v1.4.0/go.mod h1:gt7XX9+ETUcLXbtTKEuyrqW3lcLUAeS/AnGZ2e49TZs= github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY= github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw= -github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= -github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -497,13 +497,13 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= -github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gammazero/deque v1.0.0 h1:LTmimT8H7bXkkCy6gZX7zNLtkbz4NdS2z8LZuor3j34= github.com/gammazero/deque v1.0.0/go.mod h1:iflpYvtGfM3U8S8j+sZEKIak3SAKYpA5/SQewgfXDKo= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/github/go-spdx/v2 v2.3.3 h1:QI7evnHWEfWkT54eJwkoV/f3a0xD3gLlnVmT5wQG6LE= -github.com/github/go-spdx/v2 v2.3.3/go.mod h1:2ZxKsOhvBp+OYBDlsGnUMcchLeo2mrpEBn2L1C+U3IQ= +github.com/github/go-spdx/v2 v2.4.0 h1:+4IwVwJJbm3rzvrQ6P1nI9BDMcy3la4RchRy5uehV/M= +github.com/github/go-spdx/v2 v2.4.0/go.mod h1:/5rwgS0txhGtRdUZwc02bTglzg6HK3FfuEbECKlK2Sg= github.com/glebarez/go-sqlite v1.20.3 h1:89BkqGOXR9oRmG58ZrzgoY/Fhy5x0M+/WV48U5zVrZ4= github.com/glebarez/go-sqlite v1.20.3/go.mod h1:u3N6D/wftiAzIOJtZl6BmedqxmmkDfH3q+ihjqxC9u0= github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= @@ -580,8 +580,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= @@ -596,8 +596,8 @@ github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg= -github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec= +github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= +github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= @@ -667,8 +667,8 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.20.7 h1:24VGNpS0IwrOZ2ms2P1QE3Xa5X9p4phx0aUgzYzHW6I= -github.com/google/go-containerregistry v0.20.7/go.mod h1:Lx5LCZQjLH1QBaMPeGwsME9biPeo1lPx6lbGj/UmzgM= +github.com/google/go-containerregistry v0.21.2 h1:vYaMU4nU55JJGFC9JR/s8NZcTjbE9DBBbvusTW9NeS0= +github.com/google/go-containerregistry v0.21.2/go.mod h1:ctO5aCaewH4AK1AumSF5DPW+0+R+d2FmylMJdp5G7p0= github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -855,6 +855,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.20260601171344-787cc4376971 h1:8SKwW9yJQMFSYHCGJ8ybhsgXSV8OaVTHqnoQzpiaEzQ= +github.com/k8sstormcenter/storage v0.0.240-0.20260601171344-787cc4376971/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= @@ -889,8 +891,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.213 h1:JaEVzgE5qwQ3rEjQ8tBMp48YX4yveitLfYNaCIk8j/A= github.com/kubescape/k8s-interface v0.0.213/go.mod h1:WNYUG93aZ5kDmuaRKFLtVhp18Yc6EfaHdD1gLYtVTN4= -github.com/kubescape/storage v0.0.258 h1:0mL0z3dAmtP1qup7VgoEgwLgbBSROu5oOusBAPeMmus= -github.com/kubescape/storage v0.0.258/go.mod h1:VHs+xQzvZKE2lJDN8rR1sFmTa43N6XJAcatZ249gviU= +github.com/kubescape/storage v0.0.278 h1:/pOtKul443yb2Fzg/4MFq29oOaFoJg1okQaCGbcEVOk= +github.com/kubescape/storage v0.0.278/go.mod h1:FpV6tCrYXlp2kKWza4yr7zf2Y1q7IGgx871ndN7SMNo= 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= @@ -1033,8 +1035,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncw/directio v1.0.5 h1:JSUBhdjEvVaJvOoyPAbcW0fnd0tvRXD76wEfZ1KcQz4= github.com/ncw/directio v1.0.5/go.mod h1:rX/pKEYkOXBGOggmcyJeJGloCkleSvphPx2eV3t6ROk= github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= @@ -1083,8 +1085,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/runtime-spec v1.2.1 h1:S4k4ryNgEpxW1dzyqffOmhI1BHYcjzU8lpJfSlR0xww= github.com/opencontainers/runtime-spec v1.2.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= -github.com/opencontainers/runtime-tools v0.9.1-0.20250523060157-0ea5ed0382a2 h1:2xZEHOdeQBV6PW8ZtimN863bIOl7OCW/X10K0cnxKeA= -github.com/opencontainers/runtime-tools v0.9.1-0.20250523060157-0ea5ed0382a2/go.mod h1:MXdPzqAA8pHC58USHqNCSjyLnRQ6D+NjbpP+02Z1U/0= +github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116 h1:tAKu3NkKWZYpqBSOJKwTxT1wIGueiF7gcmcNgr5pNTY= +github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116/go.mod h1:DKDEfzxvRkoQ6n9TGhxQgg2IM1lY4aM0eaQP4e3oElw= github.com/opencontainers/selinux v1.13.1 h1:A8nNeceYngH9Ow++M+VVEwJVpdFmrlxsN22F+ISDCJE= github.com/opencontainers/selinux v1.13.1/go.mod h1:S10WXZ/osk2kWOYKy1x2f/eXF5ZHJoUs8UU/2caNRbg= github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A= @@ -1196,8 +1198,8 @@ github.com/sassoftware/go-rpmutils v0.4.0/go.mod h1:3goNWi7PGAT3/dlql2lv3+MSN5jN github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e h1:7q6NSFZDeGfvvtIRwBrU/aegEYJYmvev0cHAwo17zZQ= github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e/go.mod h1:DkpGd78rljTxKAnTDPFqXSGxvETQnJyuSOQwsHycqfs= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/sebdah/goldie/v2 v2.7.1 h1:PkBHymaYdtvEkZV7TmyqKxdmn5/Vcj+8TpATWZjnG5E= -github.com/sebdah/goldie/v2 v2.7.1/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= +github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc= +github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= github.com/seccomp/libseccomp-golang v0.11.0 h1:SDkcBRqGLP+sezmMACkxO1EfgbghxIxnRKfd6mHUEis= github.com/seccomp/libseccomp-golang v0.11.0/go.mod h1:5m1Lk8E9OwgZTTVz4bBOer7JuazaBa+xTkM895tDiWc= github.com/secure-systems-lab/go-securesystemslib v0.10.0 h1:l+H5ErcW0PAehBNrBxoGv1jjNpGYdZ9RcheFkB2WI14= @@ -1240,8 +1242,8 @@ github.com/sigstore/sigstore v1.10.4/go.mod h1:tDiyrdOref3q6qJxm2G+JHghqfmvifB7h github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0= -github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik= @@ -1302,8 +1304,8 @@ github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaB github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/sylabs/sif/v2 v2.22.0 h1:Y+xXufp4RdgZe02SR3nWEg7S6q4tPWN237WHYzkDSKA= -github.com/sylabs/sif/v2 v2.22.0/go.mod h1:W1XhWTmG1KcG7j5a3KSYdMcUIFvbs240w/MMVW627hs= +github.com/sylabs/sif/v2 v2.24.0 h1:1wB5uMDUQYjk8AckTySaDcP9YnpMb1LyDRr1Jt9A10w= +github.com/sylabs/sif/v2 v2.24.0/go.mod h1:DbXWqWZ1hdLSU+K9ipdds5AmZeHWsyxCOj/oQakBa88= github.com/sylabs/squashfs v1.0.6 h1:PvJcDzxr+vIm2kH56mEMbaOzvGu79gK7P7IX+R7BDZI= github.com/sylabs/squashfs v1.0.6/go.mod h1:DlDeUawVXLWAsSRa085Eo0ZenGzAB32JdAUFaB0LZfE= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= @@ -1337,8 +1339,8 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 h1:jIVmlAFIqV3d+DOxazTR9v+zgj8+VYuQBzPgBZvWBHA= github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651/go.mod h1:b26F2tHLqaoRQf8DywqzVaV1MQ9yvjb0OMcNl7Nxu20= -github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 h1:0KGbf+0SMg+UFy4e1A/CPVvXn21f1qtWdeJwxZFoQG8= -github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0/go.mod h1:jLXFoL31zFaHKAAyZUh+sxiTDFe1L1ZHrcK2T1itVKA= +github.com/wagoodman/go-progress v0.0.0-20260303201901-10176f79b2c0 h1:EHsPe0Q0ANoLOZff1dBLAyeWLTA4sbPTpGI+2zb0FnM= +github.com/wagoodman/go-progress v0.0.0-20260303201901-10176f79b2c0/go.mod h1:g/D9uEUFp5YLyciwCpVsSOZOm56hfv4rzGJod6MlqIM= github.com/weaveworks/procspy v0.0.0-20150706124340-cb970aa190c3 h1:UC4iN/yCDCObTBhKzo34/R2U6qptTPmqbzG6UiQVMUQ= github.com/weaveworks/procspy v0.0.0-20150706124340-cb970aa190c3/go.mod h1:cJTfuBcxkdbj8Mabk4PPdaf0AXv9TYEJmkFxKcWxYY4= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -2141,18 +2143,20 @@ k8s.io/kubelet v0.35.0 h1:8cgJHCBCKLYuuQ7/Pxb/qWbJfX1LXIw7790ce9xHq7c= k8s.io/kubelet v0.35.0/go.mod h1:ciRzAXn7C4z5iB7FhG1L2CGPPXLTVCABDlbXt/Zz8YA= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= -modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= -modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= -modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= -modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= -modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= -modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -2161,8 +2165,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= -modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/pkg/containerprofilemanager/v1/event_reporting.go b/pkg/containerprofilemanager/v1/event_reporting.go index 077875fe1a..e262c5326a 100644 --- a/pkg/containerprofilemanager/v1/event_reporting.go +++ b/pkg/containerprofilemanager/v1/event_reporting.go @@ -32,15 +32,30 @@ func (cpm *ContainerProfileManager) ReportCapability(containerID, capability str cpm.logEventError(err, "capability", containerID) } -// resolveExecPath derives the path to record for an exec event. It is kept -// symmetric with the rule-side resolver in -// pkg/rulemanager/cel/libraries/parse/parse.go (parse.get_exec_path): prefer -// the kernel-authoritative exepath, then argv[0] when non-empty, then comm. -// Using args[0] unconditionally produces an empty Path when the syscall has -// an empty pathname (fexecve / execveat AT_EMPTY_PATH — the libpam helper -// invocation pattern), while the rule-side resolver falls back to comm — -// leaving the AP entry unreachable to ap.was_executed and producing spurious -// "Unexpected process launched" alerts. +// resolveExecPath chooses the canonical recorded path for an exec event. +// +// Precedence (kept symmetric with the rule-side +// pkg/rulemanager/cel/libraries/parse/parse.go::getExecPathWithExePath +// — divergence here would let runtime queries miss profile entries that +// were recorded under a different key): +// +// 1. exepath — kernel-authoritative; the only spoof-resistant source. +// argv[0] is user-controllable (man 3 exec: argv[0] is convention, +// not enforced) even when absolute, so it cannot be the recorded +// identity for security-relevant lookups. Repro: `exec -a /bin/sh +// sleep 2` yields cmdline=/bin/sh while /proc//exe stays +// /usr/bin/sleep — recording argv[0] would let a process masquerade +// as any allowed absolute path. +// 2. argv[0] when non-empty AND exepath empty (fexecve / execveat +// AT_EMPTY_PATH — the libpam helper invocation pattern). +// 3. comm as last resort. +// +// Busybox-image consequence: utilities like /bin/sh, /usr/bin/nslookup +// are symlinks to /bin/busybox. exepath resolves to /bin/busybox, so +// that's the recorded identity. User-authored profiles for busybox +// images must list /bin/busybox (not the symlink form) — the prior +// "absolute argv[0] wins" tier was an earlier experimental design that +// re-opened argv[0] spoofing and has been reverted. func resolveExecPath(exepath, comm string, args []string) string { if exepath != "" { return exepath diff --git a/pkg/containerprofilemanager/v1/event_reporting_test.go b/pkg/containerprofilemanager/v1/event_reporting_test.go index ee38683d53..0bfedd2a08 100644 --- a/pkg/containerprofilemanager/v1/event_reporting_test.go +++ b/pkg/containerprofilemanager/v1/event_reporting_test.go @@ -45,6 +45,40 @@ func TestResolveExecPath(t *testing.T) { args: []string{"sshd", "-i"}, want: "/usr/bin/curl", }, + { + // Busybox symlink: kernel resolves /bin/sh → /bin/busybox and + // reports exepath=/bin/busybox. The recorded identity is the + // kernel-resolved exepath, not the user-controllable argv[0] + // symlink form. User-authored profiles for busybox images + // must therefore list /bin/busybox. Trusting absolute argv[0] + // here would re-open the argv[0] spoofing hole pinned by the + // "absolute argv[0] spoof" case below. + name: "busybox symlink — exepath /bin/busybox wins over argv[0]=/bin/sh", + exepath: "/bin/busybox", + comm: "sh", + args: []string{"/bin/sh", "-c", "echo hi"}, + want: "/bin/busybox", + }, + { + name: "busybox symlink — exepath /bin/busybox wins over argv[0]=/usr/bin/nslookup", + exepath: "/bin/busybox", + comm: "nslookup", + args: []string{"/usr/bin/nslookup", "example.com"}, + want: "/bin/busybox", + }, + { + // `exec -a /bin/sh sleep 2` — attacker spoofs argv[0] to an + // allowed absolute path while running a different binary. + // The recorder MUST anchor on kernel-authoritative exepath + // so the recorded identity reflects the real executable, + // not the spoofed argv[0]. Regression pin for the parse.go + // matthyx blocker on PR #805 (2026-05-27). + name: "absolute argv[0] spoof — exec -a /bin/sh sleep", + exepath: "/usr/bin/sleep", + comm: "sleep", + args: []string{"/bin/sh", "2"}, + want: "/usr/bin/sleep", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/objectcache/containerprofilecache/projection_apply.go b/pkg/objectcache/containerprofilecache/projection_apply.go index 1354641886..0284885326 100644 --- a/pkg/objectcache/containerprofilecache/projection_apply.go +++ b/pkg/objectcache/containerprofilecache/projection_apply.go @@ -49,6 +49,7 @@ func Apply(spec *objectcache.RuleProjectionSpec, cp *v1beta1.ContainerProfile, c execsPaths := extractExecsPaths(cp) pcp.Execs = projectField(s.Execs, execsPaths, true) + pcp.ExecsByPath = extractExecsByPath(cp) endpointPaths := extractEndpointPaths(cp) pcp.Endpoints = projectField(s.Endpoints, endpointPaths, true) @@ -166,6 +167,39 @@ func extractExecsPaths(cp *v1beta1.ContainerProfile) []string { return paths } +// extractExecsByPath builds the path → []argv-vectors map used by +// argv-aware matchers (ap.was_executed_with_args via +// dynamicpathdetector.CompareExecArgs in storage). Multiple ExecCalls +// entries with the same Path APPEND to the per-path list: overlay merge +// in projectUserProfiles legitimately produces several ExecCalls per +// path, each with a distinct argv shape, and the consumer must accept +// any of them. +// +// nil-Args entries are stored as empty-but-non-nil slices so the +// downstream matcher distinguishes "present with empty args" (a +// deliberate must-be-empty constraint) from "absent" (no key). +// +// 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. +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 { + var entry []string + if e.Args == nil { + entry = []string{} + } else { + entry = make([]string, len(e.Args)) + copy(entry, e.Args) + } + m[e.Path] = append(m[e.Path], entry) + } + return m +} + func extractEndpointPaths(cp *v1beta1.ContainerProfile) []string { endpoints := make([]string, len(cp.Spec.Endpoints)) for i, e := range cp.Spec.Endpoints { diff --git a/pkg/objectcache/containerprofilecache/reconciler.go b/pkg/objectcache/containerprofilecache/reconciler.go index 0af5c8ee49..73c0269500 100644 --- a/pkg/objectcache/containerprofilecache/reconciler.go +++ b/pkg/objectcache/containerprofilecache/reconciler.go @@ -366,35 +366,52 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri userManagedNN = nil } } + // Determine the user-defined overlay name. Prefer the ref already stored + // on the entry (set when addContainer saw the label at container-start). + // If absent, re-read the current pod labels: IG enrichment can populate + // the `kubescape.io/user-defined-profile` label AFTER addContainer's + // initial fire, and without re-checking here a container whose label + // propagated late would never get its overlay merged. Test_32 race: + // curl-32-overlay AP exists in storage, but the cached entry was built + // before the label was visible, so was_executed misses /bin/sh and + // R0001 fires. + var overlayName, overlayNS string + if e.UserAPRef != nil { + overlayName = e.UserAPRef.Name + overlayNS = e.UserAPRef.Namespace + } else if pod := c.k8sObjectCache.GetPod(e.Namespace, e.PodName); pod != nil { + if v, ok := pod.Labels[helpersv1.UserDefinedProfileMetadataKey]; ok && v != "" { + overlayName = v + overlayNS = e.Namespace + } + } var userAP *v1beta1.ApplicationProfile var userNN *v1beta1.NetworkNeighborhood - if e.UserAPRef != nil { + if overlayName != "" { var userAPErr error _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userAP, userAPErr = c.storageClient.GetApplicationProfile(rctx, e.UserAPRef.Namespace, e.UserAPRef.Name) + userAP, userAPErr = c.storageClient.GetApplicationProfile(rctx, overlayNS, overlayName) return userAPErr }) if userAPErr != nil && e.UserAPRV != "" { logger.L().Debug("refreshOneEntry: user-defined AP fetch failed; keeping cached entry", helpers.String("containerID", id), - helpers.String("name", e.UserAPRef.Name), + helpers.String("name", overlayName), helpers.Error(userAPErr)) return } if userAPErr != nil { userAP = nil } - } - if e.UserNNRef != nil { var userNNErr error _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userNN, userNNErr = c.storageClient.GetNetworkNeighborhood(rctx, e.UserNNRef.Namespace, e.UserNNRef.Name) + userNN, userNNErr = c.storageClient.GetNetworkNeighborhood(rctx, overlayNS, overlayName) return userNNErr }) if userNNErr != nil && e.UserNNRV != "" { logger.L().Debug("refreshOneEntry: user-defined NN fetch failed; keeping cached entry", helpers.String("containerID", id), - helpers.String("name", e.UserNNRef.Name), + helpers.String("name", overlayName), helpers.Error(userNNErr)) return } diff --git a/pkg/objectcache/containerprofilecache/reconciler_test.go b/pkg/objectcache/containerprofilecache/reconciler_test.go index e76c384d6a..039224cb23 100644 --- a/pkg/objectcache/containerprofilecache/reconciler_test.go +++ b/pkg/objectcache/containerprofilecache/reconciler_test.go @@ -1399,3 +1399,93 @@ func TestSpecChange_TriggersReprojection(t *testing.T) { assert.Contains(t, after.Capabilities.Values, "SYS_PTRACE", "after spec change → SYS_PTRACE projected") assert.Contains(t, after.Capabilities.Values, "NET_ADMIN", "after spec change → NET_ADMIN projected") } + +// TestRefreshPicksUpLabelAppearingAfterAdd pins the fix for the user-defined- +// profile late-label race. When addContainer fires before the IG enrichment +// has populated the kubescape.io/user-defined-profile label on the container +// event, the initial entry is built with UserAPRef=nil. Without the fix, the +// reconciler's per-tick refresh would forever skip the user overlay fetch +// (guarded by `if e.UserAPRef != nil`), even after the label later appears in +// the k8s pod cache. The fix has refreshOneEntry re-read the pod labels and +// pick up a newly-visible overlay reference. Surface symptom this guards +// against: Test_32_UnexpectedProcessArguments seeing R0001 fire because +// /bin/sh isn't in the cached projection's Execs. +func TestRefreshPicksUpLabelAppearingAfterAdd(t *testing.T) { + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "base-cp", Namespace: "default", ResourceVersion: "1", + Annotations: map[string]string{helpersv1.StatusMetadataKey: helpersv1.Completed}, + }, + // Base CP has NO exec entries — only the late-arriving overlay can + // satisfy was_executed(/bin/sh). + } + overlay := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "curl-overlay", Namespace: "default", ResourceVersion: "42"}, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{{ + Name: "curl", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sh", Args: []string{"/bin/sh", "-c"}}, + }, + }}, + }, + } + client := &countingProfileClient{cp: cp, ap: overlay} + k8s := newControllableK8sCache() + c := newReconcilerCache(t, client, k8s, newCountingMetrics()) + + // Project execs (so the projection's Execs.Values is populated rather + // than collapsed by the projection-spec gating). + c.SetProjectionSpec(objectcache.RuleProjectionSpec{ + Execs: objectcache.FieldSpec{InUse: true, All: true}, + }) + + const id = "container-late-label" + const podName = "curl-32-abcdef" + const ns = "default" + + // Seed an entry as if addContainer ran BEFORE the label was visible: no + // UserAPRef, no UserAPRV. Mirrors what tryPopulateEntry's buildEntry + // would have produced if container.K8s.PodLabels was empty at the time. + entry := newEntry(cp, "curl", podName, ns, "uid-1") + entry.WorkloadName = "curl-32" + c.entries.Set(id, entry) + + // Pre-fix verification: the cached projection has no execs (base CP is + // empty), so was_executed-style lookups would miss /bin/sh. + before := c.GetProjectedContainerProfile(id) + require.NotNil(t, before) + assert.NotContains(t, before.Execs.Values, "/bin/sh", + "pre-fix sanity: base-only entry doesn't see /bin/sh") + require.Nil(t, entry.UserAPRef, "pre-fix sanity: entry has no overlay ref yet") + + // Label propagates: pod appears in the k8s cache WITH the + // user-defined-profile label pointing at curl-overlay. + k8s.setPod(ns, podName, &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: podName, Namespace: ns, UID: types.UID("uid-1"), + Labels: map[string]string{ + helpersv1.UserDefinedProfileMetadataKey: "curl-overlay", + }, + }, + }) + + // Run one refresh tick. + c.containerLocks.WithLock(id, func() { + current, _ := c.entries.Load(id) + c.refreshOneEntry(context.Background(), id, current) + }) + + // Post-fix: refresh re-read pod labels, fetched the overlay, merged it + // into the projection, and recorded UserAPRef on the new entry so future + // ticks fast-path through it. + after := c.GetProjectedContainerProfile(id) + require.NotNil(t, after) + assert.Contains(t, after.Execs.Values, "/bin/sh", + "overlay execs must merge into projection after label appears") + newEntry, ok := c.entries.Load(id) + require.True(t, ok, "entry still present after refresh") + require.NotNil(t, newEntry.UserAPRef, "UserAPRef recorded so subsequent ticks skip the GetPod re-read") + assert.Equal(t, "curl-overlay", newEntry.UserAPRef.Name) + assert.Equal(t, ns, newEntry.UserAPRef.Namespace) +} diff --git a/pkg/objectcache/containerprofilecache/test32_realpipeline_test.go b/pkg/objectcache/containerprofilecache/test32_realpipeline_test.go new file mode 100644 index 0000000000..e9e53b1a54 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/test32_realpipeline_test.go @@ -0,0 +1,241 @@ +package containerprofilecache + +import ( + "context" + "testing" + + helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" + "github.com/kubescape/node-agent/pkg/objectcache" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TestTest32_RealProjectionPipeline drives the REAL ContainerProfileCache +// projection — base CP (404 / synthetic) + the curl-32-overlay user AP, +// exactly as Test_32 deploys it — through addContainer → tryPopulateEntry → +// projectUserProfiles → Apply → extractExecsByPath, with NO mock shortcut. +// +// It then dumps the materialised ExecsByPath and applies the same +// CompareExecArgs walk that wasExecutedWithArgs performs, so we can see the +// GROUND TRUTH the production rule evaluator would see — answering whether +// R0040 silence comes from the projection (missing/empty vector) or from +// somewhere else (capture-side args). +func TestTest32_RealProjectionPipeline(t *testing.T) { + const wild = dynamicpathdetector.WildcardIdentifier + + // curl-32-overlay user AP. Container name MUST be "nginx" to match the + // InstanceID that primeSharedData/eventContainer build. The argv shapes + // mirror Test_32's profile. + userAP := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "curl-32-overlay", Namespace: "default", ResourceVersion: "1"}, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{{ + Name: "nginx", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/busybox", Args: []string{"/bin/sleep", wild}}, + {Path: "/bin/busybox", Args: []string{"/bin/sh", "-c", wild}}, + {Path: "/bin/busybox", Args: []string{"/bin/echo", "hello", wild}}, + }, + }}, + }, + } + + // Base CP 404 → cache synthesises an empty base, then overlays the user AP. + client := &fakeProfileClient{cp: nil, cpErr: assertErrNotFound("no-base"), ap: userAP} + c, k8s := newTestCache(t, client) + c.SetProjectionSpec(objectcache.RuleProjectionSpec{ + Execs: objectcache.FieldSpec{InUse: true, All: true}, + Hash: "test32-real", + }) + + id := "container-test32" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") + ct := eventContainer(id) + ct.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "curl-32-overlay"} + require.NoError(t, c.addContainer(ct, context.Background())) + + pcp := c.GetProjectedContainerProfile(id) + require.NotNil(t, pcp, "real projection must produce an entry") + + // GROUND TRUTH dump. + t.Logf("Execs.Values keys: %v", keysOf(pcp.Execs.Values)) + t.Logf("Execs.Patterns: %v", pcp.Execs.Patterns) + t.Logf("ExecsByPath[/bin/busybox] = %#v", pcp.ExecsByPath["/bin/busybox"]) + t.Logf("ExecsByPath full = %#v", pcp.ExecsByPath) + + // Replicate the exact matcher walk from wasExecutedWithArgs (post-fix: + // empty vector matches only empty runtime; non-empty via CompareExecArgs). + match := func(pathStr string, runtimeArgs []string) bool { + if _, ok := pcp.Execs.Values[pathStr]; ok { + if vectors, ok := pcp.ExecsByPath[pathStr]; ok { + for _, pv := range vectors { + if len(pv) == 0 { + if len(runtimeArgs) == 0 { + return true + } + continue + } + if dynamicpathdetector.CompareExecArgs(pv, runtimeArgs) { + return true + } + } + return false + } + return true // State 2: back-compat no-constraint + } + return false + } + + // /bin/busybox must be present in Values (R0001 stays silent — path known). + _, busyboxKnown := pcp.Execs.Values["/bin/busybox"] + require.True(t, busyboxKnown, "path /bin/busybox must be in Execs.Values (R0001 precondition)") + require.NotNil(t, pcp.ExecsByPath["/bin/busybox"], "ExecsByPath must carry the busybox vectors (else back-compat returns true and R0040 never fires)") + + cases := []struct { + name string + args []string + want bool // was_executed_with_args result; false => R0040 fires + }{ + {"sh -c matches", []string{"/bin/sh", "-c", "echo hi"}, true}, + {"sh -x -c mismatches", []string{"/bin/sh", "-x", "-c", "echo hi"}, false}, + {"echo hello matches", []string{"/bin/echo", "hello", "world", "from", "test"}, true}, + {"echo goodbye mismatches", []string{"/bin/echo", "goodbye", "world"}, false}, + } + for _, tc := range cases { + got := match("/bin/busybox", tc.args) + assert.Equalf(t, tc.want, got, + "real pipeline: was_executed_with_args(/bin/busybox, %v) = %v, want %v", tc.args, got, tc.want) + } +} + +func keysOf(m map[string]struct{}) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +// TestTest32_BaseCPBareVectorPoisonsR0040 reproduces the ACTUAL Test_32 +// production failure through the real projection pipeline. +// +// In production the curl-32 pod's `sleep infinity` startup exec is recorded +// into the consolidated base ContainerProfile as a /bin/busybox entry with +// empty/no args (the recorder stores Path + the observed argv; for a bare +// applet exec the args surface collapses to empty). That base CP is then +// overlaid with the user-defined curl-32-overlay AP, so the MERGED profile +// carries BOTH the bare /bin/busybox vector AND the three constrained ones. +// +// dynamicpathdetector.CompareExecArgs treats the empty bare vector as "no +// argv constraint" → returns true for ANY runtime args. When the matcher +// ORs across vectors, that one empty vector makes was_executed_with_args +// return true for every exec → R0040 never fires. This is the silence +// entlein observed on echo_goodbye / sh_dash_x while echo_hello / sh_dash_c +// (which legitimately match) stayed green. +// +// The test drives the REAL projectUserProfiles → Apply merge, then shows the +// raw-CompareExecArgs walk (deployed behaviour) returns the WRONG answer and +// the MatchExecArgs (strict) walk (the fix) returns the RIGHT answer. +func TestTest32_BaseCPBareVectorPoisonsR0040(t *testing.T) { + const wild = dynamicpathdetector.WildcardIdentifier + + // Base consolidated CP: the recorded /bin/busybox startup exec, bare args. + baseCP := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "replicaset-curl-32-curl", Namespace: "default", ResourceVersion: "5", + Annotations: map[string]string{ + helpersv1.CompletionMetadataKey: helpersv1.Full, + helpersv1.StatusMetadataKey: helpersv1.Completed, + }, + }, + Spec: v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/busybox", Args: nil}, // bare recorder entry — the poison + }, + }, + } + userAP := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "curl-32-overlay", Namespace: "default", ResourceVersion: "1"}, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{{ + Name: "nginx", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/busybox", Args: []string{"/bin/sleep", wild}}, + {Path: "/bin/busybox", Args: []string{"/bin/sh", "-c", wild}}, + {Path: "/bin/busybox", Args: []string{"/bin/echo", "hello", wild}}, + }, + }}, + }, + } + + client := &fakeProfileClient{cp: baseCP, ap: userAP} + c, k8s := newTestCache(t, client) + c.SetProjectionSpec(objectcache.RuleProjectionSpec{ + Execs: objectcache.FieldSpec{InUse: true, All: true}, + Hash: "test32-poison", + }) + + id := "container-test32-poison" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") + ct := eventContainer(id) + ct.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "curl-32-overlay"} + require.NoError(t, c.addContainer(ct, context.Background())) + + pcp := c.GetProjectedContainerProfile(id) + require.NotNil(t, pcp) + vectors := pcp.ExecsByPath["/bin/busybox"] + t.Logf("MERGED ExecsByPath[/bin/busybox] = %#v", vectors) + + // Confirm the merged profile really does carry a bare (empty) vector + // alongside the constrained ones — the production-faithful shape. + hasBare := false + for _, v := range vectors { + if len(v) == 0 { + hasBare = true + } + } + require.True(t, hasBare, "merged profile must contain the bare /bin/busybox vector (the poison)") + require.GreaterOrEqual(t, len(vectors), 4, "bare + 3 constrained vectors") + + rawWalk := func(runtimeArgs []string) bool { // deployed b94913b3 behaviour + for _, pv := range vectors { + if dynamicpathdetector.CompareExecArgs(pv, runtimeArgs) { + return true + } + } + return false + } + fixedWalk := func(runtimeArgs []string) bool { // MatchExecArgs(strict) behaviour + for _, pv := range vectors { + if len(pv) == 0 { + if len(runtimeArgs) == 0 { + return true + } + continue + } + if dynamicpathdetector.CompareExecArgs(pv, runtimeArgs) { + return true + } + } + return false + } + + echoGoodbye := []string{"/bin/echo", "goodbye", "world"} + shDashX := []string{"/bin/sh", "-x", "-c", "echo hi"} + + // Deployed behaviour: the empty vector poisons the OR → returns true → + // R0040 stays silent. This is the bug. + assert.True(t, rawWalk(echoGoodbye), "DEPLOYED: empty vector wrongly matches echo goodbye (reproduces R0040 silence)") + assert.True(t, rawWalk(shDashX), "DEPLOYED: empty vector wrongly matches sh -x -c (reproduces R0040 silence)") + + // Fixed behaviour: empty vector matches only empty runtime → mismatches + // fall through → returns false → R0040 fires. + assert.False(t, fixedWalk(echoGoodbye), "FIXED: echo goodbye no longer matches → R0040 fires") + assert.False(t, fixedWalk(shDashX), "FIXED: sh -x -c no longer matches → R0040 fires") + // Legitimate matches still hold under the fix. + assert.True(t, fixedWalk([]string{"/bin/echo", "hello", "world"}), "FIXED: echo hello still matches") + assert.True(t, fixedWalk([]string{"/bin/sh", "-c", "echo hi"}), "FIXED: sh -c still matches") +} diff --git a/pkg/objectcache/projection_types.go b/pkg/objectcache/projection_types.go index ed55d671b6..267c22f4ef 100644 --- a/pkg/objectcache/projection_types.go +++ b/pkg/objectcache/projection_types.go @@ -54,6 +54,18 @@ type ProjectedContainerProfile struct { IngressDomains ProjectedField IngressAddresses ProjectedField + // ExecsByPath carries the per-Path Args slices from cp.Spec.Execs so + // downstream consumers (ap.was_executed_with_args + R0040) can run + // argv-vector matching against the projected profile. Keyed by + // Exec.Path (same key used in Execs.Values / Execs.Patterns); the + // value is a LIST of argv vectors because a merged profile can carry + // multiple ExecCalls entries with the same Path and different argv + // shapes — overlay merge appends rather than replaces, and the + // consumer matches if ANY vector matches the runtime args. + // Empty/absent key means "no argv constraint" (back-compat for + // pre-projection profiles). + ExecsByPath map[string][][]string + SpecHash string SyncChecksum string PolicyByRuleId map[string]v1beta1.RulePolicy diff --git a/pkg/objectcache/v1/mock.go b/pkg/objectcache/v1/mock.go index c618e24506..8051c99a77 100644 --- a/pkg/objectcache/v1/mock.go +++ b/pkg/objectcache/v1/mock.go @@ -3,6 +3,7 @@ package objectcache import ( "context" "errors" + "strings" "sync" corev1 "k8s.io/api/core/v1" @@ -13,6 +14,7 @@ import ( "github.com/kubescape/node-agent/pkg/objectcache/callstackcache" "github.com/kubescape/node-agent/pkg/watcher" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" "k8s.io/apimachinery/pkg/runtime" ) @@ -153,8 +155,28 @@ func (r *RuleObjectCacheMock) GetProjectedContainerProfile(containerID string) * if (!specInstalled || spec.Execs.InUse) && len(cp.Spec.Execs) > 0 { pcp.Execs.All = true pcp.Execs.Values = make(map[string]struct{}, len(cp.Spec.Execs)) + pcp.ExecsByPath = make(map[string][][]string, len(cp.Spec.Execs)) for _, e := range cp.Spec.Execs { - pcp.Execs.Values[e.Path] = struct{}{} + // Mirror the real projectField routing: a path with a dynamic + // segment ("⋯") goes to Patterns (matched via CompareDynamic); + // a regular path goes to Values (exact lookup). ExecsByPath is + // keyed by the literal e.Path either way (same as the real + // extractExecsByPath). Without this split a ⋯-path (e.g. the + // versioned postgres binary) would land in Values and never + // reach the pattern branch of wasExecutedWithArgs. + if strings.Contains(e.Path, dynamicpathdetector.DynamicIdentifier) { + pcp.Execs.Patterns = append(pcp.Execs.Patterns, e.Path) + } else { + pcp.Execs.Values[e.Path] = struct{}{} + } + var entry []string + if e.Args == nil { + entry = []string{} + } else { + entry = make([]string, len(e.Args)) + copy(entry, e.Args) + } + pcp.ExecsByPath[e.Path] = append(pcp.ExecsByPath[e.Path], entry) } } diff --git a/pkg/rulemanager/cel/cel.go b/pkg/rulemanager/cel/cel.go index b064323df9..a5a999bbe8 100644 --- a/pkg/rulemanager/cel/cel.go +++ b/pkg/rulemanager/cel/cel.go @@ -106,17 +106,26 @@ func (c *CEL) registerExpression(expression string) error { return nil } - ast, issues := c.env.Compile(expression) + // Auto-rewrite deprecated helper call sites (e.g. 2-arg get_exec_path + // → 3-arg) for operators who upgrade the node-agent binary without + // updating their RuleBindings. Each rewrite surfaces a notice; this + // runs once per unique expression because of the cache check above. + compileExpr, notices := rewriteDeprecatedHelpers(expression) + for _, n := range notices { + logger.L().Warning("CEL expression: deprecated form", helpers.String("notice", n)) + } + + ast, issues := c.env.Compile(compileExpr) if issues != nil { // Cache nil to prevent repeated compilation attempts for invalid expressions c.programCache[expression] = nil - logger.L().Warning("CEL expression disabled: failed to compile", helpers.String("expression", expression), helpers.Error(issues.Err())) + logger.L().Warning("CEL expression disabled: failed to compile", helpers.String("expression", compileExpr), helpers.Error(issues.Err())) return fmt.Errorf("failed to compile expression: %s", issues.Err()) } optAst, optIssues := c.staticOptimizer.Optimize(c.env, ast) if optIssues != nil && optIssues.Err() != nil { - logger.L().Warning("CEL static optimization failed, falling back to unoptimized AST", helpers.String("expression", expression), helpers.Error(optIssues.Err())) + logger.L().Warning("CEL static optimization failed, falling back to unoptimized AST", helpers.String("expression", compileExpr), helpers.Error(optIssues.Err())) } else { ast = optAst } @@ -125,7 +134,7 @@ func (c *CEL) registerExpression(expression string) error { if err != nil { // Cache nil to prevent repeated program creation attempts c.programCache[expression] = nil - logger.L().Warning("CEL expression disabled: failed to create program", helpers.String("expression", expression), helpers.Error(err)) + logger.L().Warning("CEL expression disabled: failed to create program", helpers.String("expression", compileExpr), helpers.Error(err)) return fmt.Errorf("failed to create program: %s", err) } diff --git a/pkg/rulemanager/cel/expression_rewrite.go b/pkg/rulemanager/cel/expression_rewrite.go new file mode 100644 index 0000000000..048d5597c6 --- /dev/null +++ b/pkg/rulemanager/cel/expression_rewrite.go @@ -0,0 +1,79 @@ +package cel + +import ( + "fmt" + "regexp" +) + +// twoArgEventGetExecPath matches the canonical 2-arg call against the event +// variable — safely auto-upgradable to 3-arg by passing event.exepath as the +// kernel-authoritative resolution source. +var twoArgEventGetExecPath = regexp.MustCompile(`\bparse\.get_exec_path\(\s*event\.args\s*,\s*event\.comm\s*\)`) + +// anyTwoArgGetExecPath matches any 2-arg call site. After the canonical +// rewrite has run, anything still matching is non-canonical (caller used +// identifiers other than event.args / event.comm, or literal values) and +// cannot be safely upgraded without operator intent. The pattern +// `[^,)]+` halts at the first comma, so a 3-arg call does not match. +var anyTwoArgGetExecPath = regexp.MustCompile(`\bparse\.get_exec_path\([^,)]+,\s*[^,)]+\)`) + +// rewriteDeprecatedHelpers is a compatibility shim that auto-upgrades +// deprecated 2-arg parse.get_exec_path calls to the safer 3-arg form. +// Returns the rewritten expression plus operator-visible notices +// describing each rewrite or remaining concern. +// +// Today this handles ONE rewrite: the 2-arg parse.get_exec_path against +// the event variable. The 2-arg overload returns argv[0] (or comm) which +// is user-controllable — `exec -a /bin/sh sleep 2` yields cmdline=/bin/sh +// while /proc//exe is /usr/bin/sleep, so any ap.was_executed-style +// lookup against the 2-arg result is bypassable. The 3-arg form anchors +// on event.exepath (kernel-authoritative) and falls back to argv[0]/comm +// only for fexecve / AT_EMPTY_PATH cases. +// +// Auto-rewriting is intentionally limited to expressions that name the +// event variable explicitly — those are guaranteed to be evaluated against +// an exec event that carries event.exepath, so the rewrite is contract- +// preserving. Non-canonical 2-arg calls (custom identifiers or literals) +// cannot be auto-upgraded; they surface a notice so operators can migrate +// manually. +// +// IMPLEMENTATION NOTE: this is intentionally a text-level regex rewrite, +// not an AST transform. The theoretical risk of mutating a string +// literal that happens to contain the exact 2-arg call pattern (e.g. +// `event.message == "parse.get_exec_path(event.args, event.comm)"`) is +// accepted as negligible: CEL security-rule expressions do not embed +// the helper's call form as a literal, and the alternative (CEL parser +// dependency at this layer + AST walker + serializer) is disproportionate +// to the risk. If a future need surfaces a real-world false-rewrite, +// upgrade to AST-based transformation then. +// +// DEPRECATION NOTICE: the 2-arg parse.get_exec_path overload is +// deprecated. This shim auto-upgrades calls at registration time so +// operators who upgrade the node-agent binary without updating their +// RuleBindings retain the kernel-authoritative behaviour. The shim and +// the 2-arg overload itself will be removed in a future major version; +// migrate RuleBindings to call the 3-arg form explicitly to avoid the +// removal cliff. +func rewriteDeprecatedHelpers(expression string) (string, []string) { + var notices []string + + if twoArgEventGetExecPath.MatchString(expression) { + original := expression + expression = twoArgEventGetExecPath.ReplaceAllString(expression, + "parse.get_exec_path(event.args, event.comm, event.exepath)") + notices = append(notices, fmt.Sprintf( + "[DEPRECATED, to be removed in a future major release] auto-rewrote 2-arg parse.get_exec_path → 3-arg with event.exepath fallback; the 2-arg form trusts argv[0] which is user-controllable and bypassable via `exec -a `. Update RuleBindings to call the 3-arg form explicitly. Original expression: %q", + original)) + } + + // After auto-rewrite the canonical pattern is gone. Anything still + // matching the 2-arg shape is non-canonical — flag it for operator + // attention but do not modify. + for _, m := range anyTwoArgGetExecPath.FindAllString(expression, -1) { + notices = append(notices, fmt.Sprintf( + "2-arg parse.get_exec_path with non-event arguments cannot be auto-upgraded and remains argv[0]-spoofable: %s", + m)) + } + + return expression, notices +} diff --git a/pkg/rulemanager/cel/expression_rewrite_test.go b/pkg/rulemanager/cel/expression_rewrite_test.go new file mode 100644 index 0000000000..4b63955a7d --- /dev/null +++ b/pkg/rulemanager/cel/expression_rewrite_test.go @@ -0,0 +1,219 @@ +package cel + +import ( + "strings" + "testing" + + "github.com/google/cel-go/cel" + "github.com/kubescape/node-agent/pkg/config" + "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/parse" +) + +func TestRewriteDeprecatedHelpers(t *testing.T) { + tests := []struct { + name string + input string + wantOutput string + wantNoticeCount int + wantNoticeContains string + }{ + { + name: "canonical 2-arg rewritten to 3-arg", + input: `!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm))`, + wantOutput: `!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath))`, + wantNoticeCount: 1, + wantNoticeContains: "auto-rewrote", + }, + { + name: "3-arg form already — unchanged, no notice", + input: `!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath))`, + wantOutput: `!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath))`, + wantNoticeCount: 0, + }, + { + name: "whitespace inside the call tolerated", + input: `parse.get_exec_path( event.args , event.comm )`, + wantOutput: `parse.get_exec_path(event.args, event.comm, event.exepath)`, + wantNoticeCount: 1, + }, + { + name: "multiple canonical 2-arg calls in one expression — all rewritten, single notice", + input: `parse.get_exec_path(event.args, event.comm) + parse.get_exec_path(event.args, event.comm)`, + wantOutput: `parse.get_exec_path(event.args, event.comm, event.exepath) + parse.get_exec_path(event.args, event.comm, event.exepath)`, + wantNoticeCount: 1, + }, + { + name: "non-event 2-arg — cannot auto-upgrade, notice emitted", + input: `parse.get_exec_path(myArgs, myComm)`, + wantOutput: `parse.get_exec_path(myArgs, myComm)`, + wantNoticeCount: 1, + wantNoticeContains: "cannot be auto-upgraded", + }, + { + name: "literal-args 2-arg — cannot auto-upgrade", + input: `parse.get_exec_path(['/bin/ls'], 'ls')`, + wantOutput: `parse.get_exec_path(['/bin/ls'], 'ls')`, + wantNoticeCount: 1, + wantNoticeContains: "cannot be auto-upgraded", + }, + { + name: "mixed canonical + non-canonical — canonical rewritten, both flagged", + input: `parse.get_exec_path(event.args, event.comm) && parse.get_exec_path(otherArgs, otherComm)`, + wantOutput: `parse.get_exec_path(event.args, event.comm, event.exepath) && parse.get_exec_path(otherArgs, otherComm)`, + wantNoticeCount: 2, + }, + { + name: "no get_exec_path at all — unchanged", + input: `event.containerId == 'foo'`, + wantOutput: `event.containerId == 'foo'`, + wantNoticeCount: 0, + }, + { + name: "default-rules.yaml line 434 form (chained call, multiple sites)", + input: `!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm)) && k8s.get_container_mount_paths(ns, pod, c).exists(mount, event.exepath.startsWith(mount) || parse.get_exec_path(event.args, event.comm).startsWith(mount))`, + wantOutput: `!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath)) && k8s.get_container_mount_paths(ns, pod, c).exists(mount, event.exepath.startsWith(mount) || parse.get_exec_path(event.args, event.comm, event.exepath).startsWith(mount))`, + wantNoticeCount: 1, + }, + { + name: "method-call chain on get_exec_path result", + input: `parse.get_exec_path(event.args, event.comm).startsWith('/dev/shm/')`, + wantOutput: `parse.get_exec_path(event.args, event.comm, event.exepath).startsWith('/dev/shm/')`, + wantNoticeCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, notices := rewriteDeprecatedHelpers(tt.input) + if got != tt.wantOutput { + t.Errorf("rewrite output mismatch\n input: %s\n got: %s\n expected: %s", tt.input, got, tt.wantOutput) + } + if len(notices) != tt.wantNoticeCount { + t.Errorf("notice count: got %d, want %d\n notices: %v", len(notices), tt.wantNoticeCount, notices) + } + if tt.wantNoticeContains != "" { + found := false + for _, n := range notices { + if strings.Contains(n, tt.wantNoticeContains) { + found = true + break + } + } + if !found { + t.Errorf("expected at least one notice containing %q; got: %v", tt.wantNoticeContains, notices) + } + } + }) + } +} + +// TestRewriteIntegration_2ArgExpressionResolvesViaExepath proves the +// end-to-end SF-A guarantee: a RuleBinding shipped with the older 2-arg +// `parse.get_exec_path(event.args, event.comm)` form, when run through +// rewriteDeprecatedHelpers and compiled+evaluated by CEL, yields +// kernel-authoritative `event.exepath` — i.e. the spoof case `exec -a +// /bin/sh sleep` resolves to /usr/bin/sleep instead of /bin/sh, exactly +// as the 3-arg form would. +// +// This is the contract operators rely on when they upgrade the +// node-agent binary without touching their RuleBindings. +func TestRewriteIntegration_2ArgExpressionResolvesViaExepath(t *testing.T) { + env, err := cel.NewEnv( + cel.Variable("event", cel.AnyType), + parse.Parse(config.Config{}), + ) + if err != nil { + t.Fatalf("failed to build CEL env: %v", err) + } + + cases := []struct { + name string + oldExpr string // 2-arg form as written in legacy RuleBindings + event map[string]any + expected string + }{ + { + name: "absolute argv[0] spoof — exec -a /bin/sh sleep", + oldExpr: `parse.get_exec_path(event.args, event.comm)`, + event: map[string]any{ + "args": []string{"/bin/sh", "2"}, + "comm": "sleep", + "exepath": "/usr/bin/sleep", + }, + expected: "/usr/bin/sleep", + }, + { + name: "bare argv[0] spoof — argv0=sshd, real exe curl", + oldExpr: `parse.get_exec_path(event.args, event.comm)`, + event: map[string]any{ + "args": []string{"sshd", "-i"}, + "comm": "curl", + "exepath": "/usr/bin/curl", + }, + expected: "/usr/bin/curl", + }, + { + name: "shell-typed `ls` (bare argv[0]) — fork-shell case", + oldExpr: `parse.get_exec_path(event.args, event.comm)`, + event: map[string]any{ + "args": []string{"ls", "-la"}, + "comm": "ls", + "exepath": "/bin/ls", + }, + expected: "/bin/ls", + }, + { + name: "busybox — exepath /bin/busybox wins over argv[0]=/bin/sh", + oldExpr: `parse.get_exec_path(event.args, event.comm)`, + event: map[string]any{ + "args": []string{"/bin/sh", "-c", "echo hi"}, + "comm": "sh", + "exepath": "/bin/busybox", + }, + expected: "/bin/busybox", + }, + { + name: "fexecve (libpam) — exepath empty, falls back to argv[0]", + oldExpr: `parse.get_exec_path(event.args, event.comm)`, + event: map[string]any{ + "args": []string{"unix_chkpwd", "root"}, + "comm": "unix_chkpwd", + "exepath": "", + }, + expected: "unix_chkpwd", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // 1. Auto-rewrite, as registerExpression does. + rewritten, notices := rewriteDeprecatedHelpers(tc.oldExpr) + if len(notices) == 0 { + t.Fatalf("expected at least one rewrite notice for input %q", tc.oldExpr) + } + + // 2. Compile + run the REWRITTEN expression through the real + // CEL pipeline (same env the rule manager uses). + ast, issues := env.Compile(rewritten) + if issues != nil && issues.Err() != nil { + t.Fatalf("rewritten expression failed to compile: %v\n rewritten: %s", issues.Err(), rewritten) + } + program, err := env.Program(ast) + if err != nil { + t.Fatalf("program creation failed: %v", err) + } + result, _, err := program.Eval(map[string]any{"event": tc.event}) + if err != nil { + t.Fatalf("eval failed: %v", err) + } + got, ok := result.Value().(string) + if !ok { + t.Fatalf("expected string result, got %T (%v)", result.Value(), result.Value()) + } + if got != tc.expected { + t.Errorf("integrated rewrite+eval mismatch\n legacy expression: %s\n rewritten: %s\n event: %v\n got: %q\n want: %q", + tc.oldExpr, rewritten, tc.event, got, tc.expected) + } + }) + } +} diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go index b69a69c0ea..0cb4156af8 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go @@ -1,8 +1,9 @@ package applicationprofile import ( - "github.com/google/cel-go/common/types" + "slices" + "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/kubescape/go-logger" "github.com/kubescape/go-logger/helpers" @@ -10,6 +11,7 @@ import ( "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" + corev1 "k8s.io/api/core/v1" ) func (l *apLibrary) wasExecuted(containerID, path ref.Val) ref.Val { @@ -70,12 +72,11 @@ func (l *apLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val return types.MaybeNoSuchOverloadErr(path) } - // v1 limitation for rule authors: wasExecutedWithArgs is currently equivalent - // to wasExecuted — the args list is validated but not matched against. Any - // execution of the given path returns true regardless of its arguments. Full - // argument matching (ExecArgsByPath) will be added in a future version. - _ = args - if _, err := celparse.ParseList[string](args); err != nil { + // Parse the runtime args list from CEL. Empty list is valid ("exec'd + // with no args") and matches a profile entry whose Args is also empty + // or absent (empty profile Args = "no argv constraint"). + runtimeArgs, err := celparse.ParseList[string](args) + if err != nil { return types.NewErr("failed to parse args: %v", err) } @@ -84,30 +85,151 @@ func (l *apLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val return types.Bool(true) } - cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) - if err != nil { + cp, _, perr := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) + if perr != nil { // Return a special error that will NOT be cached, allowing retry when profile becomes available. // The caller should convert this to false after the cache layer. - return cache.NewProfileNotAvailableErr("%v", err) + return cache.NewProfileNotAvailableErr("%v", perr) } + // Exact path match. ExecsByPath absent-vs-empty asymmetry: three states. + // + // 1. Path absent from cp.Execs.Values: + // Profile doesn't allow this exec at all → fall through to + // the pattern-match loop, then to false. + // + // 2. Path in Values, ABSENT from ExecsByPath (map lookup ok=false): + // Legacy / pre-args-projection profiles. Treated as + // "no argv constraint" — back-compat MATCH any args. + // This is the intentional fallback for profiles compiled + // against older storage versions that didn't populate the + // composite ExecsByPath surface. + // + // 3. Path in Values, PRESENT in ExecsByPath: + // Match each recorded argv vector with + // dynamicpathdetector.MatchExecArgs(profileArgs, true, runtimeArgs). + // argsRequired=true selects storage's STRICT anchored matcher: + // an empty recorded vector matches ONLY an empty runtime argv, so + // a recorder/synthetic "ran with no args" entry does NOT act as a + // wildcard and poison the multi-vector OR (the #805 production + // failure). The matcher handles WildcardIdentifier "*", bare + // DynamicIdentifier "⋯", and embedded-⋯ path tokens (the postgres + // versioned-binary case) — see storage's compare_exec_args.go. if _, ok := cp.Execs.Values[pathStr]; ok { - return types.Bool(true) + if vectors, ok := cp.ExecsByPath[pathStr]; ok { + for _, profileArgs := range vectors { + if dynamicpathdetector.MatchExecArgs(profileArgs, true, runtimeArgs) { + return types.Bool(true) + } + } + } else { + // State 2: ExecsByPath absent → back-compat "no argv constraint". + return types.Bool(true) + } } - // Check Patterns (dynamic-segment entries). + // Pattern path match: dynamic-segment paths in cp.Execs.Patterns. + // Args matching mirrors the exact-path case — match against any + // argv vector recorded for that pattern key. for _, execPath := range cp.Execs.Patterns { if dynamicpathdetector.CompareDynamic(execPath, pathStr) { - return types.Bool(true) + if vectors, ok := cp.ExecsByPath[execPath]; ok { + for _, profileArgs := range vectors { + if dynamicpathdetector.MatchExecArgs(profileArgs, true, runtimeArgs) { + return types.Bool(true) + } + } + } else { + return types.Bool(true) + } } } - if l.isExecInPodSpec(containerID, path).Value().(bool) { + // Pod-spec fallback. Unlike was_executed (which only needs the path to + // appear in the pod spec), the args-aware query must compare the FULL + // runtime argv against a declared command vector — the container's + // startup command (Command ++ Args) or a lifecycle hook's Exec.Command. + // Path-only matching here would let any exec of a declared binary with + // unexpected arguments pass silently and suppress R0040. + if l.isExecWithArgsInPodSpec(containerIDStr, runtimeArgs) { return types.Bool(true) } return types.Bool(false) } +// isExecWithArgsInPodSpec reports whether the full runtime argv vector exactly +// matches a command vector DECLARED in the pod spec for this container: the +// container's startup command (Command ++ Args) or a lifecycle hook's +// Exec.Command. It is the args-aware counterpart of isExecInPodSpec, used by +// wasExecutedWithArgs so that an exec of a declared binary with unexpected +// arguments is not silently treated as allowed. +func (l *apLibrary) isExecWithArgsInPodSpec(containerIDStr string, runtimeArgs []string) bool { + if l.objectCache == nil { + return false + } + + podSpec, err := profilehelper.GetPodSpec(l.objectCache, containerIDStr) + if err != nil { + logger.L().Error("isExecWithArgsInPodSpec - failed to get pod spec", helpers.String("error", err.Error())) + return false + } + containerName := profilehelper.GetContainerName(l.objectCache, containerIDStr) + if containerName == "" { + logger.L().Error("isExecWithArgsInPodSpec - failed to get container name", helpers.String("containerID", containerIDStr)) + return false + } + + // match compares the runtime argv against one declared command vector. + match := func(declared []string) bool { + return len(declared) > 0 && slices.Equal(declared, runtimeArgs) + } + // startupArgv is the argv the kubelet execs for the entrypoint: Command + // followed by Args. Only meaningful when Command is explicitly set; + // otherwise the image ENTRYPOINT is used, which is not visible from the + // pod spec, so we cannot (and must not) claim a match. + startupArgv := func(command, args []string) []string { + if len(command) == 0 { + return nil + } + argv := make([]string, 0, len(command)+len(args)) + argv = append(argv, command...) + argv = append(argv, args...) + return argv + } + // lifecycleMatch checks PreStop/PostStart exec hooks. It only reports a + // match for the return decision; marking the PreStop trigger is handled by + // was_executed (isExecInPodSpec), which the R0040 rule evaluates first. + lifecycleMatch := func(lc *corev1.Lifecycle) bool { + if lc == nil { + return false + } + if lc.PreStop != nil && lc.PreStop.Exec != nil && match(lc.PreStop.Exec.Command) { + return true + } + if lc.PostStart != nil && lc.PostStart.Exec != nil && match(lc.PostStart.Exec.Command) { + return true + } + return false + } + + for _, c := range podSpec.Containers { + if c.Name == containerName { + return match(startupArgv(c.Command, c.Args)) || lifecycleMatch(c.Lifecycle) + } + } + for _, c := range podSpec.InitContainers { + if c.Name == containerName { + return match(startupArgv(c.Command, c.Args)) || lifecycleMatch(c.Lifecycle) + } + } + for _, c := range podSpec.EphemeralContainers { + if c.Name == containerName { + return match(startupArgv(c.Command, c.Args)) || lifecycleMatch(c.Lifecycle) + } + } + return false +} + func (l *apLibrary) isExecInPodSpec(containerID, path ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec_podspec_test.go b/pkg/rulemanager/cel/libraries/applicationprofile/exec_podspec_test.go new file mode 100644 index 0000000000..0a8c06f7c0 --- /dev/null +++ b/pkg/rulemanager/cel/libraries/applicationprofile/exec_podspec_test.go @@ -0,0 +1,269 @@ +package applicationprofile + +import ( + "testing" + + "github.com/google/cel-go/cel" + "github.com/goradd/maps" + "github.com/kubescape/node-agent/pkg/config" + "github.com/kubescape/node-agent/pkg/objectcache" + objectcachev1 "github.com/kubescape/node-agent/pkg/objectcache/v1" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" +) + +// newPodSpecExecEnv builds an apLibrary CEL env whose object cache carries an +// EMPTY application profile (so was_executed_with_args is forced down to the +// pod-spec fallback) plus the supplied pod spec, for the main container named +// "test-container". +func newPodSpecExecEnv(t *testing.T, podSpec *corev1.PodSpec) *cel.Env { + t.Helper() + return newPodSpecExecEnvTyped(t, objectcache.Container, "test-container", podSpec) +} + +// newPodSpecExecEnvTyped is newPodSpecExecEnv parameterised by container type +// (Container / InitContainer / EphemeralContainer) and name, so the pod-spec +// fallback can be exercised for init and ephemeral containers too. +func newPodSpecExecEnvTyped(t *testing.T, ctype objectcache.ContainerType, cname string, podSpec *corev1.PodSpec) *cel.Env { + t.Helper() + objCache := objectcachev1.RuleObjectCacheMock{ + ContainerIDToSharedData: maps.NewSafeMap[string, *objectcache.WatchedContainerData](), + } + objCache.SetSharedContainerData("test-container-id", &objectcache.WatchedContainerData{ + ContainerType: ctype, + ContainerInfos: map[objectcache.ContainerType][]objectcache.ContainerInfo{ + ctype: {{Name: cname}}, + }, + }) + // Empty Execs → no Values/Patterns/ExecsByPath entry for the path, so + // wasExecutedWithArgs skips argv matching and reaches the pod-spec fallback. + profile := &v1beta1.ApplicationProfile{} + profile.Spec.Containers = append(profile.Spec.Containers, v1beta1.ApplicationProfileContainer{ + Name: cname, + }) + objCache.SetApplicationProfile(profile) + objCache.SetPodSpec(podSpec) + + 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) + } + return env +} + +func evalWasExecutedWithArgs(t *testing.T, env *cel.Env, path string, args []string) bool { + t.Helper() + ast, issues := env.Compile(`ap.was_executed_with_args(containerID, path, args)`) + if issues != nil && issues.Err() != nil { + t.Fatalf("failed to compile expression: %v", issues.Err()) + } + prog, err := env.Program(ast) + if err != nil { + t.Fatalf("failed to create program: %v", err) + } + out, _, err := prog.Eval(map[string]any{ + "containerID": "test-container-id", + "path": path, + "args": args, + }) + if err != nil { + t.Fatalf("failed to evaluate expression: %v", err) + } + return out.Value().(bool) +} + +// TestExecWithArgs_PodSpecFallbackFullVector pins the contract flagged in the +// node-agent#805 review (2026-06-04): the pod-spec fallback in +// was_executed_with_args must compare the FULL runtime argv vector against the +// declared command vector, not just the executable path. Otherwise an exec of +// a declared binary with UNEXPECTED arguments is silently treated as allowed +// and R0040 ("Unexpected process arguments") never fires. +// +// expectedResult: true = silent (argv matches the declared command — allowed) +// +// false = R0040 must fire (argv differs from anything declared) +func TestExecWithArgs_PodSpecFallbackFullVector(t *testing.T) { + podSpec := &corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "test-container", + Command: []string{"/bin/bash", "-c", "sleep infinity"}, + }, + }, + } + env := newPodSpecExecEnv(t, podSpec) + + testCases := []struct { + name string + path string + args []string + expectedResult bool + }{ + { + // The exact declared startup command is legitimately expected. + name: "exact declared command argv is allowed", + path: "/bin/bash", + args: []string{"/bin/bash", "-c", "sleep infinity"}, + expectedResult: true, + }, + { + // Review repro: extra -x flag. argv != declared command. + name: "extra -x flag must fire R0040", + path: "/bin/bash", + args: []string{"/bin/bash", "-x", "-c", "sleep infinity"}, + expectedResult: false, + }, + { + // Same binary, different trailing argument. + name: "different trailing arg must fire R0040", + path: "/bin/bash", + args: []string{"/bin/bash", "-c", "whoami"}, + expectedResult: false, + }, + { + // Bare binary, no args — declared command has args, so mismatch. + name: "bare binary (declared command has args) must fire R0040", + path: "/bin/bash", + args: []string{"/bin/bash"}, + expectedResult: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := evalWasExecutedWithArgs(t, env, tc.path, tc.args) + assert.Equal(t, tc.expectedResult, got, + "was_executed_with_args(%s, %v): pod-spec fallback must match the full argv vector", + tc.path, tc.args) + }) + } +} + +// TestExecWithArgs_PodSpecCommandArgsSplit covers the kubelet semantics where a +// container declares Command and Args separately: the executed argv is +// Command ++ Args, and the fallback must match against that concatenation. +func TestExecWithArgs_PodSpecCommandArgsSplit(t *testing.T) { + podSpec := &corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "test-container", + Command: []string{"/bin/bash"}, + Args: []string{"-c", "sleep infinity"}, + }, + }, + } + env := newPodSpecExecEnv(t, podSpec) + + cases := []struct { + name string + args []string + want bool + }{ + {"command++args concatenation is allowed", []string{"/bin/bash", "-c", "sleep infinity"}, true}, + {"extra flag must fire R0040", []string{"/bin/bash", "-x", "-c", "sleep infinity"}, false}, + {"only command part must fire R0040", []string{"/bin/bash"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, evalWasExecutedWithArgs(t, env, "/bin/bash", tc.args), tc.args) + }) + } +} + +// TestExecWithArgs_PodSpecImageEntrypointNoMatch pins that when a container +// declares NO Command (the image ENTRYPOINT, invisible from the pod spec), the +// fallback must NOT claim a match — otherwise it would silently allow arbitrary +// argv it cannot actually verify. +func TestExecWithArgs_PodSpecImageEntrypointNoMatch(t *testing.T) { + podSpec := &corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "test-container"}, // no Command/Args → image entrypoint + }, + } + env := newPodSpecExecEnv(t, podSpec) + assert.False(t, evalWasExecutedWithArgs(t, env, "/bin/bash", + []string{"/bin/bash", "-c", "sleep infinity"}), + "no declared Command → fallback must not claim a match") +} + +// TestExecWithArgs_PodSpecLifecycleHooks covers PreStop / PostStart exec hooks: +// the declared hook command vector is allowed, anything else fires R0040. +func TestExecWithArgs_PodSpecLifecycleHooks(t *testing.T) { + podSpec := &corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "test-container", + Command: []string{"/bin/bash", "-c", "sleep infinity"}, + Lifecycle: &corev1.Lifecycle{ + PreStop: &corev1.LifecycleHandler{ + Exec: &corev1.ExecAction{Command: []string{"/bin/sh", "-c", "nginx -s quit"}}, + }, + PostStart: &corev1.LifecycleHandler{ + Exec: &corev1.ExecAction{Command: []string{"/bin/sh", "-c", "echo started"}}, + }, + }, + }, + }, + } + env := newPodSpecExecEnv(t, podSpec) + + cases := []struct { + name string + args []string + want bool + }{ + {"prestop exact command allowed", []string{"/bin/sh", "-c", "nginx -s quit"}, true}, + {"poststart exact command allowed", []string{"/bin/sh", "-c", "echo started"}, true}, + {"prestop binary with different args fires R0040", []string{"/bin/sh", "-c", "rm -rf /"}, false}, + {"undeclared command fires R0040", []string{"/bin/sh", "-c", "curl evil"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, evalWasExecutedWithArgs(t, env, "/bin/sh", tc.args), tc.args) + }) + } +} + +// TestExecWithArgs_PodSpecInitContainer exercises the fallback for an init +// container (separate pod-spec list, resolved via shared-data ContainerType). +func TestExecWithArgs_PodSpecInitContainer(t *testing.T) { + podSpec := &corev1.PodSpec{ + InitContainers: []corev1.Container{ + { + Name: "test-init", + Command: []string{"/bin/sh", "-c", "init-setup"}, + }, + }, + } + env := newPodSpecExecEnvTyped(t, objectcache.InitContainer, "test-init", podSpec) + assert.True(t, evalWasExecutedWithArgs(t, env, "/bin/sh", []string{"/bin/sh", "-c", "init-setup"}), + "init container declared command must be allowed") + assert.False(t, evalWasExecutedWithArgs(t, env, "/bin/sh", []string{"/bin/sh", "-x", "-c", "init-setup"}), + "init container argv mismatch must fire R0040") +} + +// TestExecWithArgs_PodSpecEphemeralContainer exercises the fallback for an +// ephemeral container (debug container injected at runtime). +func TestExecWithArgs_PodSpecEphemeralContainer(t *testing.T) { + podSpec := &corev1.PodSpec{ + EphemeralContainers: []corev1.EphemeralContainer{ + { + EphemeralContainerCommon: corev1.EphemeralContainerCommon{ + Name: "test-ephemeral", + Command: []string{"/bin/sh", "-c", "debug"}, + }, + }, + }, + } + env := newPodSpecExecEnvTyped(t, objectcache.EphemeralContainer, "test-ephemeral", podSpec) + assert.True(t, evalWasExecutedWithArgs(t, env, "/bin/sh", []string{"/bin/sh", "-c", "debug"}), + "ephemeral container declared command must be allowed") + assert.False(t, evalWasExecutedWithArgs(t, env, "/bin/sh", []string{"/bin/sh", "-c", "exfiltrate"}), + "ephemeral container argv mismatch must fire R0040") +} diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go b/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go index 085e2215fc..5465b7449d 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go @@ -9,6 +9,7 @@ import ( "github.com/kubescape/node-agent/pkg/objectcache" objectcachev1 "github.com/kubescape/node-agent/pkg/objectcache/v1" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" "github.com/stretchr/testify/assert" ) @@ -200,12 +201,16 @@ func TestExecWithArgsInProfile(t *testing.T) { expectedResult: true, }, { - // v1 degradation: args projection is out of scope; path-only matching. + // Profile entry has Args=["-la", "/tmp"]; runtime args differ at + // position 1 (/home vs /tmp). CompareExecArgs walks the literal + // segments, so the mismatch surfaces and the match fails. This + // is the contract R0040 ("Unexpected process arguments") relies + // on: path-allowed exec with mismatching argv must NOT match. name: "Path matches but args don't match", containerID: "test-container-id", path: "/bin/ls", args: []string{"-la", "/home"}, - expectedResult: true, + expectedResult: false, }, { name: "Path doesn't exist", @@ -229,12 +234,17 @@ func TestExecWithArgsInProfile(t *testing.T) { expectedResult: true, }, { - // v1 degradation: args projection is out of scope; path-only matching. + // Profile entry has Args=["-la", "/tmp"] (non-empty); runtime + // args is empty. matchExecArgsStrict treats this as anchored: + // the profile demands at least the literal "-la" but there's + // no runtime arg to consume. No match. (If the profile entry + // also wanted to allow the no-args case, it would carry a + // second ExecCalls entry for the same Path with empty Args.) name: "Empty args list", containerID: "test-container-id", path: "/bin/ls", args: []string{}, - expectedResult: true, + expectedResult: false, }, } @@ -326,3 +336,228 @@ func TestExecWithArgsCompilation(t *testing.T) { t.Fatalf("failed to create program: %v", err) } } + +// TestExecWithArgsBusyboxMultiVector pins the Test_32 component-test contract +// at the unit level. In busybox-symlink containers the kernel-resolved +// /proc//exe (and therefore event.exepath) is /bin/busybox for every +// applet exec, so parse.get_exec_path routes was_executed_with_args queries +// to /bin/busybox regardless of which symlink was invoked. The profile +// therefore carries multiple ExecCalls entries with the SAME Path +// (/bin/busybox) and DIFFERENT Args vectors — one per allowed argv shape. +// The matcher must walk every vector and accept if ANY matches. +// +// Failure modes this guards against: +// - The wasExecutedWithArgs stub that returned true for any path-in-profile +// match regardless of args (the bug that suppressed R0040 entirely on +// #805's first 3 CT runs). +// - extractExecsByPath overwriting prior entries when Paths collide (the +// "last-write-wins" concern CodeRabbit/matthyx raised on PR #807). +// +// Test_32 has 4 subtests; this pins the contract for each: +// +// sh_dash_c_matches_wildcard_trailing — argv matches profile [sh, -c, *]. +// sh_dash_x_mismatches_R0040 — argv mismatches at literal anchor. +// echo_hello_matches_wildcard_trailing — argv matches profile [echo, hello, *]. +// echo_goodbye_mismatches_R0040 — argv mismatches at literal "hello". +func TestExecWithArgsBusyboxMultiVector(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{ + // Three ExecCalls share Path=/bin/busybox with distinct argv + // shapes. The projection layer appends them all into + // ExecsByPath["/bin/busybox"]; the matcher walks every + // vector and accepts if ANY matches. + {Path: "/bin/busybox", Args: []string{"/bin/sleep", dynamicpathdetector.WildcardIdentifier}}, + {Path: "/bin/busybox", Args: []string{"/bin/sh", "-c", dynamicpathdetector.WildcardIdentifier}}, + {Path: "/bin/busybox", Args: []string{"/bin/echo", "hello", dynamicpathdetector.WildcardIdentifier}}, + }, + }) + 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 + args []string + expectedResult bool // true = silent (matches), false = R0040 must fire + }{ + { + name: "sh_dash_c matches [sh,-c,*]", + args: []string{"/bin/sh", "-c", "echo hi"}, + expectedResult: true, + }, + { + name: "sh_dash_x_dash_c mismatches at position 1 (-c vs -x)", + args: []string{"/bin/sh", "-x", "-c", "echo hi"}, + expectedResult: false, + }, + { + name: "echo_hello matches [echo,hello,*]", + args: []string{"/bin/echo", "hello", "world", "from", "test"}, + expectedResult: true, + }, + { + name: "echo_goodbye mismatches at position 1 (hello vs goodbye)", + args: []string{"/bin/echo", "goodbye", "world"}, + expectedResult: false, + }, + { + name: "sleep matches [sleep,*] wildcard", + args: []string{"/bin/sleep", "infinity"}, + expectedResult: true, + }, + { + name: "unknown applet mismatches all three vectors", + args: []string{"/bin/cat", "/etc/passwd"}, + expectedResult: 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()) + } + prog, err := env.Program(ast) + if err != nil { + t.Fatalf("failed to create program: %v", err) + } + result, _, err := prog.Eval(map[string]any{ + "containerID": "test-container-id", + "path": "/bin/busybox", + "args": tc.args, + }) + if err != nil { + t.Fatalf("failed to evaluate expression: %v", err) + } + assert.Equal(t, tc.expectedResult, result.Value().(bool), + "ap.was_executed_with_args(/bin/busybox, %v) — must return %v so R0040's `!was_executed_with_args` produces the right alert decision", + tc.args, tc.expectedResult) + }) + } +} + +// TestExecWithArgsEmptyVectorDoesNotPoisonMatch reproduces the production +// failure that survived the first ExecsByPath wiring: R0040 stayed silent on +// every argv mismatch in #805 CT runs through the "side-effects" tip. +// +// In a real merged profile the SAME path can carry both a constrained vector +// (from the user-defined ApplicationProfile, e.g. [echo, hello, *]) AND a +// bare vector with no args (from the recorder, or a synthesised base CP, e.g. +// /bin/busybox observed with empty Args). extractExecsByPath stores the bare +// entry as an empty []string{}. +// +// dynamicpathdetector.CompareExecArgs treats an EMPTY profile vector as "no +// argv constraint" and returns true for ANY runtime args. When +// wasExecutedWithArgs ORs across every vector for the path, that one empty +// vector short-circuits the whole match to true — so !was_executed_with_args +// is always false and R0040 never fires, even for argv vectors that match +// none of the constrained entries. The fix (dynamicpathdetector.MatchExecArgs, strict) treats an +// empty recorded vector as "ran with no args" (matches only empty runtime). +func TestExecWithArgsEmptyVectorDoesNotPoisonMatch(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{ + // Bare recorder/synthetic entry: same path, NO args. This is the + // poison vector — present in real merged profiles, absent from + // the clean multi-vector test above. + {Path: "/bin/busybox", Args: nil}, + // User-defined constrained vectors. + {Path: "/bin/busybox", Args: []string{"/bin/echo", "hello", dynamicpathdetector.WildcardIdentifier}}, + {Path: "/bin/busybox", Args: []string{"/bin/sh", "-c", dynamicpathdetector.WildcardIdentifier}}, + }, + }) + 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 + args []string + expectedResult bool + }{ + { + name: "echo goodbye still mismatches despite bare poison vector", + args: []string{"/bin/echo", "goodbye", "world"}, + expectedResult: false, + }, + { + name: "sh -x -c still mismatches despite bare poison vector", + args: []string{"/bin/sh", "-x", "-c", "echo hi"}, + expectedResult: false, + }, + { + name: "echo hello still matches its constrained vector", + args: []string{"/bin/echo", "hello", "world"}, + expectedResult: true, + }, + { + name: "no-args invocation matches the bare vector", + args: []string{}, + expectedResult: true, + }, + } + + 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()) + } + prog, err := env.Program(ast) + if err != nil { + t.Fatalf("failed to create program: %v", err) + } + result, _, err := prog.Eval(map[string]any{ + "containerID": "test-container-id", + "path": "/bin/busybox", + "args": tc.args, + }) + if err != nil { + t.Fatalf("failed to evaluate expression: %v", err) + } + assert.Equal(t, tc.expectedResult, result.Value().(bool), + "ap.was_executed_with_args(/bin/busybox, %v) — empty recorded vector must not poison the multi-vector OR", + tc.args) + }) + } +} diff --git a/pkg/rulemanager/cel/libraries/parse/default_rules_yaml_lint_test.go b/pkg/rulemanager/cel/libraries/parse/default_rules_yaml_lint_test.go new file mode 100644 index 0000000000..93843e3570 --- /dev/null +++ b/pkg/rulemanager/cel/libraries/parse/default_rules_yaml_lint_test.go @@ -0,0 +1,127 @@ +package parse + +import ( + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" +) + +// TestDefaultRulesYAML_NoTwoArgGetExecPath pins the migration of bundled +// rule expressions to the 3-arg parse.get_exec_path(args, comm, exepath). +// Any 2-arg call site re-introduces the fork-shell mismatch where the rule +// side evaluates to a bare comm (e.g. "sh") while the recording side stores +// the resolved exepath (e.g. "/bin/sh") — silently breaking ap.was_executed +// lookups for execve patterns like `sh -c …` whose argv[0] is just "sh". +func TestDefaultRulesYAML_NoTwoArgGetExecPath(t *testing.T) { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "..", "..")) + yamlPath := filepath.Join(repoRoot, "tests", "chart", "templates", "node-agent", "default-rules.yaml") + + data, err := os.ReadFile(yamlPath) + if err != nil { + t.Fatalf("read %s: %v", yamlPath, err) + } + + // Match parse.get_exec_path( args , comm ) — i.e. exactly two arguments, + // no third event.exepath. Whitespace between args is tolerated. + twoArg := regexp.MustCompile(`parse\.get_exec_path\(\s*event\.args\s*,\s*event\.comm\s*\)`) + if locs := twoArg.FindAllIndex(data, -1); len(locs) > 0 { + lines := lineNumbers(data, locs) + t.Errorf("found %d 2-arg parse.get_exec_path() call(s) at line(s) %v; migrate to parse.get_exec_path(event.args, event.comm, event.exepath)", len(locs), lines) + } +} + +// TestDefaultRulesYAML_NoStringOnArgsList guards against CEL expressions that +// wrap the list-typed event.args field in string(). CEL's string() has no +// list overload, so e.g. string(event.args) compiles-then-fails at rule-eval +// time. When that expression is a rule's message or uniqueId, the rule manager +// drops the whole alert (rule_manager.go: getUniqueIdAndMessage error → the +// event is skipped) and spams error logs on every matching event — which broke +// R0040 delivery and tripped Test_02/Test_32. Render list fields with +// event.args.map(a, string(a)).join(" ") instead (precedent: R0006 uses +// event.flags.join(",")). +func TestDefaultRulesYAML_NoStringOnArgsList(t *testing.T) { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "..", "..")) + yamlPath := filepath.Join(repoRoot, "tests", "chart", "templates", "node-agent", "default-rules.yaml") + + data, err := os.ReadFile(yamlPath) + if err != nil { + t.Fatalf("read %s: %v", yamlPath, err) + } + + stringOnArgs := regexp.MustCompile(`string\(\s*event\.args\s*\)`) + if locs := stringOnArgs.FindAllIndex(data, -1); len(locs) > 0 { + lines := lineNumbers(data, locs) + t.Errorf("found %d string(event.args) call(s) at line(s) %v; CEL string() has no list overload — "+ + "render with event.args.map(a, string(a)).join(\" \")", len(locs), lines) + } +} + +// TestDefaultRulesYAML_R1000DetectsDevShmViaArgv guards R1000 ("Process +// executed from malicious source") against silently losing its /dev/shm +// detection. R1000 must inspect argv[0] (event.args[0]) and event.exepath / +// event.cwd directly — NOT route through parse.get_exec_path, because the +// 3-arg resolver prefers the kernel-resolved exepath (e.g. /bin/busybox for a +// busybox-symlinked applet) over the as-invoked argv[0] (/dev/shm/ls). Routing +// /dev/shm detection through get_exec_path therefore resolves the path away +// from /dev/shm and the rule never fires (regressed Test_02). +func TestDefaultRulesYAML_R1000DetectsDevShmViaArgv(t *testing.T) { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "..", "..")) + yamlPath := filepath.Join(repoRoot, "tests", "chart", "templates", "node-agent", "default-rules.yaml") + + data, err := os.ReadFile(yamlPath) + if err != nil { + t.Fatalf("read %s: %v", yamlPath, err) + } + + // Isolate the R1000 rule block: from its id line to the next rule's id line. + text := string(data) + start := strings.Index(text, `id: "R1000"`) + if start < 0 { + t.Fatal(`R1000 rule not found in default-rules.yaml`) + } + rest := text[start+len(`id: "R1000"`):] + end := strings.Index(rest, `id: "R`) + if end < 0 { + end = len(rest) + } + block := rest[:end] + + if strings.Contains(block, "get_exec_path") { + t.Errorf("R1000 routes /dev/shm detection through parse.get_exec_path; the resolver " + + "prefers exepath over argv[0], so /dev/shm/ resolves away from /dev/shm and " + + "the rule never fires. Inspect event.args[0] / event.exepath / event.cwd directly.") + } + if !strings.Contains(block, "event.args[0]") { + t.Errorf("R1000 must inspect argv[0] (event.args[0]) so an exec invoked as /dev/shm/ " + + "is detected even when exepath resolves to the symlink target (e.g. /bin/busybox)") + } +} + +func lineNumbers(data []byte, locs [][]int) []int { + out := make([]int, 0, len(locs)) + for _, loc := range locs { + line := 1 + for i := 0; i < loc[0] && i < len(data); i++ { + if data[i] == '\n' { + line++ + } + } + out = append(out, line) + } + return out +} diff --git a/pkg/rulemanager/cel/libraries/parse/parse.go b/pkg/rulemanager/cel/libraries/parse/parse.go index ba82f982f6..be0608a848 100644 --- a/pkg/rulemanager/cel/libraries/parse/parse.go +++ b/pkg/rulemanager/cel/libraries/parse/parse.go @@ -17,7 +17,10 @@ func (l *parseLibrary) getExecPath(args ref.Val, comm ref.Val) ref.Val { return types.MaybeNoSuchOverloadErr(comm) } - // Implement the logic from GetExecPathFromEvent + // 2-arg overload — back-compat. Resolves args[0] → comm. + // Callers that have event.exepath SHOULD use the 3-arg overload below + // to stay symmetric with the recording side's resolveExecPath in + // pkg/containerprofilemanager/v1/event_reporting.go. if len(argsList) > 0 { if argsList[0] != "" { return types.String(argsList[0]) @@ -25,3 +28,39 @@ func (l *parseLibrary) getExecPath(args ref.Val, comm ref.Val) ref.Val { } return types.String(commStr) } + +// getExecPathWithExePath resolves the exec path symmetrically with the +// recording side's resolveExecPath in +// pkg/containerprofilemanager/v1/event_reporting.go. Precedence: +// +// 1. exepath — kernel-authoritative; the only spoof-resistant source. +// argv[0] is user-controllable (man 3 exec) even when absolute, so +// a process can lie about its identity via `exec -a /bin/sh sleep` +// while /proc//exe stays /usr/bin/sleep. Trusting absolute +// argv[0] would let that lie pass an ap.was_executed check for +// /bin/sh. +// +// 2. argv[0] when non-empty AND exepath empty (fexecve / execveat +// AT_EMPTY_PATH — the libpam helper invocation pattern; defers to +// the 2-arg fallback below). +// +// 3. comm as final fallback (also via 2-arg). +// +// This closes the fork-shell mismatch — `sh -c …` records /bin/sh on +// the recording side and the rule side now queries /bin/sh too — while +// preserving the kernel-authoritative argv[0]-spoofing protection that +// the prior absolute-argv[0]-wins tier had eroded. +func (l *parseLibrary) getExecPathWithExePath(args ref.Val, comm ref.Val, exepath ref.Val) ref.Val { + exepathStr, ok := exepath.Value().(string) + if !ok { + return types.MaybeNoSuchOverloadErr(exepath) + } + + if exepathStr != "" { + return types.String(exepathStr) + } + + // exepath empty (fexecve / AT_EMPTY_PATH) — fall back to argv[0], + // then comm, via the 2-arg path. + return l.getExecPath(args, comm) +} diff --git a/pkg/rulemanager/cel/libraries/parse/parselib.go b/pkg/rulemanager/cel/libraries/parse/parselib.go index 57b05be451..758492d542 100644 --- a/pkg/rulemanager/cel/libraries/parse/parselib.go +++ b/pkg/rulemanager/cel/libraries/parse/parselib.go @@ -47,6 +47,17 @@ func (l *parseLibrary) Declarations() map[string][]cel.FunctionOpt { return l.getExecPath(values[0], values[1]) }), ), + cel.Overload( + "parse_get_exec_path_with_exepath", + []*cel.Type{cel.ListType(cel.StringType), cel.StringType, cel.StringType}, + cel.StringType, + cel.FunctionBinding(func(values ...ref.Val) ref.Val { + if len(values) != 3 { + return types.NewErr("expected 3 arguments, got %d", len(values)) + } + return l.getExecPathWithExePath(values[0], values[1], values[2]) + }), + ), }, } } diff --git a/pkg/rulemanager/cel/libraries/parse/parsing_test.go b/pkg/rulemanager/cel/libraries/parse/parsing_test.go index 5677c8b56f..51b213b718 100644 --- a/pkg/rulemanager/cel/libraries/parse/parsing_test.go +++ b/pkg/rulemanager/cel/libraries/parse/parsing_test.go @@ -135,3 +135,129 @@ func TestParseLibraryErrorCases(t *testing.T) { }) } } + +// TestGetExecPath_SymmetryWithRecordingSide pins the contract that the +// rule-side resolver MUST agree with pkg/containerprofilemanager/v1/ +// event_reporting.go:resolveExecPath. That recording function uses +// 1. exepath (kernel-authoritative) +// 2. argv[0] when non-empty AND exepath empty (fexecve / AT_EMPTY_PATH) +// 3. comm +// in that precedence order — so the path stored in the ApplicationProfile +// is whatever the kernel reports, and argv[0] is only consulted when the +// kernel itself could not report an exepath. +// +// If the rule side ignores exepath, the profile entry written under +// "/bin/sh" becomes unreachable when the runtime queries with the rule's +// resolved path "sh" (argv[0]), and R0001 fires spuriously on benign +// shell invocations — exactly the regression bobctl tune was hitting on +// merge/upstream-profile-rearch. +// +// These cases mirror TestResolveExecPath in pkg/containerprofilemanager/v1/ +// event_reporting_test.go. They use a 3-arg overload of parse.get_exec_path +// that accepts (args, comm, exepath). +func TestGetExecPath_SymmetryWithRecordingSide(t *testing.T) { + env, err := cel.NewEnv( + cel.Variable("event", cel.AnyType), + Parse(config.Config{}), + ) + if err != nil { + t.Fatalf("failed to create env: %v", err) + } + + tests := []struct { + name string + expr string + expected string + }{ + { + name: "exepath present (canonical exec)", + expr: "parse.get_exec_path(['/usr/sbin/unix_chkpwd', 'root'], 'unix_chkpwd', '/usr/sbin/unix_chkpwd')", + expected: "/usr/sbin/unix_chkpwd", + }, + { + name: "exepath disagrees with argv[0] — exepath wins (argv[0] spoofing)", + // kernel says /usr/bin/curl, argv[0] says sshd. Profile recorded by + // resolveExecPath has "/usr/bin/curl" — rule MUST query the same. + expr: "parse.get_exec_path(['sshd', '-i'], 'curl', '/usr/bin/curl')", + expected: "/usr/bin/curl", + }, + { + name: "exepath empty (fexecve / AT_EMPTY_PATH) — fall back to argv[0]", + expr: "parse.get_exec_path(['unix_chkpwd', 'root'], 'unix_chkpwd', '')", + expected: "unix_chkpwd", + }, + { + name: "exepath + argv[0] empty — fall back to comm", + expr: "parse.get_exec_path(['', 'root'], 'unix_chkpwd', '')", + expected: "unix_chkpwd", + }, + { + name: "fork-shell case — kernel /bin/sh, argv[0] sh, comm sh", + expr: "parse.get_exec_path(['sh', '-c', 'echo'], 'sh', '/bin/sh')", + expected: "/bin/sh", + }, + { + // Busybox-style symlink case: the user runs `/bin/sh` which + // is a symlink to `/bin/busybox`. The kernel-resolved exepath + // is `/bin/busybox`. The rule side queries the same identity + // the recording side stored — exepath — so user-authored + // profiles on busybox images must list `/bin/busybox`. Trusting + // the absolute argv[0] here would let `exec -a /bin/sh sleep` + // pass an ap.was_executed check for `/bin/sh`. + name: "busybox symlink — exepath /bin/busybox wins over argv[0]=/bin/sh", + expr: "parse.get_exec_path(['/bin/sh', '-c', 'echo hi'], 'sh', '/bin/busybox')", + expected: "/bin/busybox", + }, + { + name: "busybox symlink — exepath /bin/busybox wins over argv[0]=/usr/bin/nslookup", + expr: "parse.get_exec_path(['/usr/bin/nslookup', 'example.com'], 'nslookup', '/bin/busybox')", + expected: "/bin/busybox", + }, + { + // Bare-argv[0] spoof — exepath still wins. + name: "bare argv[0] spoof — exepath wins", + expr: "parse.get_exec_path(['sshd', '-i'], 'curl', '/usr/bin/curl')", + expected: "/usr/bin/curl", + }, + { + // Absolute argv[0] spoof — `exec -a /bin/sh sleep 2`. + // Process lies about its identity via an allowed absolute + // argv[0]; the real exe is /usr/bin/sleep. Rule-side resolver + // MUST anchor on kernel-authoritative exepath, mirroring + // resolveExecPath so ap.was_executed lookups reflect the + // real binary. Regression pin for matthyx blocker on PR #805 + // (2026-05-27). + name: "absolute argv[0] spoof — exec -a /bin/sh sleep", + expr: "parse.get_exec_path(['/bin/sh', '2'], 'sleep', '/usr/bin/sleep')", + expected: "/usr/bin/sleep", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ast, issues := env.Compile(tt.expr) + 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{}{ + "event": map[string]interface{}{ + "args": []string{}, + "comm": "test", + "exepath": "", + }, + }) + if err != nil { + t.Fatalf("failed to eval program: %v", err) + } + actual, ok := result.Value().(string) + if !ok { + t.Fatalf("expected string result, got %T", result.Value()) + } + assert.Equal(t, tt.expected, actual, "result should match expected value") + }) + } +} diff --git a/tests/chart/templates/node-agent/default-rule-binding.yaml b/tests/chart/templates/node-agent/default-rule-binding.yaml index 26367de97f..710deb6e35 100644 --- a/tests/chart/templates/node-agent/default-rule-binding.yaml +++ b/tests/chart/templates/node-agent/default-rule-binding.yaml @@ -15,6 +15,7 @@ spec: - "kubeconfig" rules: - ruleName: "Unexpected process launched" + - ruleName: "Unexpected process arguments" - ruleName: "Files Access Anomalies in container" - ruleName: "Syscalls Anomalies in container" - ruleName: "Linux Capabilities Anomalies in container" diff --git a/tests/chart/templates/node-agent/default-rules.yaml b/tests/chart/templates/node-agent/default-rules.yaml index 0a4fe1d87f..ec0ed45260 100644 --- a/tests/chart/templates/node-agent/default-rules.yaml +++ b/tests/chart/templates/node-agent/default-rules.yaml @@ -18,7 +18,7 @@ spec: uniqueId: "event.comm + '_' + event.exepath" ruleExpression: - eventType: "exec" - expression: "!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm))" + expression: "!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath))" profileDependency: 0 profileDataRequired: execs: all @@ -198,7 +198,7 @@ spec: uniqueId: "eventType == 'exec' ? 'exec_' + event.comm : 'network_' + event.dstAddr" ruleExpression: - eventType: "exec" - expression: "(event.comm == 'kubectl' || event.exepath.endsWith('/kubectl')) && !ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm))" + expression: "(event.comm == 'kubectl' || event.exepath.endsWith('/kubectl')) && !ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath))" - eventType: "network" expression: "event.pktType == 'OUTGOING' && k8s.is_api_server_address(event.dstAddr) && !nn.was_address_in_egress(event.containerId, event.dstAddr)" profileDependency: 0 @@ -332,8 +332,8 @@ spec: - eventType: "exec" expression: > (event.exepath == '/dev/shm' || event.exepath.startsWith('/dev/shm/')) || - (event.cwd == '/dev/shm' || event.cwd.startsWith('/dev/shm/') || - (parse.get_exec_path(event.args, event.comm).startsWith('/dev/shm/'))) + (event.cwd == '/dev/shm' || event.cwd.startsWith('/dev/shm/')) || + (event.args.size() > 0 && (event.args[0] == '/dev/shm' || event.args[0].startsWith('/dev/shm/'))) profileDependency: 2 severity: 8 supportPolicy: false @@ -358,7 +358,7 @@ spec: expression: > (event.upperlayer == true || event.pupperlayer == true) && - !ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm)) + !ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath)) profileDependency: 1 profileDataRequired: execs: all @@ -431,7 +431,7 @@ spec: uniqueId: "event.comm" ruleExpression: - eventType: "exec" - expression: "!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm)) && k8s.get_container_mount_paths(event.namespace, event.podName, event.containerName).exists(mount, event.exepath.startsWith(mount) || parse.get_exec_path(event.args, event.comm).startsWith(mount))" + expression: "!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath)) && k8s.get_container_mount_paths(event.namespace, event.podName, event.containerName).exists(mount, event.exepath.startsWith(mount) || parse.get_exec_path(event.args, event.comm, event.exepath).startsWith(mount))" profileDependency: 1 profileDataRequired: execs: all @@ -689,3 +689,27 @@ spec: - "syscalls" - "io_uring" - "applicationprofile" + - name: "Unexpected process arguments" + enabled: true + id: "R0040" + description: "Detects an exec event whose path IS in the profile but whose argv vector does not match any recorded argv pattern for that path. Consumes ap.was_executed_with_args, which walks the ExecsByPath projection surface added by node-agent#807 and delegates argv comparison to dynamicpathdetector.CompareExecArgs (storage). Stays silent when the path is unknown (R0001 covers that case) and when the argv vector matches any recorded pattern (including the trailing-wildcard form `[…, *]`)." + expressions: + message: "'Unexpected process arguments: ' + event.comm + ' with PID ' + string(event.pid) + ' argv=' + event.args.map(a, string(a)).join(' ')" + uniqueId: "event.comm + '_' + event.exepath + '_' + event.args.map(a, string(a)).join(' ')" + ruleExpression: + - eventType: "exec" + expression: "ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath)) && !ap.was_executed_with_args(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath), event.args)" + profileDependency: 0 + profileDataRequired: + execs: all + severity: 3 + supportPolicy: false + isTriggerAlert: true + mitreTactic: "TA0002" + mitreTechnique: "T1059" + tags: + - "context:kubernetes" + - "anomaly" + - "process" + - "exec" + - "applicationprofile" diff --git a/tests/component_test.go b/tests/component_test.go index fcdb760bfb..b71859e524 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -1569,3 +1569,531 @@ func Test_24_ProcessTreeDepthTest(t *testing.T) { t.Logf("Found alerts for the process tree depth: %v", alerts) } + +// --------------------------------------------------------------------------- +// Test_32_UnexpectedProcessArguments — component test for the wildcard-aware +// exec-argument matching (R0040). Each subtest gets its own namespace so +// alerts don't cross-contaminate. +// +// AP overlay declares 4 allowed exec patterns for the curl pod. Profile +// shape: +// +// - Path = full kernel-resolved exec path (used by parse.get_exec_path +// +// - ap.was_executed for path-level matching) +// +// - Args[0] = ABSOLUTE invoking path (e.g. "/bin/sh"). Matches runtime +// argv[0] as captured by eBPF after the symlink-faithful +// precedence fix (parse.get_exec_path / resolveExecPath +// prefer absolute argv[0] over kernel exepath when argv[0] +// starts with "/"). Recording side records the same form +// via the matching precedence in +// pkg/containerprofilemanager/v1/event_reporting.go:: +// resolveExecPath, so profile.Args[0] agrees with what +// CompareExecArgs compares against at rule-eval time. See +// pkg/rulemanager/cel/libraries/parse/parse.go for the +// live precedence definition. +// +// /bin/sleep [/bin/sleep, *] — pod startup, must stay silent +// /bin/sh [/bin/sh, -c, *] — sh -c +// /bin/echo [/bin/echo, hello, *] — echo hello +// /usr/bin/curl [/usr/bin/curl, -s, ⋯] — curl -s +// +// Profile loaded into the new ContainerProfileCache via the unified +// kubescape.io/user-defined-profile= label. The exec.go CEL function +// routes ap.was_executed_with_args through dynamicpathdetector.CompareExecArgs +// — see storage/pkg/registry/file/dynamicpathdetector/tests/ +// compare_exec_args_test.go::TestCompareExecArgs_Argv0BareName for the +// matcher-level contract these subtests rest on. +// +// R0040 ("Unexpected process arguments") fires when: +// - the exec'd path IS in the profile (R0001 silent), AND +// - the runtime arg vector does NOT match any profile entry's pattern. +// +// Each subtest asserts R0001 silence as a PRECONDITION (path resolution +// works), THEN asserts presence/absence of R0040. If R0001 fires, the +// failure points at the recording-side exepath capture (event.exepath +// empty AND argv[0] not absolute → parse.get_exec_path falls back to +// bare comm → profile +// Path lookup misses), not at R0040 logic. Separating the two axes +// stops Test_32 from flaking on unrelated capture-layer gaps. +// --------------------------------------------------------------------------- +func Test_32_UnexpectedProcessArguments(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + const overlayName = "curl-32-overlay" + + setup := func(t *testing.T) *testutils.TestWorkload { + t.Helper() + ns := testutils.NewRandomNamespace() + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: overlayName, + Namespace: ns.Name, + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "curl", + Execs: []v1beta1.ExecCalls{ + // Profile shape: Path AND Args[0] both use the + // absolute-path symlink form (/bin/sh, + // /usr/bin/nslookup, ...). With the symlink- + // faithful precedence in parse.get_exec_path + // (fix 9a6eb359), the rule queries the + // symlink-as-invoked path that the kernel + // preserves in argv[0]. Recording-side + // resolveExecPath uses the same precedence so + // auto-learned profiles get the same key. + // + // Storage's CompareExecArgs is a strict + // positional compare — no special argv[0] + // normalisation — so Args[0] MUST be the same + // string as runtime argv[0]. For + // kubectl-exec'd processes that's the absolute + // path the caller invoked. + // + // pod startup: sleep + {Path: "/bin/sleep", Args: []string{"/bin/sleep", dynamicpathdetector.WildcardIdentifier}}, + // sh -c + {Path: "/bin/sh", Args: []string{"/bin/sh", "-c", dynamicpathdetector.WildcardIdentifier}}, + // echo hello + {Path: "/bin/echo", Args: []string{"/bin/echo", "hello", dynamicpathdetector.WildcardIdentifier}}, + // curl -s + {Path: "/usr/bin/curl", Args: []string{"/usr/bin/curl", "-s", dynamicpathdetector.DynamicIdentifier}}, + // Busybox-symlink mirror entries. The curl image's + // /bin/{sleep,sh,echo} are symlinks to /bin/busybox, + // so the kernel's resolved /proc//exe — what + // IG captures as event.exepath — is /bin/busybox. + // parse.get_exec_path(args, comm, exepath) returns + // exepath first, so ap.was_executed queries arrive + // at the rule keyed on /bin/busybox, not the + // symlink form. Without a matching profile entry + // keyed on /bin/busybox, R0001 fires before R0040 + // ever evaluates and the test trips its R0001 + // precondition. The symlink-form entries above are + // retained for environments where exepath resolves + // to the as-invoked path (non-symlinked utilities; + // fexecve / argv[0] fallback in resolveExecPath). + {Path: "/bin/busybox", Args: []string{"/bin/sleep", dynamicpathdetector.WildcardIdentifier}}, + {Path: "/bin/busybox", Args: []string{"/bin/sh", "-c", dynamicpathdetector.WildcardIdentifier}}, + {Path: "/bin/busybox", Args: []string{"/bin/echo", "hello", dynamicpathdetector.WildcardIdentifier}}, + }, + Syscalls: []string{"socket", "connect", "sendto", "recvfrom", "read", "write", "close", "openat", "mmap", "mprotect", "munmap", "fcntl", "ioctl", "poll", "epoll_create1", "epoll_ctl", "epoll_wait", "bind", "listen", "accept4", "getsockopt", "setsockopt", "getsockname", "getpid", "fstat", "rt_sigaction", "rt_sigprocmask", "writev", "execve"}, + }, + }, + }, + } + _, err := storageClient.ApplicationProfiles(ns.Name).Create( + context.Background(), ap, metav1.CreateOptions{}) + require.NoError(t, err, "create AP") + + // User-supplied SBOB pattern (mirrors Test_28): the pod carries BOTH + // kubescape.io/user-defined-profile and kubescape.io/user-defined-network. + // Node-agent uses the single overlay name as the lookup key for BOTH + // the user ApplicationProfile and the user NetworkNeighborhood, so the + // NN must exist under the same name and be created before the pod. + // User-authored objects carry managed-by=User + a terminal + // status/completion and the workload-binding labels. + nn := &v1beta1.NetworkNeighborhood{ + ObjectMeta: metav1.ObjectMeta{ + Name: overlayName, + Namespace: ns.Name, + Annotations: map[string]string{ + helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, + helpersv1.StatusMetadataKey: helpersv1.Completed, + helpersv1.CompletionMetadataKey: helpersv1.Full, + }, + Labels: map[string]string{ + helpersv1.ApiGroupMetadataKey: "apps", + helpersv1.ApiVersionMetadataKey: "v1", + helpersv1.RelatedKindMetadataKey: "Deployment", + helpersv1.RelatedNameMetadataKey: "curl-32", + helpersv1.RelatedNamespaceMetadataKey: ns.Name, + }, + }, + Spec: v1beta1.NetworkNeighborhoodSpec{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "curl-32"}, + }, + Containers: []v1beta1.NetworkNeighborhoodContainer{ + {Name: "curl"}, + }, + }, + } + _, err = storageClient.NetworkNeighborhoods(ns.Name).Create( + context.Background(), nn, metav1.CreateOptions{}) + require.NoError(t, err, "create NN") + + require.Eventually(t, func() bool { + _, apErr := storageClient.ApplicationProfiles(ns.Name).Get( + context.Background(), overlayName, v1.GetOptions{}) + _, nnErr := storageClient.NetworkNeighborhoods(ns.Name).Get( + context.Background(), overlayName, v1.GetOptions{}) + return apErr == nil && nnErr == nil + }, 30*time.Second, 1*time.Second, "AP+NN must be in storage before pod deploy") + + wl, err := testutils.NewTestWorkload(ns.Name, + path.Join(utils.CurrentDir(), "resources/curl-exec-arg-wildcards-deployment.yaml")) + require.NoError(t, err) + require.NoError(t, wl.WaitForReady(80)) + // let node-agent load the user AP+NN into the CP cache + time.Sleep(30 * time.Second) + return wl + } + + countByRule := func(alerts []testutils.Alert, ruleID string) int { + n := 0 + for _, a := range alerts { + if a.Labels["rule_id"] == ruleID { + n++ + } + } + return n + } + + waitAlerts := func(t *testing.T, ns string) []testutils.Alert { + t.Helper() + var alerts []testutils.Alert + var err error + require.Eventually(t, func() bool { + alerts, err = testutils.GetAlerts(ns) + return err == nil + }, 60*time.Second, 5*time.Second, "must be able to fetch alerts") + // settle time for any in-flight alerts + time.Sleep(10 * time.Second) + alerts, _ = testutils.GetAlerts(ns) + return alerts + } + + logAlerts := func(t *testing.T, alerts []testutils.Alert) { + t.Helper() + for i, a := range alerts { + t.Logf(" [%d] %s(%s) comm=%s container=%s", + i, a.Labels["rule_name"], a.Labels["rule_id"], + a.Labels["comm"], a.Labels["container_name"]) + } + } + + // R0001 silence is a precondition for every subtest below: it means + // parse.get_exec_path resolved to the profile's Path key, so R0040 + // gets to evaluate its argv comparison cleanly. A non-zero R0001 for + // the test binary's comm means the recording / capture / resolution + // chain dropped event.exepath — that's a separate bug (track it in + // the recording side, not in R0040), and asserting it here fails the + // subtest on the right axis instead of polluting the R0040 signal. + assertR0001Silent := func(t *testing.T, alerts []testutils.Alert, comm string) { + t.Helper() + n := 0 + for _, a := range alerts { + if a.Labels["rule_id"] == "R0001" && a.Labels["comm"] == comm { + n++ + } + } + require.Zero(t, n, + "R0001 precondition: path resolution failed for comm=%q. "+ + "parse.get_exec_path either didn't receive event.exepath or "+ + "profile Path doesn't match its return value. Fix capture-side "+ + "exepath before reading R0040 results from this subtest.", comm) + } + + // ----------------------------------------------------------------- + // 32a. sh -c — argv [sh, -c, "echo hi"] matches + // profile [sh, -c, *]. R0040 must NOT fire. + // ----------------------------------------------------------------- + t.Run("sh_dash_c_matches_wildcard_trailing", func(t *testing.T) { + wl := setup(t) + // Warm the cache: retry the exec until it runs cleanly so the user + // overlay is loaded, then settle and assert R0040 stays silent + // (mirrors Test_28 no-alert idiom). A matching argv must not alert. + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"sh", "-c", "echo hi"}, "curl") + return err == nil + }, 60*time.Second, 5*time.Second, "exec must run") + time.Sleep(20 * time.Second) + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + assertR0001Silent(t, alerts, "sh") + assert.Equal(t, 0, countByRule(alerts, "R0040"), + "sh -c matches profile [sh, -c, *]: R0040 must stay silent") + }) + + // ----------------------------------------------------------------- + // 32b. sh -x -c — argv [sh, -x, -c, "echo hi"] does NOT match + // profile [sh, -c, *] (literal anchor `-c` at position 1 mismatches + // `-x`). Path /bin/sh (or /bin/busybox) IS in profile so R0001 + // stays silent. R0040 must fire. + // + // Earlier shape `sh -x "echo hi"` exited 2 (busybox sh tried to + // open "echo hi" as a script file) — kubectl exec returned an + // error and require.NoError tripped before R0040 could be read. + // Adding -c keeps sh's invocation valid while preserving the + // argv-shape mismatch that exercises R0040. + // ----------------------------------------------------------------- + t.Run("sh_dash_x_mismatches_R0040", func(t *testing.T) { + wl := setup(t) + // Retry the trigger until node-agent has loaded the user overlay + // into the ContainerProfileCache and R0040 fires. The overlay loads + // asynchronously, so a single exec can race the load and the + // profile-dependent rule is suppressed (mirrors Test_28). The + // command is idempotent, so re-exec is side-effect-free. + var alerts []testutils.Alert + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"sh", "-x", "-c", "echo hi"}, "curl") + if err != nil { + return false + } + alerts = waitAlerts(t, wl.Namespace) + return countByRule(alerts, "R0040") > 0 + }, 120*time.Second, 10*time.Second, "sh -x mismatches profile [sh, -c, *]: R0040 must fire") + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + assertR0001Silent(t, alerts, "sh") + require.Greater(t, countByRule(alerts, "R0040"), 0, + "sh -x mismatches profile [sh, -c, *]: R0040 must fire") + }) + + // ----------------------------------------------------------------- + // 32c. echo hello — argv [echo, hello, world, from, test] + // matches profile [echo, hello, *]. R0040 must NOT fire. + // ----------------------------------------------------------------- + t.Run("echo_hello_matches_wildcard_trailing", func(t *testing.T) { + wl := setup(t) + // Warm the cache: retry the exec until it runs cleanly so the user + // overlay is loaded, then settle and assert R0040 stays silent + // (mirrors Test_28 no-alert idiom). A matching argv must not alert. + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"echo", "hello", "world", "from", "test"}, "curl") + return err == nil + }, 60*time.Second, 5*time.Second, "exec must run") + time.Sleep(20 * time.Second) + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + assertR0001Silent(t, alerts, "echo") + assert.Equal(t, 0, countByRule(alerts, "R0040"), + "echo hello matches profile [echo, hello, *]: R0040 must stay silent") + }) + + // ----------------------------------------------------------------- + // 32d. echo goodbye — argv [echo, goodbye, world] does + // NOT match profile [echo, hello, *] (literal anchor `hello` + // mismatch). R0040 must fire. + // ----------------------------------------------------------------- + t.Run("echo_goodbye_mismatches_R0040", func(t *testing.T) { + wl := setup(t) + // Retry the trigger until node-agent has loaded the user overlay + // into the ContainerProfileCache and R0040 fires. The overlay loads + // asynchronously, so a single exec can race the load and the + // profile-dependent rule is suppressed (mirrors Test_28). The + // command is idempotent, so re-exec is side-effect-free. + var alerts []testutils.Alert + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"echo", "goodbye", "world"}, "curl") + if err != nil { + return false + } + alerts = waitAlerts(t, wl.Namespace) + return countByRule(alerts, "R0040") > 0 + }, 120*time.Second, 10*time.Second, "echo goodbye mismatches profile [echo, hello, *] (literal anchor): R0040 must fire") + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + assertR0001Silent(t, alerts, "echo") + require.Greater(t, countByRule(alerts, "R0040"), 0, + "echo goodbye mismatches profile [echo, hello, *] (literal anchor): R0040 must fire") + }) + + // ----------------------------------------------------------------- + // 32e. curl -s — the NON-symlinked binary (curl is a real + // binary in curlimages/curl, not a busybox applet) with an + // ELLIPSIS profile: [curl, -s, ⋯]. ⋯ matches EXACTLY ONE arg, so + // `curl -s ` matches → R0040 silent. + // + // A file:// URL is used so curl reads a local file and exits 0 + // regardless of cluster egress — the test pins argv matching, not + // network reachability. + // ----------------------------------------------------------------- + t.Run("curl_dash_s_one_url_matches_ellipsis", func(t *testing.T) { + wl := setup(t) + // Warm the cache: retry the exec until it runs cleanly so the user + // overlay is loaded, then settle and assert R0040 stays silent + // (mirrors Test_28 no-alert idiom). A matching argv must not alert. + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"curl", "-s", "file:///etc/hostname"}, "curl") + return err == nil + }, 60*time.Second, 5*time.Second, "exec must run") + time.Sleep(20 * time.Second) + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + assertR0001Silent(t, alerts, "curl") + assert.Equal(t, 0, countByRule(alerts, "R0040"), + "curl -s matches profile [curl, -s, dyn]: R0040 must stay silent") + }) + + // ----------------------------------------------------------------- + // 32f. curl -s — argv [curl, -s, url1, url2] does NOT match + // profile [curl, -s, ⋯] because ⋯ consumes EXACTLY ONE arg, not + // two. R0040 must fire. Pins the ⋯ (DynamicIdentifier) arity on + // the non-symlinked path. Both file:// URLs are readable so curl + // still exits 0. + // ----------------------------------------------------------------- + t.Run("curl_dash_s_two_urls_mismatches_R0040", func(t *testing.T) { + wl := setup(t) + // Retry the trigger until node-agent has loaded the user overlay + // into the ContainerProfileCache and R0040 fires. The overlay loads + // asynchronously, so a single exec can race the load and the + // profile-dependent rule is suppressed (mirrors Test_28). The + // command is idempotent, so re-exec is side-effect-free. + var alerts []testutils.Alert + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"curl", "-s", "file:///etc/hostname", "file:///etc/hosts"}, "curl") + if err != nil { + return false + } + alerts = waitAlerts(t, wl.Namespace) + return countByRule(alerts, "R0040") > 0 + }, 120*time.Second, 10*time.Second, "curl -s exceeds the single-arg dyn token in profile [curl, -s, dyn]: R0040 must fire") + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + assertR0001Silent(t, alerts, "curl") + require.Greater(t, countByRule(alerts, "R0040"), 0, + "curl -s exceeds the single-arg dyn token in profile [curl, -s, dyn]: R0040 must fire") + }) + + // ----------------------------------------------------------------- + // Pod-spec fallback coverage (node-agent#805 review, 2026-06-04). + // + // was_executed_with_args falls back to the pod spec when the profile + // records no matching argv vector. That fallback must compare the FULL + // runtime argv against a DECLARED command vector (the container's + // Command ++ Args, or a lifecycle hook's Exec.Command) — not just the + // executable path. A path-only fallback lets any exec of a declared + // binary with unexpected arguments pass silently and suppresses R0040. + // + // The pod here declares its command with an explicit /bin/busybox argv0 + // (so exepath == argv0 == command[0] all agree — the symlink forms hide + // the bug) and a postStart hook ["/bin/busybox", "echo", "poststart-hook"]. + // The overlay AP carries NO Execs for the container, so was_executed_with_args + // is forced down to the pod-spec fallback. was_executed still resolves + // (the path appears in the declared command), so R0001 stays silent and + // R0040 gets to evaluate. + // ----------------------------------------------------------------- + const podSpecOverlayName = "curl-32-podspec-overlay" + setupPodSpec := func(t *testing.T) *testutils.TestWorkload { + t.Helper() + ns := testutils.NewRandomNamespace() + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + + // Empty Execs → no Values/Patterns/ExecsByPath entry, so + // was_executed_with_args reaches the pod-spec fallback. + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{Name: podSpecOverlayName, Namespace: ns.Name}, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "curl", + Syscalls: []string{"execve", "read", "write", "close", "openat", "fstat", "rt_sigaction", "rt_sigprocmask"}, + }, + }, + }, + } + _, err := storageClient.ApplicationProfiles(ns.Name).Create( + context.Background(), ap, metav1.CreateOptions{}) + require.NoError(t, err, "create AP") + + nn := &v1beta1.NetworkNeighborhood{ + ObjectMeta: metav1.ObjectMeta{ + Name: podSpecOverlayName, + Namespace: ns.Name, + Annotations: map[string]string{ + helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, + helpersv1.StatusMetadataKey: helpersv1.Completed, + helpersv1.CompletionMetadataKey: helpersv1.Full, + }, + Labels: map[string]string{ + helpersv1.ApiGroupMetadataKey: "apps", + helpersv1.ApiVersionMetadataKey: "v1", + helpersv1.RelatedKindMetadataKey: "Deployment", + helpersv1.RelatedNameMetadataKey: "curl-32-podspec", + helpersv1.RelatedNamespaceMetadataKey: ns.Name, + }, + }, + Spec: v1beta1.NetworkNeighborhoodSpec{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "curl-32-podspec"}, + }, + Containers: []v1beta1.NetworkNeighborhoodContainer{{Name: "curl"}}, + }, + } + _, err = storageClient.NetworkNeighborhoods(ns.Name).Create( + context.Background(), nn, metav1.CreateOptions{}) + require.NoError(t, err, "create NN") + + require.Eventually(t, func() bool { + _, apErr := storageClient.ApplicationProfiles(ns.Name).Get( + context.Background(), podSpecOverlayName, v1.GetOptions{}) + _, nnErr := storageClient.NetworkNeighborhoods(ns.Name).Get( + context.Background(), podSpecOverlayName, v1.GetOptions{}) + return apErr == nil && nnErr == nil + }, 30*time.Second, 1*time.Second, "AP+NN must be in storage before pod deploy") + + wl, err := testutils.NewTestWorkload(ns.Name, + path.Join(utils.CurrentDir(), "resources/curl-podspec-command-deployment.yaml")) + require.NoError(t, err) + require.NoError(t, wl.WaitForReady(80)) + time.Sleep(30 * time.Second) + return wl + } + + // ----------------------------------------------------------------- + // 32g. Exec the EXACT declared postStart hook command + // ["/bin/busybox", "echo", "poststart-hook"]. It matches a command + // vector declared in the pod spec, so the args-aware fallback allows + // it → R0040 must stay silent. + // ----------------------------------------------------------------- + t.Run("podspec_declared_command_matches_silent", func(t *testing.T) { + wl := setupPodSpec(t) + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"/bin/busybox", "echo", "poststart-hook"}, "curl") + return err == nil + }, 60*time.Second, 5*time.Second, "exec must run") + time.Sleep(20 * time.Second) + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + assert.Equal(t, 0, countByRule(alerts, "R0040"), + "argv matches the declared postStart command vector: R0040 must stay silent") + }) + + // ----------------------------------------------------------------- + // 32h. Exec the SAME declared binary (/bin/busybox) with an argv vector + // that matches NO declared command — neither the container command + // ["/bin/busybox","sleep","999999"] nor the postStart hook + // ["/bin/busybox","echo","poststart-hook"]. Before the fix the + // path-only pod-spec fallback returned true (R0040 silent — the bug); + // the full-vector fallback now rejects it → R0040 must fire. + // ----------------------------------------------------------------- + t.Run("podspec_command_arg_mismatch_fires_R0040", func(t *testing.T) { + wl := setupPodSpec(t) + var alerts []testutils.Alert + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"/bin/busybox", "echo", "not-a-declared-command"}, "curl") + if err != nil { + return false + } + alerts = waitAlerts(t, wl.Namespace) + return countByRule(alerts, "R0040") > 0 + }, 120*time.Second, 10*time.Second, "argv matches no declared pod-spec command vector: R0040 must fire") + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + require.Greater(t, countByRule(alerts, "R0040"), 0, + "exec of a declared binary with undeclared args must fire R0040 (pod-spec fallback compares the full argv vector)") + }) +} diff --git a/tests/resources/curl-exec-arg-wildcards-deployment.yaml b/tests/resources/curl-exec-arg-wildcards-deployment.yaml new file mode 100644 index 0000000000..cdb2ddf96b --- /dev/null +++ b/tests/resources/curl-exec-arg-wildcards-deployment.yaml @@ -0,0 +1,29 @@ +## Curl pod for Test_32_UnexpectedProcessArguments. +## +## Carries the unified user-defined-profile label used by upstream's +## ContainerProfileCache (kubescape/node-agent#788). The label value +## must match the name of BOTH the user ApplicationProfile and (when +## present) the user NetworkNeighborhood. The test creates only the AP +## with that name; both the user AP and the user NN use it. +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: curl-32 + name: curl-32 +spec: + selector: + matchLabels: + app: curl-32 + replicas: 1 + template: + metadata: + labels: + app: curl-32 + kubescape.io/user-defined-profile: curl-32-overlay + kubescape.io/user-defined-network: curl-32-overlay + spec: + containers: + - name: curl + image: docker.io/curlimages/curl@sha256:08e466006f0860e54fc299378de998935333e0e130a15f6f98482e9f8dab3058 + command: ["sleep", "infinity"] diff --git a/tests/resources/curl-podspec-command-deployment.yaml b/tests/resources/curl-podspec-command-deployment.yaml new file mode 100644 index 0000000000..039b20c713 --- /dev/null +++ b/tests/resources/curl-podspec-command-deployment.yaml @@ -0,0 +1,41 @@ +## Pod for the Test_32 pod-spec-fallback subtests (node-agent#805 review, +## 2026-06-04). The container command is declared with an EXPLICIT +## /bin/busybox argv0 so the kernel-resolved exepath (/bin/busybox), the +## runtime argv[0] (/bin/busybox) and the declared command[0] (/bin/busybox) +## all agree. That alignment is what makes the pod-spec fallback in +## was_executed_with_args observable end-to-end: with the symlink forms +## (/bin/sh -> /bin/busybox) the exepath never appears literally in the +## declared command, so the path-only fallback bug cannot manifest. +## +## A postStart lifecycle hook declares a SHORT command vector so the +## "declared command is allowed" case can be exercised by a quick exec +## (the main command is `sleep 999999`, which would block kubectl exec). +## +## Carries the unified user-defined-profile/network labels (one overlay name +## is the lookup key for BOTH the user AP and the user NN). +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: curl-32-podspec + name: curl-32-podspec +spec: + selector: + matchLabels: + app: curl-32-podspec + replicas: 1 + template: + metadata: + labels: + app: curl-32-podspec + kubescape.io/user-defined-profile: curl-32-podspec-overlay + kubescape.io/user-defined-network: curl-32-podspec-overlay + spec: + containers: + - name: curl + image: docker.io/curlimages/curl@sha256:08e466006f0860e54fc299378de998935333e0e130a15f6f98482e9f8dab3058 + command: ["/bin/busybox", "sleep", "999999"] + lifecycle: + postStart: + exec: + command: ["/bin/busybox", "echo", "poststart-hook"]