From 103f955a6655f66d6c0a99bc7dcae0acfe744722 Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Tue, 27 Oct 2026 11:52:56 +0100 Subject: [PATCH 01/23] feat(sandboxpolicy): add opt-in persistent exec enforcement Watch the SandboxPolicy CRDs, resolve selected exec plans and publish them through the full persistent enforce_exec backend. Preserve strict runtime identity, successful role epochs, healthy last-good enforcement and explicit unsupported/uncertain status. Add public verdict hooks, trusted containerd admission, bounded telemetry, lifecycle regressions and disposable real-kernel fixtures. Pin the published IG implementation; the existing unmerged-dependency guard remains in force. Private OTel integration and chart changes are out of scope. Signed-off-by: Matthias Bertschy --- cmd/main.go | 29 + docs/features/sandbox-policy.md | 239 +++ go.mod | 8 +- go.sum | 473 +++++- pkg/config/config.go | 26 + pkg/config/config_test.go | 35 + pkg/sandboxpolicy/compiler.go | 186 +++ pkg/sandboxpolicy/compiler_test.go | 106 ++ pkg/sandboxpolicy/controller.go | 756 +++++++++ pkg/sandboxpolicy/controller_test.go | 1178 ++++++++++++++ pkg/sandboxpolicy/exporter.go | 295 ++++ pkg/sandboxpolicy/exporter_test.go | 266 +++ pkg/sandboxpolicy/igbackend/README.md | 65 + pkg/sandboxpolicy/igbackend/backend.go | 307 ++++ pkg/sandboxpolicy/igbackend/backend_test.go | 470 ++++++ .../igbackend/controller_identity_test.go | 296 ++++ pkg/sandboxpolicy/igbackend/fullbackend.go | 398 +++++ .../igbackend/fullbackend_attest_test.go | 147 ++ .../igbackend/fullbackend_kernel_test.go | 615 +++++++ .../igbackend/fullbackend_test.go | 251 +++ pkg/sandboxpolicy/igbackend/fullcatalog.go | 104 ++ .../igbackend/fullcatalog_test.go | 205 +++ pkg/sandboxpolicy/igbackend/fullcompile.go | 48 + .../igbackend/fullcompile_test.go | 135 ++ pkg/sandboxpolicy/igbackend/fullengine.go | 74 + .../igbackend/fulllease_linux.go | 404 +++++ .../igbackend/fulllease_linux_test.go | 195 +++ pkg/sandboxpolicy/igbackend/fullverdict.go | 224 +++ .../igbackend/fullverdict_test.go | 314 ++++ pkg/sandboxpolicy/igbackend/identity_linux.go | 178 +++ .../igbackend/identity_linux_test.go | 58 + pkg/sandboxpolicy/igbackend/imageevidence.go | 1423 +++++++++++++++++ .../igbackend/imageevidence_kernel_test.go | 126 ++ .../igbackend/imageevidence_test.go | 620 +++++++ pkg/sandboxpolicy/igbackend/imageindex.go | 361 +++++ .../igbackend/imageindex_test.go | 198 +++ .../igbackend/kernelstats_test.go | 139 ++ .../igbackend/metadata_fence_test.go | 75 + pkg/sandboxpolicy/igbackend/native.go | 122 ++ pkg/sandboxpolicy/igbackend/nativefull.go | 283 ++++ .../igbackend/nativefull_test.go | 332 ++++ .../igbackend/testdata/fullbackend-helper.c | 76 + pkg/sandboxpolicy/igbackend/verdict.go | 91 ++ pkg/sandboxpolicy/integration/bindings.go | 209 +++ .../integration/bindings_test.go | 246 +++ pkg/sandboxpolicy/integration/readiness.go | 40 + pkg/sandboxpolicy/integration/service.go | 313 ++++ pkg/sandboxpolicy/integration/service_test.go | 267 ++++ pkg/sandboxpolicy/integration/sources.go | 115 ++ pkg/sandboxpolicy/integration/sources_test.go | 108 ++ pkg/sandboxpolicy/status.go | 160 ++ pkg/sandboxpolicy/status_test.go | 76 + pkg/sandboxpolicy/types.go | 168 ++ pkg/utils/events.go | 3 + 54 files changed, 13628 insertions(+), 8 deletions(-) create mode 100644 docs/features/sandbox-policy.md create mode 100644 pkg/sandboxpolicy/compiler.go create mode 100644 pkg/sandboxpolicy/compiler_test.go create mode 100644 pkg/sandboxpolicy/controller.go create mode 100644 pkg/sandboxpolicy/controller_test.go create mode 100644 pkg/sandboxpolicy/exporter.go create mode 100644 pkg/sandboxpolicy/exporter_test.go create mode 100644 pkg/sandboxpolicy/igbackend/README.md create mode 100644 pkg/sandboxpolicy/igbackend/backend.go create mode 100644 pkg/sandboxpolicy/igbackend/backend_test.go create mode 100644 pkg/sandboxpolicy/igbackend/controller_identity_test.go create mode 100644 pkg/sandboxpolicy/igbackend/fullbackend.go create mode 100644 pkg/sandboxpolicy/igbackend/fullbackend_attest_test.go create mode 100644 pkg/sandboxpolicy/igbackend/fullbackend_kernel_test.go create mode 100644 pkg/sandboxpolicy/igbackend/fullbackend_test.go create mode 100644 pkg/sandboxpolicy/igbackend/fullcatalog.go create mode 100644 pkg/sandboxpolicy/igbackend/fullcatalog_test.go create mode 100644 pkg/sandboxpolicy/igbackend/fullcompile.go create mode 100644 pkg/sandboxpolicy/igbackend/fullcompile_test.go create mode 100644 pkg/sandboxpolicy/igbackend/fullengine.go create mode 100644 pkg/sandboxpolicy/igbackend/fulllease_linux.go create mode 100644 pkg/sandboxpolicy/igbackend/fulllease_linux_test.go create mode 100644 pkg/sandboxpolicy/igbackend/fullverdict.go create mode 100644 pkg/sandboxpolicy/igbackend/fullverdict_test.go create mode 100644 pkg/sandboxpolicy/igbackend/identity_linux.go create mode 100644 pkg/sandboxpolicy/igbackend/identity_linux_test.go create mode 100644 pkg/sandboxpolicy/igbackend/imageevidence.go create mode 100644 pkg/sandboxpolicy/igbackend/imageevidence_kernel_test.go create mode 100644 pkg/sandboxpolicy/igbackend/imageevidence_test.go create mode 100644 pkg/sandboxpolicy/igbackend/imageindex.go create mode 100644 pkg/sandboxpolicy/igbackend/imageindex_test.go create mode 100644 pkg/sandboxpolicy/igbackend/kernelstats_test.go create mode 100644 pkg/sandboxpolicy/igbackend/metadata_fence_test.go create mode 100644 pkg/sandboxpolicy/igbackend/native.go create mode 100644 pkg/sandboxpolicy/igbackend/nativefull.go create mode 100644 pkg/sandboxpolicy/igbackend/nativefull_test.go create mode 100644 pkg/sandboxpolicy/igbackend/testdata/fullbackend-helper.c create mode 100644 pkg/sandboxpolicy/igbackend/verdict.go create mode 100644 pkg/sandboxpolicy/integration/bindings.go create mode 100644 pkg/sandboxpolicy/integration/bindings_test.go create mode 100644 pkg/sandboxpolicy/integration/readiness.go create mode 100644 pkg/sandboxpolicy/integration/service.go create mode 100644 pkg/sandboxpolicy/integration/service_test.go create mode 100644 pkg/sandboxpolicy/integration/sources.go create mode 100644 pkg/sandboxpolicy/integration/sources_test.go create mode 100644 pkg/sandboxpolicy/status.go create mode 100644 pkg/sandboxpolicy/status_test.go create mode 100644 pkg/sandboxpolicy/types.go diff --git a/cmd/main.go b/cmd/main.go index 2d1395b9c0..d488a6e60e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -62,6 +62,7 @@ import ( "github.com/kubescape/node-agent/pkg/rulemanager/rulecooldown" "github.com/kubescape/node-agent/pkg/rulemanager/rulecreator" "github.com/kubescape/node-agent/pkg/rulemanager/ruleswatcher" + sandboxintegration "github.com/kubescape/node-agent/pkg/sandboxpolicy/integration" "github.com/kubescape/node-agent/pkg/sbommanager" sbommanagerv1 "github.com/kubescape/node-agent/pkg/sbommanager/v1" sbomscanner "github.com/kubescape/node-agent/pkg/sbomscanner/v1" @@ -569,6 +570,34 @@ func main() { defer mainHandler.Stop() } + if cfg.EnableSandboxPolicy { + // Enforcement is independent of observer/runtime-detection switches. The + // service rejects unavailable predicates/bindings rather than weakening them. + sandboxService, sandboxErr := sandboxintegration.NewService(ctx, sandboxintegration.ServiceOptions{ + Kubernetes: k8sClient.GetKubernetesClient(), Dynamic: k8sClient.GetDynamicClient(), + Containers: mainHandler.GetContainerCollection(), Tracers: mainHandler.GetTracerCollection(), + NodeName: cfg.NodeName, AgentNamespace: cfg.NamespaceName, ImageName: cfg.SandboxPolicyImage, ImageArchive: "tracers.tar", + ContainerdSocket: cfg.SandboxPolicyContainerdSocket, ControlRoot: cfg.SandboxPolicyControlRoot, MaxOwners: cfg.SandboxPolicyMaxOwners, + Sources: sandboxintegration.NewProfileResolver(storageClient.GetStorageClient()), + Receivers: thirdPartyTracers.ThirdPartyEventReceivers, + OnError: func(err error) { logger.L().Ctx(ctx).Error("sandbox policy NotReady", helpers.Error(err)) }, + }) + if sandboxErr != nil { + logger.L().Ctx(ctx).Fatal("creating sandbox policy service", helpers.Error(sandboxErr)) + } + dWatcher.AddAdaptor(sandboxService) + if sandboxErr = sandboxService.Start(ctx); sandboxErr != nil { + logger.L().Ctx(ctx).Fatal("starting sandbox policy service", helpers.Error(sandboxErr)) + } + defer func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := sandboxService.Close(cleanupCtx); err != nil { + logger.L().Error("sandbox policy cleanup incomplete", helpers.Error(err)) + } + }() + } + // start watching dWatcher.Start(ctx) defer dWatcher.Stop(ctx) diff --git a/docs/features/sandbox-policy.md b/docs/features/sandbox-policy.md new file mode 100644 index 0000000000..43eee26175 --- /dev/null +++ b/docs/features/sandbox-policy.md @@ -0,0 +1,239 @@ +# SandboxPolicy execution enforcement (experimental) + +The public service selects the persistent full-exec backend, with no path-only +fallback. Controller/watch/status behavior has client-backed regression tests; +node-agent's native backend has isolated real containerd/CRI kernel proof on the +explicitly pinned amd64 environment below. This is not generic runtime/kernel +compatibility or an API-server/kubelet deployment proof. Private OTel export is +out of scope. The IG dependency remains merge-gated until PR #15 is merged. + +## Configuration and prerequisites + +Disabled by default: + +```json +{"sandboxPolicyEnabled": false, "sandboxPolicyImage": "", + "sandboxPolicyContainerdSocket": "", "sandboxPolicyControlRoot": "", + "sandboxPolicyMaxOwners": 4} +``` + +Enabling requires an explicit, trusted mutator image reference in `tracers.tar`, +compiled from the **same IG revision as the Go runtime owner**. Do not substitute a +trace gadget or reuse an older policy-map layout. The public dependency currently +pins the prototype revision; publishing/re-pinning the expanded backend remains +outstanding. No mutator image is implicitly downloaded, trusted or published by +this change. IG treats an explicit local OCI target as pretrusted and skips its signature +verification. The archive must therefore be authenticated by the administrator +as part of the agent-image supply chain; the verification flag alone does not +authenticate it. Policy authors cannot choose the image/archive. + +Enabling also requires explicit administrator-trusted `sandboxPolicyContainerdSocket` +and writable `sandboxPolicyControlRoot` paths. The socket and its parent must be +protected from workload substitution. The host root/proc mount must be a trusted +host mount, not a workload-supplied path. `sandboxPolicyMaxOwners` (1–1024, +default 4) reserves aggregate capacity for persistent owners, including cleanup +pending owners. Each owner reserves 128 MiB of map payload capacity, one million +encoded instructions and bounded identity/epoch history; these ceilings are not +RSS or JIT memory measurements. Unsupported kernels, runtimes and images refuse +admission rather than falling back to the legacy backend. + +Requires Kubernetes, BPF LSM with working attachment support, cgroup v2, and +host-visible procfs/cgroup discovery. Runtime container IDs, pod UID, PID, mount +namespace, cgroup path and cgroup inode must agree. Root cgroups and incomplete or +reused identities are refused. BPF verification or a kernel version string alone +does not establish these capabilities. + +Enforcement is independent of runtime-detection/application-profile switches. +There are **no inferred helper, host-mount-namespace or agent exemptions**. Strict +cgroup scope can block container-runtime helpers (`kubectl exec`, probes and +lifecycle helpers), including a helper that shares the target cgroup. An entrant +without accepted birth lineage can quarantine the owner: subsequent revisions +remain NotReady/protection-unconfirmed, rather than silently adopting that helper +or resetting roles. Explicit withdrawal can still drain and detach safely; a new +policy revision alone does not redeem structural lineage faults. Roll out +only in an explicitly selected test workload before production use. + +Install the four CRDs from storage's SandboxPolicy API first. The agent needs: + +- `kubescape.io`: get/list/watch SandboxPolicy, ClusterSandboxPolicy and + SandboxPolicyClasses; get/create SandboxPolicyNodeStatus and update its + `status` subresource. +- Core API: get the node UID; list pods on the node and namespaces for selectors. +- Storage API: get referenced ContainerProfiles read-only. + +This change does not grant chart RBAC, change a chart version or store policy in +SQLite/files. Kubernetes owns the CRD objects. Per-policy/node status resources +are output; the agent does not write parent aggregate status. + +## Reconciliation and failure behavior + +Configuration watch events are **refresh hints**, not trusted queued payloads. +Each refresh reads an authoritative all-resource snapshot. This prevents an old +queued update from resurrecting a deleted/recreated policy. Container discovery +is subscribed before the initial runtime snapshot. A five-second recovery pass +also refreshes pod/namespace labels and referenced profiles, repairs watch gaps, +retries cleanup and reports status heartbeats. + +Failed configuration lists keep a persistent NotReady barrier until a complete +relist succeeds. They do not authorize new bindings or removal of retained +last-good plans. Failed runtime snapshots also retain the previous authoritative +snapshot, report NotReady and are not interpreted as an empty container list. Discovery requirements are +policy-aware: an unselected, observer-filtered container does not block unrelated +bindings. An explicitly selected filtered container with unavailable identity is +NotReady; it is not exempted. + +The first policy is activated only after native startup acknowledges all operators +and exact-identity checks pass. Subsequent revisions swap frozen program data in +the same owner; they do not restart maps, links or committed role state. Publication +closes exec admission and drains transactions, so concurrent exec attempts can +fail during this bounded maintenance window. A drain timeout retains the old +program and reports NotReady. Existing-owner publication, re-attestation and stop +operations have a five-second bound; first admission allows two minutes for the +bounded image-index walk and native load. Earlier caller deadlines still apply. Successful policy UID epochs survive revisions and +removal/re-addition within the same owner lifetime. +Precommit failure retains last-good enforcement **only when its live lease and +native readiness can still be confirmed**. Unchanged and retained plans are +re-attested; uncertain protection reports NotReady and is excluded from confirmed +active counts, rather than being described as healthy last-good enforcement. +Postcommit cleanup failure +records the actual new revision and reports NotReady. Cleanup retries do not +restart the new generation. Withdrawal records removal separately from cleanup; +replacement waits until conflicting cleanup finishes. Shutdown is retryable and +does not release the runtime before owned cleanup succeeds. + +The full backend retains at most 1,024 owned identities, further constrained by +the explicit shared owner budget. Per owner the compiler bounds eight policies, +256 rules, 1,024 patterns, 256 predicate values and 16,384 IR entries. Paths may +contain at most 255 encoded bytes; argv capture is bounded to 64 arguments and +4,096 bytes. Oversized or unrepresentable candidates are rejected as a whole. +The supported runtime/kernel matrix remains deliberately narrow; see the +matching IG owner documentation and exact BTF admission gate. + +ContainerProfile import currently requires a completed profile with full +coverage, an unambiguous selected container and literal paths. Exact argv-length, +argument-wildcard and environment constraints cannot be represented faithfully by +the CRD exec vocabulary and are refused rather than broadened. + +## Public event hooks + +Register receivers in `ThirdPartyTracers.ThirdPartyEventReceivers` under +`utils.SandboxExecVerdictEventType`. The public `VerdictExporter` accepts +`EnrichedEventReceiver`, `EventReceiver`, or `sandboxpolicy.VerdictReceiver`. +Optional process-tree enrichment uses the existing `TaskBasedEnricher` interface. +Private OTel export is intentionally not implemented here. + +`VerdictEvent` implements `EnrichEvent`, **not** `ExecEvent`: an LSM denial is not +process creation. The payload preserves policy/revision, verdict, effective mode, +`WouldDeny`, and available rule/origin metadata. Unknown argv, UID, image origin +and actor path remain absent for legacy events. Legacy time is receipt time. +Exact binding correlation rejects stale pod/PID/mount-namespace metadata. An +ambiguous matching rule does not receive invented single-rule provenance. + +Telemetry is bounded and best effort: a 1,024-entry service queue, explicit drop, +receiver-error and enrichment-error counters, exposed by `Service.ExporterStats()`. Slow/failing receivers cannot alter +kernel decisions. Void third-party receiver calls must return promptly; cancellation +cannot forcibly interrupt arbitrary user callbacks. Events are independent of +trace_exec's successful-exec filters and do not mutate the process tree as an exec. + +## Verification boundary + +Unit/race tests cover selection, reload bookkeeping, import refusal, status, +identity checks, queued-event ordering and public hook delivery. These are not +proof of kernel capabilities. Isolated IG tests have passed on Linux +7.0.0-31-generic x86_64 with BPF LSM enabled, including the 128th path, mixed modes, +strict scope, replacement/churn and reporting saturation; three repeat runs also +passed. They prove the expanded **legacy** backend, not the outstanding full +predicate integration. No arm64 or general-kernel support is claimed. + +The separate advanced IG image has also passed isolated tests on a pinned custom +x86_64 kernel for staged argv from the new exec mm, actor/ancestor paths, effective +UID, stdin pipe facts, and overlay backing membership. Its native runtime tests +cover immutable-map admission and rollback. Supplied image-membership indexes in +those tests are **not proof of trusted container image discovery**. A separate +role-lifecycle spike proves fork/vfork inheritance, successful-exec-only commits, +script retries, nonleader exec and cleanup, not production role bootstrap or +revision publication. + +Separate filesystem fixtures now prove tmpfs, deleted, overlay and authoritative +memfd classification, including an actual hugetlb-memfd exec denial. Untracked +pre-existing memfd remains unknown; a known positive filesystem alternative can +still match an OR list. Allocation exhaustion does not deny unrelated file +creation. The combined production image passed all 29 current live-exec cases, including +six filesystem cases; tracker attachment is probed before binding admission. + +The persistent owner is connected through the public native backend, and the +pinned CRI proof below exercises its trusted image admission and live reload. +This remains a deliberately constrained environment, not broad distribution, +compiler or runtime compatibility. Refusal or isolated component tests alone are +not sufficient completion evidence. + +The expanded IG revision is published as signed-off commit +`fe1de461890489288685634d12821f976b16d0a6`, followed by the lint-only commit +`6b8b24afaf6f2fd9eeb1bcaa9f7a52f4f11e9ea1`, in IG PR #15. Node-agent pins the latter exact +public pseudo-version and passes targeted tests, race checks, vet and binary +compilation with `GOWORK=off`. The repository's merge guard intentionally blocks +this stacked dependency until IG #15 merges; re-pin to its merged main revision +before merging the consumer change. Do not bypass that guard. + +The native backend subscribes the advanced generation-specific event decoder. +Its `PolicySet` records the applied policies, and `Matches` records only +kernel-reported matching rules. Ambiguous multi-policy decisions do not invent a +single rule or policy attribution. `MatchesComplete=false` marks partial results +on operational faults. `Revision` remains the aggregate plan revision; policy +revisions are explicit in `PolicySet` and `Matches`. Fields absent from the kernel +event remain absent, including argv, UID and actor/image facts. Export queues and +receiver copies isolate the added slices from producer/receiver mutation. + +## Pinned CRI kernel validation + +The isolated combined fixture uses real containerd 1.7.30 / runc 1.3.4 CRI +RunPodSandbox/CreateContainer/StartContainer, not forged ctr metadata. It retains +the generated hosts, hostname, resolv.conf, sandbox shared-memory bind and +read-only cgroup mount. The host-network fixture has private PID/mount namespaces; +no Kubernetes API server or kubelet is involved in this kernel fixture. Controller +selection/status/watch behavior has separate fake-client tests. + +`TestFullBackendContainerdKernel` exercises thirteen real paths under default +deny, full compound exec predicates, enforce/audit decisions, inherited roles, +same-owner reload, unchanged-plan checks, the full 256-rule/bit-255 boundary, +safe 257-rule rejection, public +verdict provenance and withdrawal. The matching test-kernel BTF is admitted +explicitly; this is not generic kernel, arm64 or containerd 2.x compatibility. + +CRI shared-memory admission authenticates sandbox metadata and its runtime-owned +OCI spec, then compares actual source/destination inode/device and whole TMPFS +mount roots. The protected host source retains nosuid/nodev/noexec. Some runc +binds drop these flags on the application mount. That case requires **all five +capability sets empty in both OCI and live proc status, no-new-privileges in both, +and exactly the canonical restrictive OCI device-policy baseline with no authored +allows**. Trusted runc still permits OCI default safe devices; this is not a +literal deny-every-device claim. The bound init was kernel-tested to receive +EPERM opening unregistered char and block nodes created by the host in shared +memory, with mode 0666 and nonblocking read-only opens. No device data was read or +written. Ordinary TMPFS execution is enforced by the gadget, not assumed impossible. + +Read-only cgroup mounts must be actual cgroup v2 with readonly/nosuid/nodev/noexec +properties. Other mounts, capabilities, device-policy variants or runtime layouts +are refused, not exempted. Shared-memory metadata churn can invalidate the bounded +double-scan evidence; this conservative stable-snapshot boundary is not proof for +arbitrary active-IPC workloads. Runtime helpers remain subject to strict scope. + +The persistent-owner BPF object used for final isolated proof was compiled with +Clang 21.1.8 against the exact admitted kernel BTF. Clang 19 produced a +verifier-rejected owner object in this experiment; compilation alone is not a +compatibility guarantee. The default upstream builder is not evidence for this +experimental profile—use an explicitly matching, tested compiler/artifact. + +`Service.KernelEventStats(ctx)` exposes owned and retired kernel submitted, +ring-reservation-loss and observation-error counters. Retired totals survive +withdrawal; unavailable-instance counts and returned errors must be checked +before interpreting zero loss. These are separate from userspace +`ExporterStats()`. Early native owner/gate refusals can occur before verdict +emission and are not counted as ring loss, so neither API promises complete +coverage of every operational denial. + +Unreliable container snapshots use nonpublishing `Attest` for retained plans, +never `Replace`/`Resume`. Unsupported attestation reports protection unconfirmed. +Configuration-list failures freeze policy mutation and suppress confirmed-active +claims. Superseded uncommitted candidates must abort before another revision is +considered; already committed revisions cannot be rolled back. diff --git a/go.mod b/go.mod index 55a9999584..49feb26bff 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/cenkalti/backoff/v5 v5.0.3 github.com/cespare/xxhash/v2 v2.3.0 github.com/cilium/ebpf v0.22.0 + github.com/containerd/containerd v1.7.36 github.com/crewjam/rfc5424 v0.1.0 github.com/cyphar/filepath-securejoin v0.6.1 github.com/deckarep/golang-set/v2 v2.8.0 @@ -35,12 +36,13 @@ require ( github.com/kubescape/backend v0.0.39 github.com/kubescape/go-logger v0.0.35 github.com/kubescape/k8s-interface v0.0.214 - github.com/kubescape/storage v0.0.303 + github.com/kubescape/storage v0.0.352-0.20261013133511-eb6406e01cb9 github.com/kubescape/workerpool v0.0.0-20250526074519-0e4a4e7f44cf github.com/moby/sys/mountinfo v0.7.2 github.com/opcoder0/fanotify v0.4.2 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 + github.com/opencontainers/runtime-spec v1.3.0 github.com/panjf2000/ants/v2 v2.11.3 github.com/picatz/xcel v0.0.0-20260226001349-6958ffac5706 github.com/prometheus/alertmanager v0.27.0 @@ -184,7 +186,6 @@ require ( github.com/cloudflare/circl v1.6.3 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/containerd/cgroups/v3 v3.1.2 // indirect - github.com/containerd/containerd v1.7.36 // 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 @@ -352,7 +353,6 @@ 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.3.0 // indirect github.com/opencontainers/selinux v1.13.1 // indirect github.com/packetcap/go-pcap v0.0.0-20250723190045-d00b185f30b7 // indirect github.com/pborman/indent v1.2.1 // indirect @@ -480,7 +480,7 @@ require ( zombiezen.com/go/sqlite v1.4.0 // indirect ) -replace github.com/inspektor-gadget/inspektor-gadget => github.com/kubescape/inspektor-gadget v0.0.0-20260826074832-06b0d12baca0 +replace github.com/inspektor-gadget/inspektor-gadget => github.com/kubescape/inspektor-gadget v0.0.0-20261027103046-6b8b24afaf6f replace github.com/anchore/syft => github.com/kubescape/syft v1.32.0-ks.2 diff --git a/go.sum b/go.sum index fd86cf4927..f2a9438856 100644 --- a/go.sum +++ b/go.sum @@ -33,33 +33,131 @@ cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0c cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/accessapproval v1.8.8/go.mod h1:RFwPY9JDKseP4gJrX1BlAVsP5O6kI8NdGlTmaeDefmk= +cloud.google.com/go/accesscontextmanager v1.9.7/go.mod h1:i6e0nd5CPcrh7+YwGq4bKvju5YB9sgoAip+mXU73aMM= +cloud.google.com/go/aiplatform v1.120.0/go.mod h1:6mDthfmy0oS1EQhVFdijoxkVdI2+HIZkpuGTBpedeCg= +cloud.google.com/go/analytics v0.30.1/go.mod h1:V/FnINU5kMOsttZnKPnXfKi6clJUHTEXUKQjHxcNK8A= +cloud.google.com/go/apigateway v1.7.7/go.mod h1:j1bCmrUK1BzVHpiIyTApxB7cRyhivKzltqLmp6j6i7U= +cloud.google.com/go/apigeeconnect v1.7.7/go.mod h1:ftGK3nca0JePiVLl0A6alaMjKdOc5C+sAkFMyH2RH8U= +cloud.google.com/go/apigeeregistry v0.10.0/go.mod h1:SAlF5OhKvyLDuwWAaFAIVJjrEqKRrGTPkJs+TWNnSqg= +cloud.google.com/go/appengine v1.9.7/go.mod h1:y1XpGVeAhbsNzHida79cHbr3pFRsym0ob8xnC8yphbo= +cloud.google.com/go/area120 v0.10.0/go.mod h1:Xg3fKl4xU3UVai9wsI1FXwNU8wSCDYT7dFZfwJKViAM= +cloud.google.com/go/artifactregistry v1.20.0/go.mod h1:0G9wdbGyDFkvrYH+2AlQs9MuTJdbY8Vg45M8VjlI8rc= +cloud.google.com/go/asset v1.22.1/go.mod h1:NlvWwmca7CX6BIBEdRNxOocH6DowmBghAAHucOHuHng= +cloud.google.com/go/assuredworkloads v1.13.0/go.mod h1:o/oHEOnUlribR+uJWTKQo8A5RhSl9K9FNeMOew4TJ3M= cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/automl v1.15.0/go.mod h1:U9zOtQb8zVrFNGTuW3BfxeqmLyeleLgT9B12EaXfODg= +cloud.google.com/go/baremetalsolution v1.4.0/go.mod h1:K6C6g4aS8LW95I0fEHZiBsBlh0UxwDLGf+S/vyfXbvg= +cloud.google.com/go/batch v1.14.0/go.mod h1:oeQveyG6NDS/ks2ilOP4LzKRmuIaI7GLe0CkR7WF6pk= +cloud.google.com/go/beyondcorp v1.2.0/go.mod h1:sszcgxpPPBEfLzbI0aYCTg6tT1tyt3CmKav3NZIUcvI= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/bigquery v1.74.0/go.mod h1:iViO7Cx3A/cRKcHNRsHB3yqGAMInFBswrE9Pxazsc90= +cloud.google.com/go/bigtable v1.42.0/go.mod h1:oZ30nofVB6/UYGg7lBwGLWSea7NZUvw/WvBBgLY07xU= +cloud.google.com/go/billing v1.21.0/go.mod h1:ZGairB3EVnb3i09E2SxFxo50p5unPaMTuo1jh6jW9js= +cloud.google.com/go/binaryauthorization v1.10.0/go.mod h1:WOuiaQkI4PU/okwrcREjSAr2AUtjQgVe+PlrXKOmKKw= +cloud.google.com/go/certificatemanager v1.9.6/go.mod h1:vWogV874jKZkSRDFCMM3r7wqybv8WXs3XhyNff6o/Zo= +cloud.google.com/go/channel v1.21.0/go.mod h1:8v3TwHtgLmFxTpL2U+e10CLFOQN8u/Vr9RhYcJUS3y8= +cloud.google.com/go/cloudbuild v1.25.0/go.mod h1:lCu+T6IPkobPo2Nw+vCE7wuaAl9HbXLzdPx/tcF+oWo= +cloud.google.com/go/clouddms v1.8.8/go.mod h1:QtCyw+a73dlkDb2q20aTAPvfaTZCepDDi6Gb1AKq0a4= +cloud.google.com/go/cloudtasks v1.13.7/go.mod h1:H0TThOUG+Ml34e2+ZtW6k6nt4i9KuH3nYAJ5mxh7OM4= +cloud.google.com/go/compute v1.54.0/go.mod h1:RfBj0L1x/pIM84BrzNX2V21oEv16EKRPBiTcBRRH1Ww= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/contactcenterinsights v1.17.4/go.mod h1:kZe6yOnKDfpPz2GphDHynxk/Spx+53UX/pGf+SmWAKM= +cloud.google.com/go/container v1.46.0/go.mod h1:A7gMqdQduTk46+zssWDTKbGS2z46UsJNXfKqvMI1ZO4= +cloud.google.com/go/containeranalysis v0.14.2/go.mod h1:FjppROiUtP9cyMegdWdY/TsBSGc6kqh1GjA2NOJXXL8= +cloud.google.com/go/datacatalog v1.26.1/go.mod h1:2Qcq8vsHNxMDgjgadRFmFG47Y+uuIVsyEGUrlrKEdrg= +cloud.google.com/go/dataflow v0.11.1/go.mod h1:3s6y/h5Qz7uuxTmKJKBifkYZ3zs63jS+6VGtSu8Cf7Y= +cloud.google.com/go/dataform v0.13.0/go.mod h1:U3fqrPY5jAcFh1a8rQb4a+PQ7zKlc5qfgotFZ+luKPo= +cloud.google.com/go/datafusion v1.8.7/go.mod h1:4dkFb1la41qCEXh1AzYtFwl842bu2ikTUXyKhjvFCb0= +cloud.google.com/go/datalabeling v0.9.7/go.mod h1:EEUVn+wNn3jl19P2S13FqE1s9LsKzRsPuuMRq2CMsOk= +cloud.google.com/go/dataplex v1.28.0/go.mod h1:VB+xlYJiJ5kreonXsa2cHPj0A3CfPh/mgiHG4JFhbUA= +cloud.google.com/go/dataproc/v2 v2.16.0/go.mod h1:HlzFg8k1SK+bJN3Zsy2z5g6OZS1D4DYiDUgJtF0gJnE= +cloud.google.com/go/dataqna v0.9.8/go.mod h1:2lHKmGPOqzzuqCc5NI0+Xrd5om4ulxGwPpLB4AnFgpA= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/datastore v1.22.0/go.mod h1:aopSX+Whx0lHspWWBj+AjWt68/zjYsPfDe3LjWtqZg8= +cloud.google.com/go/datastream v1.15.1/go.mod h1:aV1Grr9LFon0YvqryE5/gF1XAhcau2uxN2OvQJPpqRw= +cloud.google.com/go/deploy v1.27.3/go.mod h1:7LFIYYTSSdljYRqY3n+JSmIFdD4lv6aMD5xg0crB5iw= +cloud.google.com/go/dialogflow v1.76.0/go.mod h1:mdLkMmSCghfcP85X9dFBlirC1OssS65KE5hrrSz2GXY= +cloud.google.com/go/dlp v1.28.0/go.mod h1:C3od1fIK8lf7Kr62aU1Uh0z4OL5Z8s3do3znAiEupAw= +cloud.google.com/go/documentai v1.42.0/go.mod h1:CABOUzRNOuvb/QwJS2LS80Hpqbu3UW2afyRKTYuW7bo= +cloud.google.com/go/domains v0.10.7/go.mod h1:T3WG/QUAO/52z4tUPooKS8AY7yXaFxPYn1V3F0/JbNQ= +cloud.google.com/go/edgecontainer v1.4.4/go.mod h1:yyNVHsCKtsX/0mqFdbljQw0Uo660q2dlMPaiqYiC2Tg= +cloud.google.com/go/errorreporting v0.4.0/go.mod h1:dZGEhqzdHZSRxxWLVjC3Ue5CVaROzvP58D9rU6zbBfw= +cloud.google.com/go/essentialcontacts v1.7.7/go.mod h1:ytycWAEn/aKUMRKQPMVgMrAtphEMgjbzL8vFwM3tqXs= +cloud.google.com/go/eventarc v1.18.0/go.mod h1:/6SDoqh5+9QNUqCX4/oQcJVK16fG/snHBSXu7lrJtO8= +cloud.google.com/go/filestore v1.10.3/go.mod h1:94ZGyLTx9j+aWKozPQ6Wbq1DuImie/L/HIdGMshtwac= cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= +cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4= +cloud.google.com/go/functions v1.19.7/go.mod h1:xbcKfS7GoIcaXr2FSwmtn9NXal1JR4TV6iYZlgXffwA= +cloud.google.com/go/gkebackup v1.8.1/go.mod h1:GAaAl+O5D9uISH5MnClUop2esQW4pDa2qe/95A4l7YQ= +cloud.google.com/go/gkeconnect v0.12.5/go.mod h1:wMD2RXcsAWlkREZWJDVeDV70PYka1iEb9stFmgpw+5o= +cloud.google.com/go/gkehub v0.16.0/go.mod h1:ADp27Ucor8v81wY+x/5pOxTorxkPj/xswH3AUpN62GU= +cloud.google.com/go/gkemulticloud v1.6.0/go.mod h1:bGpd4o/Z5Z/XFlaojkgdVisHRwb+fLJvUPzsmV0I9ok= +cloud.google.com/go/gsuiteaddons v1.7.8/go.mod h1:DBKNHH4YXAdd/rd6zVvtOGAJNGo0ekOh+nIjTUDEJ5U= cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/iap v1.11.3/go.mod h1:+gXO0ClH62k2LVlfhHzrpiHQNyINlEVmGAE3+DB4ShU= +cloud.google.com/go/ids v1.5.7/go.mod h1:N3ZQOIgIBwwOu2tzyhmh3JDT+kt8PcoKkn2BRT9Qe4A= +cloud.google.com/go/iot v1.8.7/go.mod h1:HvVcypV8LPv1yTXSLCNK+YCtqGHhq+p0F3BXETfpN+U= +cloud.google.com/go/kms v1.26.0/go.mod h1:pHKOdFJm63hxBsiPkYtowZPltu9dW0MWvBa6IA4HM58= +cloud.google.com/go/language v1.14.6/go.mod h1:7y3J9OexQsfkWNGCxhT+7lb64pa60e12ZCoWDOHxJ1M= +cloud.google.com/go/lifesciences v0.10.7/go.mod h1:v3AbTki9iWttEls/Wf4ag3EqeLRHofploOcpsLnu7iY= cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA= cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak= cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= +cloud.google.com/go/managedidentities v1.7.7/go.mod h1:nwNlMxtBo2YJMvsKXRtAD1bL41qiCI9npS7cbqrsJUs= +cloud.google.com/go/maps v1.29.0/go.mod h1:FNATcM5ziB2TDE2IVWH4f/yeXc+SbUk1X+bmKjR8HEA= +cloud.google.com/go/mediatranslation v0.9.7/go.mod h1:mz3v6PR7+Fd/1bYrRxNFGnd+p4wqdc/fyutqC5QHctw= +cloud.google.com/go/memcache v1.11.7/go.mod h1:AU1jYlUqCihxapcJ1GGMtlMWDVhzjbfUWBXqsXa4rBg= +cloud.google.com/go/metastore v1.14.8/go.mod h1:h1XI2LpD4ohJhQYn9TwXqKb5sVt6KSo47ft96SiFF1s= cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/networkconnectivity v1.21.0/go.mod h1:XC1UJ+tqBsLWz73dqrMc7kUvdTv0FIxtDGv6YntTBO0= +cloud.google.com/go/networkmanagement v1.23.0/go.mod h1:QTYCWp5UxUnU280SqF7AX/mf6NhsqKblmLeCALQmx5c= +cloud.google.com/go/networksecurity v0.11.0/go.mod h1:JLgDsg4tOyJ3eMO8lypjqMftbfd60SJ+P7T+DUmWBsM= +cloud.google.com/go/notebooks v1.12.7/go.mod h1:uR9pxAkKmlNloibMr9Q1t8WhIu4P2JeqJs7c064/0Mo= +cloud.google.com/go/optimization v1.7.7/go.mod h1:OY2IAlX23o52qwMAZ0w65wibKuV12a4x6IHDTCq6kcU= +cloud.google.com/go/orchestration v1.11.10/go.mod h1:tz7m1s4wNEvhNNIM3JOMH0lYxBssu9+7si5MCPw/4/0= +cloud.google.com/go/orgpolicy v1.15.1/go.mod h1:bpvi9YIyU7wCW9WiXL/ZKT7pd2Ovegyr2xENIeRX5q0= +cloud.google.com/go/osconfig v1.16.0/go.mod h1:PRmLgZ1loD1hGaqnTBww1nETbqcqAvmTQOLYiIZ7Nvk= +cloud.google.com/go/oslogin v1.14.7/go.mod h1:NB6NqBHfDMwznePdBVX+ILllc1oPCdNSGp5u/WIyndY= +cloud.google.com/go/phishingprotection v0.9.7/go.mod h1:JTI4HNGyAbWolBoNOoCyCF0e3cqPNrYnlievHU49EwE= +cloud.google.com/go/policytroubleshooter v1.11.7/go.mod h1:JP/aQ+bUkt4Gz6lQXBi/+A/6nyNRZ0Pvxui5Xl9ieyk= +cloud.google.com/go/privatecatalog v0.10.8/go.mod h1:BkLHi+rtAGYBt5DocXLytHhF0n6F03Tegxgty40Y7aA= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/pubsub v1.50.1/go.mod h1:6YVJv3MzWJUVdvQXG081sFvS0dWQOdnV+oTo++q/xFk= +cloud.google.com/go/pubsub/v2 v2.0.0/go.mod h1:0aztFxNzVQIRSZ8vUr79uH2bS3jwLebwK6q1sgEub+E= +cloud.google.com/go/pubsublite v1.8.2/go.mod h1:4r8GSa9NznExjuLPEJlF1VjOPOpgf3IT6k8x/YgaOPI= +cloud.google.com/go/recaptchaenterprise/v2 v2.21.0/go.mod h1:HxQYqZC2/zl2CvKN7jJEv71vEdDi1GMGNUiZxnpiuVI= +cloud.google.com/go/recommendationengine v0.9.7/go.mod h1:snZ/FL147u86Jqpv1j95R+CyU5NvL/UzYiyDo6UByTM= +cloud.google.com/go/recommender v1.13.6/go.mod h1:y5/5womtdOaIM3xx+76vbsiA+8EBTIVfWnxHDFHBGJM= +cloud.google.com/go/redis v1.18.3/go.mod h1:x8HtXZbvMBDNT6hMHaQ022Pos5d7SP7YsUH8fCJ2Wm4= +cloud.google.com/go/resourcemanager v1.10.7/go.mod h1:rScGkr6j2eFwxAjctvOP/8sqnEpDbQ9r5CKwKfomqjs= +cloud.google.com/go/resourcesettings v1.8.3/go.mod h1:BzgfXFHIWOOmHe6ZV9+r3OWfpHJgnqXy8jqwx4zTMLw= +cloud.google.com/go/retail v1.26.0/go.mod h1:gMfh6s174Mvy1rK4g50J9TH5sRim8px+Krml25kdrqo= +cloud.google.com/go/run v1.15.0/go.mod h1:rgFHMdAopLl++57vzeqA+a1o2x0/ILZnEacRD6nC0EA= +cloud.google.com/go/scheduler v1.11.8/go.mod h1:bNKU7/f04eoM6iKQpwVLvFNBgGyJNS87RiFN73mIPik= +cloud.google.com/go/secretmanager v1.16.0/go.mod h1://C/e4I8D26SDTz1f3TQcddhcmiC3rMEl0S1Cakvs3Q= +cloud.google.com/go/security v1.19.2/go.mod h1:KXmf64mnOsLVKe8mk/bZpU1Rsvxqc0Ej0A6tgCeN93w= +cloud.google.com/go/securitycenter v1.38.1/go.mod h1:Ge2D/SlG2lP1FrQD7wXHy8qyeloRenvKXeB4e7zO6z0= +cloud.google.com/go/servicedirectory v1.12.7/go.mod h1:gOtN+qbuCMH6tj2dqlDY3qQL7w3V0+nkWaZElnJK8Ps= +cloud.google.com/go/shell v1.8.7/go.mod h1:OTke7qc3laNEW5Jr5OV9VR3IwU5x5VqGOE6705zFex4= +cloud.google.com/go/spanner v1.88.0/go.mod h1:MzulBwuuYwQUVdkZXBBFapmXee3N+sQrj2T/yup6uEE= +cloud.google.com/go/speech v1.30.0/go.mod h1:F2+NJujR8uzDLd6bwy5kgtVycxvEq06nzvzz5eQ/gMo= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= @@ -67,8 +165,23 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg= cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk= +cloud.google.com/go/storagetransfer v1.13.1/go.mod h1:S858w5l383ffkdqAqrAA+BC7KlhCqeNieK3sFf5Bj4Y= +cloud.google.com/go/talent v1.8.4/go.mod h1:3yukBXUTVFNyKcJpUExW/k5gqEy8qW6OCNj7WdN0MWo= +cloud.google.com/go/texttospeech v1.16.0/go.mod h1:AeSkoH3ziPvapsuyI07TWY4oGxluAjntX+pF4PJ2jy0= +cloud.google.com/go/tpu v1.8.4/go.mod h1:ul0cyWSHr6jHGZYElZe6HvQn35VY93RAlwpDiSBRnPA= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +cloud.google.com/go/translate v1.12.7/go.mod h1:wwJp14NZyWvcrFANhIXutXj0pOBkYciBHwSlUOykcjI= +cloud.google.com/go/video v1.27.1/go.mod h1:xzfAC77B4vtnbi/TT3UUxEjCa/+Ehy5EA8w470ytOig= +cloud.google.com/go/videointelligence v1.12.7/go.mod h1:XAk5hCMY+GihxJ55jNoMdwdXSNZnCl3wGs2+94gK7MA= +cloud.google.com/go/vision/v2 v2.9.6/go.mod h1:lJC+vP15D5znJvHQYjEoTKnpToX1L93BUlvBmzM0gyg= +cloud.google.com/go/vmmigration v1.10.0/go.mod h1:LDztCWEb+RwS1bPg4Xzt0fcJS9kVrFxa3ejhH7OW9vg= +cloud.google.com/go/vmwareengine v1.3.6/go.mod h1:ps0rb+Skgpt9ppHYC0o5DqtJ5ld2FyS8sAqtbHH8t9s= +cloud.google.com/go/vpcaccess v1.8.7/go.mod h1:9RYw5bVvk4Z51Rc8vwXT63yjEiMD/l7XyEaDyrNHgmk= +cloud.google.com/go/webrisk v1.11.2/go.mod h1:yH44GeXz5iz4HFsIlGeoVvnjwnmfbni7Lwj1SelV4f0= +cloud.google.com/go/websecurityscanner v1.7.7/go.mod h1:ng/PzARaus3Bj4Os4LpUnyYHsbtJky1HbBDmz148v1o= +cloud.google.com/go/workflows v1.14.3/go.mod h1:CC9+YdVI2Kvp0L58WajHpEfKJxhrtRh3uQ0SYWcmAk4= +crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk= cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8= cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= @@ -78,6 +191,10 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7 dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU= dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4= dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU= +gioui.org v0.2.0/go.mod h1:1H72sKEk/fNFV+l0JNeM2Dt3co3Y4uaQcD+I+/GQ0e4= +gioui.org/cpu v0.0.0-20220412190645-f1e9e8c3b1f7/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= +gioui.org/shader v1.0.6/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= +gioui.org/x v0.2.0/go.mod h1:rCGN2nZ8ZHqrtseJoQxCMZpt2xrZUrdZ2WuMRLBJmYs= git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= git.sr.ht/~sbinet/cmpimg v0.1.0 h1:E0zPRk2muWuCqSKSVZIWsgtU9pjsw3eKHi8VmQeScxo= git.sr.ht/~sbinet/cmpimg v0.1.0/go.mod h1:FU12psLbF4TfNXkKH2ZZQ29crIqoiqTZmeQ7dkp/pxE= @@ -87,11 +204,29 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20250520111509-a70c2aa677fa h1:x6kFzdPgBoLbyoNkA/jny0ENpoEz4wqY8lPTQL2DPkg= github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20250520111509-a70c2aa677fa/go.mod h1:gCLVsLfv1egrcZu+GoJATN5ts75F2s62ih/457eWzOw= +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization v1.0.0/go.mod h1:lPneRe3TwsoDRKY4O6YDLXHhEWrD+TIRa8XrV/3/fqw= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.1.1/go.mod h1:WqyxV5S0VtXD2+2d6oPqOvyhGubCvzLCKSAKgQ004Uk= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v2 v2.4.0/go.mod h1:U5gpsREQZE6SLk1t/cFfc1eMhYAlYpEzvaYXuDfefy8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/Azure/go-autorest/autorest v0.11.29/go.mod h1:ZtEzC4Jy2JDrZLxvWs8LrBWEBycl1hbT1eknI8MtfAs= +github.com/Azure/go-autorest/autorest/adal v0.9.24/go.mod h1:7T1+g0PYFmACYW5LlG2fcoPiPlFHjClyRGL7dRlP5c8= +github.com/Azure/go-autorest/autorest/azure/auth v0.5.13/go.mod h1:5BAVfWLWXihP47vYrPuBKKf4cS0bXI+KM9Qx6ETDJYo= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.6/go.mod h1:piCfgPho7BiIDdEQ1+g4VmKyD5y+p/XtSNqE6Hc4QD0= +github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= +github.com/Azure/go-autorest/autorest/to v0.4.0/go.mod h1:fE8iZBn7LQR7zH/9XU2NcPR4o9jEImooCeWJcYV/zLE= +github.com/Azure/go-autorest/autorest/validation v0.3.1/go.mod h1:yhLgjC0Wda5DYXl6JAsWyUe4KVNffhoDhG0zVzUMo3E= +github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= +github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= @@ -104,6 +239,7 @@ github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/DmitriyVTitov/size v1.5.0 h1:/PzqxYrOyOUX1BXj6J9OuVRVGe+66VL4D9FlUaW515g= github.com/DmitriyVTitov/size v1.5.0/go.mod h1:le6rNI4CoLQV1b9gzp1+3d7hMAD/uu2QcJ+aYbNgiU0= +github.com/GoogleCloudPlatform/docker-credential-gcr v2.0.5+incompatible/go.mod h1:BB1eHdMLYEFuFdBlRMb0N7YGVdM5s6Pt0njxgvfbGGs= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.34.0 h1:yzIYdwuro811Z27D3T80Wkd3rqZzb0K43nner7Eh1yE= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.34.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= @@ -112,44 +248,60 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/Microsoft/cosesign1go v1.4.0/go.mod h1:1La/HcGw19rRLhPW0S6u55K6LKfti+GQSgGCtrfhVe8= +github.com/Microsoft/didx509go v0.0.3/go.mod h1:wWt+iQsLzn3011+VfESzznLIp/Owhuj7rLF7yLglYbk= 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.14.0-rc.1 h1:qAPXKwGOkVn8LlqgBN8GS0bxZ83hOJpcjxzmlQKxKsQ= github.com/Microsoft/hcsshim v0.14.0-rc.1/go.mod h1:hTKFGbnDtQb1wHiOWv4v0eN+7boSWAHyK/tNAaYZL0c= +github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= 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/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I= +github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY= 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= github.com/SergJa/jsonhash v0.0.0-20210531165746-fc45f346aa74/go.mod h1:GE9lvSMBrKhFDkoh660mCThn1v7/jfb1r0Z+DpUX4zQ= +github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= +github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= github.com/acobaugh/osrelease v0.1.0 h1:Yb59HQDGGNhCj4suHaFQQfBps5wyoKLSSX/J/+UifRE= github.com/acobaugh/osrelease v0.1.0/go.mod h1:4bFEs0MtgHNHBrmHCt67gNisnabCRAlzdVasCEGHTWY= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/agnivade/levenshtein v1.2.0/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= +github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= +github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= +github.com/alecthomas/kong v0.5.0/go.mod h1:uzxf/HUh0tj43x1AyJROl3JT7SgsZ5m+icOv1csRhc0= +github.com/alecthomas/participle/v2 v2.1.0/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/aliyun/alibaba-cloud-sdk-go v1.63.68/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ= github.com/anchore/archiver/v3 v3.5.3-0.20241210171143-5b1d8d1c7c51 h1:yhk+P8lF3ZiROjmaVRao9WGTRo4b/wYjoKEiAHWrKwc= github.com/anchore/archiver/v3 v3.5.3-0.20241210171143-5b1d8d1c7c51/go.mod h1:nwuGSd7aZp0rtYt79YggCGafz1RYsclE7pi3fhLwvuw= +github.com/anchore/bubbly v0.0.0-20231115134915-def0aba654a9/go.mod h1:3ZsFB9tzW3vl4gEiUeuSOMDnwroWxIxJelOOHUp8dSw= github.com/anchore/clio v0.0.0-20250715152405-a0fa658e5084 h1:7DUAXEdAxoANPlDgxYiaSRKnWnTygvdrrWhnmvEjNLg= github.com/anchore/clio v0.0.0-20250715152405-a0fa658e5084/go.mod h1:42dWox8z4//b898OIELsQnSdYq9q1aCXkwp5fKF+BEU= github.com/anchore/fangs v0.0.0-20250716230140-94c22408c232 h1:aVC6r9h5wGNh8BYTW3CXxOdPoZzY/bBRWne1NvSTlO8= @@ -175,6 +327,7 @@ github.com/anchore/go-testutils v0.0.0-20200925183923-d5f45b0d3c04 h1:VzprUTpc0v github.com/anchore/go-testutils v0.0.0-20200925183923-d5f45b0d3c04/go.mod h1:6dK64g27Qi1qGQZ67gFmBFvEHScy0/C8qhQhNe5B5pQ= github.com/anchore/go-version v1.2.2-0.20210903204242-51efa5b487c4 h1:rmZG77uXgE+o2gozGEBoUMpX27lsku+xrMwlmBZJtbg= github.com/anchore/go-version v1.2.2-0.20210903204242-51efa5b487c4/go.mod h1:Bkc+JYWjMCF8OyZ340IMSIi2Ebf3uwByOk6ho4wne1E= +github.com/anchore/grype v0.99.1/go.mod h1:cTGGq+9Z2O9gGbe2EYiiLyqtmmRAdAZKbBA3i9ZXAao= github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115 h1:ZyRCmiEjnoGJZ1+Ah0ZZ/mKKqNhGcUZBl0s7PTTDzvY= github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115/go.mod h1:KoYIv7tdP5+CC9VGkeZV4/vGCKsY55VvoG+5dadg4YI= github.com/anchore/stereoscope v0.1.9 h1:Nhvk8g6PRx9ubaJU4asAhD3fGcY5HKXZCDGkxI2e0sI= @@ -182,12 +335,14 @@ github.com/anchore/stereoscope v0.1.9/go.mod h1:YkrCtDgz7A+w6Ggd0yxU9q58CerqQFwY github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/stroke v0.0.0-20221221101821-bd29b49d73f0/go.mod h1:ccdDYaY5+gO+cbnQdFxEXqfy0RkoV25H3jLXUDNM3wg= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/aquasecurity/go-pep440-version v0.0.1 h1:8VKKQtH2aV61+0hovZS3T//rUF+6GDn18paFTVS0h0M= @@ -211,6 +366,7 @@ github.com/armosec/utils-go v0.0.58 h1:g9RnRkxZAmzTfPe2ruMo2OXSYLwVSegQSkSavOfma github.com/armosec/utils-go v0.0.58/go.mod h1:CdqKHKruVJMCxGcZXYW9J+5P9FZou8dMzVpcB0Xt8pk= github.com/armosec/utils-k8s-go v0.0.35 h1:CliNObhAca5UYl84m5OQecOTm9ZfMFI8648pYhQJiu4= github.com/armosec/utils-k8s-go v0.0.35/go.mod h1:iHwR/KhMFtdd8Px1oYexLZYOHqmdknfGTZ8b7sZS0Ms= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= @@ -233,12 +389,19 @@ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72 github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.56.2/go.mod h1:dLREOeW66eVaaGIOi2ZlLHDgkR3nuJ02rd00j0YSlBE= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.195.0/go.mod h1:00zqVNJFK6UASrTnuvjJHJuaqUdkVz5tW8Ip+VhzuNg= +github.com/aws/aws-sdk-go-v2/service/ecr v1.45.1/go.mod h1:xZzWl9AXYa6zsLLH41HBFW8KRKJRIzlGmvSM0mVMIX4= +github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.33.2/go.mod h1:JUBHdhvKbbKmhaHjLsKJAWnQL80T6nURmhB/LEprV+4= github.com/aws/aws-sdk-go-v2/service/ecs v1.71.0 h1:MzP/ElwTpINq+hS80ZQz4epKVnUTlz8Sz+P/AFORCKM= github.com/aws/aws-sdk-go-v2/service/ecs v1.71.0/go.mod h1:pMlGFDpHoLTJOIZHGdJOAWmi+xeIlQXuFTuQxs1epYE= +github.com/aws/aws-sdk-go-v2/service/eks v1.48.5/go.mod h1:9dn8p15siUL80NCTPVNd+YvEpVTmWO+rboGx6qOMBa0= +github.com/aws/aws-sdk-go-v2/service/iam v1.53.6/go.mod h1:RJNVc52A0K41fCDJOnsCLeWJf8mwa0q30fM3CfE9U18= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.20/go.mod h1:ihZMtPTKoX/ugQRHbui6zNdSgVYN1KY2Dgwb2d3hXlc= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= @@ -247,6 +410,8 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3q github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow= github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE= +github.com/aws/aws-sdk-go-v2/service/sns v1.39.13/go.mod h1:RwF6Xnba8PlINxJUQq1IAWeon6IglvqsnhNqV8QsQjk= +github.com/aws/aws-sdk-go-v2/service/sqs v1.42.24/go.mod h1:Ql9ziDutk8ERAN9HMaYANCW3lop451ppebkxEJMLCTM= github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o= github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU= @@ -255,10 +420,13 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8 github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.10.1/go.mod h1:EVJOSYOVeoD3VFFZ/dWCAzWJp5wZr9lTOCjW8ejAmO0= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/becheran/wildmatch-go v1.0.0 h1:mE3dGGkTmpKtT4Z+88t8RStG40yN9T+kFEGj2PZFSzA= github.com/becheran/wildmatch-go v1.0.0/go.mod h1:gbMvj0NtVdJ15Mg/mH9uxk2R1QCistMyU7d9KFzroX4= +github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -268,12 +436,14 @@ github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bitnami/go-version v0.0.0-20250505154626-452e8c5ee607 h1:lBg3tHGquFySSblLi9zNi2iGNmVLRHBzVal2fqphCM8= github.com/bitnami/go-version v0.0.0-20250505154626-452e8c5ee607/go.mod h1:9iglf1GG4oNRJ39bZ5AZrjgAFD2RwQbXw6Qf7Cs47wo= +github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4= github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= 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/v2 v2.0.4/go.mod h1:QMmcs3H2AUQICWhfzLXz+IYln8lRQmTZRptLie8RgRw= 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= @@ -282,12 +452,14 @@ github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4 github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8= github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= +github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY= github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8= github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= @@ -303,20 +475,26 @@ github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/checkpoint-restore/checkpointctl v1.3.0/go.mod h1:dqZH4wDvbjnsqFGK2LdUDk21yFQ1dCAtzgRMlG44KDM= +github.com/checkpoint-restore/go-criu/v7 v7.2.0/go.mod h1:u0LCWLg0w4yqqu14aXhiB4YD3a1qd8EcCEg7vda5dwo= +github.com/cheggaaa/pb v1.0.27/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s= github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs= github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= @@ -328,11 +506,24 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/cilium/cilium v1.17.17 h1:lm2loJwLu9jDjC5Izk71Y5Zkr//rY7UXqvQjNT2KwFE= github.com/cilium/cilium v1.17.17/go.mod h1:h1VXfDHk2lnb02j9QwsTugs2dQw+7eBx03ELeUg7gJE= +github.com/cilium/coverbee v0.3.3-0.20240723084546-664438750fce/go.mod h1:6RGqSqaXtkBGjm7na2bKFi52BeeGUuiT3178zeje4Ik= +github.com/cilium/deepequal-gen v0.0.0-20241016021505-f57df2fe2e62/go.mod h1:9EU8oWNwEP6f98xJz/YjWw7yOLHK7p90MKmaPu1wBcE= +github.com/cilium/dns v1.1.51-0.20240603182237-af788769786a/go.mod h1:/7LC2GOgyXJ7maupZlaVIumYQiGPIgllSf6mA9sg6RU= github.com/cilium/ebpf v0.5.0/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs= github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2usCA= github.com/cilium/ebpf v0.8.1/go.mod h1:f5zLIM0FSNuAkSyLAN7X+Hy6yznlF1mNiWUMfxMtrgk= github.com/cilium/ebpf v0.22.0 h1:v2ktp0roffpMOj2MMf3idtCQZOsAoC4BJbAJN+ke2bY= github.com/cilium/ebpf v0.22.0/go.mod h1:CDzZbe2hC5JjlDC+CY3KFCzlYwN4gbxppYM+Z10bQt4= +github.com/cilium/endpointslice v0.29.4-0.20240409195643-982ad68ab7ba/go.mod h1:9MPoeojWVEBLFnioKXTvRoqGWTs9Dt252r1ACFsi8K8= +github.com/cilium/endpointslice-controller v0.0.0-20240409203012-75cb5d61db1b/go.mod h1:izWO5C3waDVkh/nt++nNyozXyJAPL6tfFpJSMtzVnwQ= +github.com/cilium/fake v0.6.1/go.mod h1:V9lCbbcsnSf3vB6sdOP7Q0bsUUJ/jyHPZxnFAw5nPUc= +github.com/cilium/hive v0.0.0-20250522145610-0734675df148/go.mod h1:pI2GJ1n3SLKIQVFrKF7W6A6gb6BQkZ+3Hp4PAEo5SuI= +github.com/cilium/linters v0.1.0/go.mod h1:mpr0RBmILRLQu2F7ek+gjBxxuacUB/IBmjDRC3RaFkI= +github.com/cilium/lumberjack/v2 v2.4.1/go.mod h1:yfbtPGmg4i//5oEqzaMxDqSWqgfZFmMoV70Mc2k6v0A= +github.com/cilium/proxy v0.0.0-20250526114940-b80199397e8a/go.mod h1:Bs5kHQ+FYHLrAkEaTQpbwblJIv6NfU2Tsuo+wCkN+9s= +github.com/cilium/statedb v0.4.5/go.mod h1:DlxX9OQi/nM8oumUuz8VjxXUtVRiEfbfo8Ri1YWNCGI= +github.com/cilium/stream v0.0.0-20241203114243-53c3e5d79744/go.mod h1:/e83AwqvNKpyg4n3C41qmnmj1x2G9DwzI+jb7GkF4lI= +github.com/cilium/workerpool v1.2.0/go.mod h1:GOYJhwlnIjR+jWSDNBb5kw47G1H/XA9X4WOBpgr4pQU= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= @@ -354,12 +545,17 @@ github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= 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/aufs v1.0.0/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU= +github.com/containerd/btrfs/v2 v2.0.0/go.mod h1:swkD/7j9HApWpzl8OHfrHNxppPd9l44DFZdF94BUj9k= +github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw= 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/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/containerd/containerd v1.7.36 h1:HyMsOG5kmp1LQsGzqwI6Ts06T4VpYZbszfDZYB0bW5w= github.com/containerd/containerd v1.7.36/go.mod h1:ozI//0TomTCLPhQREnx0IXDIQMg+Fk7yTtg9fNvU8EQ= 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/containerd/v2 v2.1.2/go.mod h1:8C5QV9djwsYDNhxfTCFjWtTBZrqjditQ4/ghHSYjnHM= 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= @@ -368,47 +564,74 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/fifo v1.1.0 h1:4I2mbh5stb1u6ycIABlBw9zgtlK8viPI9QkQNRQEEmY= github.com/containerd/fifo v1.1.0/go.mod h1:bmC4NWMbXlt2EZ0Hc7Fx7QzTFxgPID13eH0Qu+MAb2o= +github.com/containerd/go-cni v1.1.9/go.mod h1:XYrZJ1d5W6E2VOvjffL3IZq0Dz6bsVlERHbekNK90PM= +github.com/containerd/go-runc v1.1.0/go.mod h1:xJv2hFF7GvHtTJd9JqTS2UVxMkULUYw4JN5XAUZqH5U= +github.com/containerd/imgcrypt v1.1.8/go.mod h1:x6QvFIkMyO2qGIY2zXc88ivEzcbgvLdWjoZyGqDap5U= 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.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/plugin v1.0.0/go.mod h1:hQfJe5nmWfImiqT1q8Si3jLv3ynMUIBB47bQ+KexvO8= +github.com/containerd/protobuild v0.3.0/go.mod h1:5mNMFKKAwCIAkFBPiOdtRx2KiQlyEJeMXnL5R1DsWu8= 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 v1.0.2/go.mod h1:9trJWW2sRlGub4wZJRTW83VtbOLS6hwcDZXTn6oPz9s= github.com/containerd/typeurl/v2 v2.2.3 h1:yNA/94zxWdvYACdYO8zofhrTVuQY73fFU1y++dYSw40= github.com/containerd/typeurl/v2 v2.2.3/go.mod h1:95ljDnPfD3bAbDJRugOiShd/DlAAsxGtUBhJxIn7SCk= +github.com/containerd/zfs v1.1.0/go.mod h1:oZF9wBnrnQjpWLaPKEinrx3TQ9a+W/RJO7Zb41d8YLE= +github.com/containernetworking/cni v1.3.0/go.mod h1:Bs8glZjjFfGPHMw6hQu82RUgEPNGEaBb9KS5KtNMnJ4= +github.com/containernetworking/plugins v1.7.1/go.mod h1:xuMdjuio+a1oVQsHKjr/mgzuZ24leAsqUYRnzGoXHy0= github.com/containers/common v0.64.2 h1:1xepE7QwQggUXxmyQ1Dbh6Cn0yd7ktk14sN3McSWf5I= github.com/containers/common v0.64.2/go.mod h1:o29GfYy4tefUuShm8mOn2AiL5Mpzdio+viHI7n24KJ4= +github.com/containers/image/v5 v5.36.2/go.mod h1:b4GMKH2z/5t6/09utbse2ZiLK/c72GuGLFdp7K69eA4= +github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01/go.mod h1:9rfv8iPl1ZP7aqh9YA68wnZv2NUDbXdcdPHVz0pFbPY= +github.com/containers/ocicrypt v1.2.1/go.mod h1:aD0AAqfMp0MtwqWgHM1bUwe1anx0VazI108CRrSKINQ= +github.com/containers/storage v1.59.1/go.mod h1:KoAYHnAjP3/cTsRS+mmWZGkufSY2GACiKQ4V3ZLQnR0= +github.com/coreos/go-oidc v2.3.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo= github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/crewjam/rfc5424 v0.1.0 h1:MSeXJm22oKovLzWj44AHwaItjIMUMugYGkEzfa831H8= github.com/crewjam/rfc5424 v0.1.0/go.mod h1:RCi9M3xHVOeerf6ULZzqv2xOGRO/zYaVUeRyPnBW3gQ= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ= github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/deitch/magic v0.0.0-20240306090643-c67ab88f10cb h1:4W/2rQ3wzEimF5s+J6OY3ODiQtJZ5W1sForSgogVXkY= github.com/deitch/magic v0.0.0-20240306090643-c67ab88f10cb/go.mod h1:B3tI9iGHi4imdLi4Asdha1Sc6feLMTfPLXh9IUYmysk= github.com/dghubble/trie v0.1.0 h1:kJnjBLFFElBwS60N4tkPvnLhnpcDxbBjIulgI8CpNGM= github.com/dghubble/trie v0.1.0/go.mod h1:sOmnzfBNH7H92ow2292dDFWNsVQuh/izuD7otCYb1ak= +github.com/dgraph-io/badger/v3 v3.2103.2/go.mod h1:RHo4/GmYcKKh5Lxu63wLEMHJ70Pac2JqZRYGhlyAo2M= +github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug= github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/didip/tollbooth/v7 v7.0.2/go.mod h1:RtRYfEmFGX70+ike5kSndSvLtQ3+F2EAmTI4Un/VXNc= +github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= +github.com/disiqueira/gotree/v3 v3.0.2/go.mod h1:ZuyjE4+mUQZlbpkI24AmruZKhg3VHEgPLDY8Qk+uUu8= github.com/diskfs/go-diskfs v1.7.0 h1:vonWmt5CMowXwUc79jWyGrf2DIMeoOjkLlMnQYGVOs8= github.com/diskfs/go-diskfs v1.7.0/go.mod h1:LhQyXqOugWFRahYUSw47NyZJPezFzB9UELwhpszLP/k= github.com/distribution/distribution v2.8.2+incompatible h1:k9+4DKdOG+quPFZXT/mUsiQrGu9vYCp+dXpuPkuqhk8= @@ -429,14 +652,19 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM 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= github.com/docker/go-events v0.0.0-20250114142523-c867878c5e32/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/libnetwork v0.8.0-dev.2.0.20210525090646-64b7a4574d14/go.mod h1:93m0aTqz6z+g32wla4l4WxTrdtvBRmVzYRkYvasA5Z8= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/dvyukov/go-fuzz v0.0.0-20210103155950-6a8e9d1f2415/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= +github.com/eapache/channels v1.1.0/go.mod h1:jMm2qB5Ubtg9zLd+inMZd2/NUvXgzmWXsDaLyQIGfH0= +github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/elliotchance/phpserialize v1.4.0 h1:cAp/9+KSnEbUC8oYCE32n2n84BeW8HOY3HMDI8hG2OY= @@ -458,6 +686,7 @@ github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go. github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/contrib v1.32.4/go.mod h1:gkGYoY7plfQg7FPBDhyKtP1cDA9frFR/3YsCx8taRvI= github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= @@ -468,11 +697,16 @@ github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMD github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= +github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c/go.mod h1:QGzNH9ujQ2ZUr/CjDGZGWeDAVStrWNjHeEcjJL96Nuk= github.com/facebookincubator/nvdtools v0.1.5 h1:jbmDT1nd6+k+rlvKhnkgMokrCAzHoASWE5LtHbX2qFQ= github.com/facebookincubator/nvdtools v0.1.5/go.mod h1:Kh55SAWnjckS96TBSrXI99KrEKH4iB0OJby3N8GRJO4= github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb h1:IT4JYU7k4ikYg1SCxNI1/Tieq/NFvh6dzLdgi7eu0tM= github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb/go.mod h1:bH6Xx7IW64qjjJq8M2u4dxNaBiDfKK+z/3eGDpXEQhc= +github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= @@ -488,6 +722,7 @@ github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9 github.com/florianl/go-tc v0.4.7 h1:Ysai5TIx4PgOzqI/1cse/pquOFCEkWofKtc/EPumfrg= github.com/florianl/go-tc v0.4.7/go.mod h1:Fdz6eHitQZwylSvpAW3y9R9cUrnS/zinuAdjJpD7XqY= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= @@ -507,6 +742,9 @@ github.com/gammazero/deque v1.0.0/go.mod h1:iflpYvtGfM3U8S8j+sZEKIak3SAKYpA5/SQe github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 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/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.14/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= 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= @@ -523,6 +761,7 @@ github.com/go-fonts/latin-modern v0.3.2 h1:M+Sq24Dp0ZRPf3TctPnG1MZxRblqyWC/cRUL9 github.com/go-fonts/latin-modern v0.3.2/go.mod h1:9odJt4NbRrbdj4UAMuLVd4zEukf6aAEKnDaQga0whqQ= github.com/go-fonts/liberation v0.3.2 h1:XuwG0vGHFBPRRI8Qwbi5tIvR3cku9LUfZGq/Ar16wlQ= github.com/go-fonts/liberation v0.3.2/go.mod h1:N0QsDLVUQPy3UYg9XAc3Uh3UDMp2Z7M1o4+X98dXkmI= +github.com/go-fonts/stix v0.2.2/go.mod h1:SUxggC9dxd/Q+rb5PkJuvfvTbOPtNc2Qaua00fIp9iU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= @@ -534,16 +773,21 @@ github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuw github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gota/gota v0.12.0/go.mod h1:UT+NsWpZC/FhaOyWb9Hui0jXg0Iq8e/YugZHTbyW/34= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= github.com/go-latex/latex v0.0.0-20231108140139-5c1ce85aa4ea h1:DfZQkvEbdmOe+JK2TMtBM+0I9GSdzE2y/L1/AmD8xKc= github.com/go-latex/latex v0.0.0-20231108140139-5c1ce85aa4ea/go.mod h1:Y7Vld91/HRbTBm7JwoI7HejdDB0u+e9AUBO9MB7yuZk= github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU= github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -603,21 +847,27 @@ github.com/go-openapi/validate v1.0.0 h1:dFsYCLVUQUL6Vi2lQSexgwmCXDuHe7eWRDhQxkE github.com/go-openapi/validate v1.0.0/go.mod h1:wwXGRqMQzOZ7PCqBcgNk+DD9+Cacnxv7we5T0M/eA3Y= github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw= github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= +github.com/go-pkgz/expirable-cache/v3 v3.0.0/go.mod h1:2OQiDyEGQalYecLWmXprm3maPXeVb5/6/X7yRPYTzec= github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s= github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= github.com/go-restruct/restruct v1.2.0-alpha h1:2Lp474S/9660+SJjpVxoKuWX09JsXHSrdV7Nv3/gkvc= github.com/go-restruct/restruct v1.2.0-alpha/go.mod h1:KqrpKpn4M8OLznErihXTGLlsXFGeLxHUrLRRI/1YjGk= +github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 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-text/typesetting v0.0.0-20230803102845-24e03d8b5372/go.mod h1:evDBbvNR/KaVFZ2ZlDSOWWXIUKq0wCOEtzLxRM8SG3k= 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/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= 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= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -626,6 +876,7 @@ github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FP github.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 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= @@ -633,9 +884,11 @@ github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxU 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-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -680,8 +933,10 @@ github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/cel-go v0.29.0 h1:fEG+Ja3YRwNOqnQxTyJwoByAUAvTuxUGiro/jhrm4F4= github.com/google/cel-go v0.29.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/flatbuffers v2.0.8+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmdtest v0.4.0/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -702,10 +957,16 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX 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-intervals v0.0.2/go.mod h1:MkaR3LNRfeKLPmqgJYs4E66z5InYjmCjbbr4TQlcT6Y= +github.com/google/go-licenses v1.6.1-0.20230903011517-706b9c60edd4/go.mod h1:BUoZvAC+k4aLzq3+719tLz4jZum9m7lfwuXKtq8n5VM= +github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= 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= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gops v0.3.28/go.mod h1:6f6+Nl8LcHrzJwi8+p0ii+vmBFSlB4f8cOOkTJ7sk4c= github.com/google/licensecheck v0.3.1 h1:QoxgoDkaeC4nFrtGN1jV7IPmDCHFNIVh54e5hSt6sPs= github.com/google/licensecheck v0.3.1/go.mod h1:ORkR35t/JjW+emNKtfJDII0zlciG9JgbT7SmsohlHmY= +github.com/google/licenseclassifier/v2 v2.0.0/go.mod h1:cOjbdH0kyC9R22sdQbYsFkto4NGCAc+ZSwbeThazEtM= github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -732,8 +993,10 @@ github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0Z github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ= github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/renameio/v2 v2.0.0/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -757,6 +1020,7 @@ github.com/gopacket/gopacket v1.5.0/go.mod h1:i3NaGaqfoWKAr1+g7qxEdWsmfT+MXuWkAe github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/goradd/maps v1.3.0 h1:toF7ALsgbjQBmmmRSACTAEO+9g2rApW8dU1WirFQyrE= github.com/goradd/maps v1.3.0/go.mod h1:O3i5k17BAjHa9h5dzGWWfRJizF03umiBDZsNSqFdbVA= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= @@ -768,6 +1032,11 @@ github.com/grafana/pyroscope-go/godeltaprof v0.1.8/go.mod h1:2+l7K7twW49Ct4wFluZ github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0/go.mod h1:qOchhhIlmRcqk/O9uCo/puJlyo07YINaIqdZfZG3Jkc= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 h1:Bd7KaOxzULLxtZ/K5s1aLbWhR0+5RToO65TXHsf3bqQ= @@ -787,8 +1056,10 @@ github.com/hashicorp/go-getter v1.8.6 h1:9sQboWULaydVphxc4S64oAI4YqpuCk7nPmvbk13 github.com/hashicorp/go-getter v1.8.6/go.mod h1:nVH12eOV2P58dIiL3rsU6Fh3wLeJEKBOJzhMmzlSWoo= github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v1.0.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= @@ -796,7 +1067,9 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-sockaddr v1.0.6/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= @@ -811,6 +1084,7 @@ github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uG github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= @@ -818,10 +1092,14 @@ github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= +github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0= github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4= +github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0= +github.com/hmarr/codeowners v1.2.1/go.mod h1:KPlR1p/B4owPjwfNIBueWlOP4CmqlQFX9b6nANG6j40= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0/go.mod h1:N0Wam8K1arqPXNWjMo21EXnBPOPp36vB07FNRdD2geA= github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= @@ -829,6 +1107,7 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/iceber/iouring-go v0.0.0-20230403020409-002cfd2e2a90 h1:xrtfZokN++5kencK33hn2Kx3Uj8tGnjMEhdt6FMvHD0= github.com/iceber/iouring-go v0.0.0-20230403020409-002cfd2e2a90/go.mod h1:LEzdaZarZ5aqROlLIwJ4P7h3+4o71008fSy6wpaEB+s= github.com/in-toto/attestation v1.1.2 h1:MBFn6lsMq6dptQZJBhalXTcWMb/aJy3V+GX3VYj/V1E= @@ -838,6 +1117,9 @@ github.com/in-toto/in-toto-golang v0.11.0/go.mod h1:u3PjTnwFKjp5a1YCcw8SJg0G+tMe github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/intel/goresctrl v0.5.0/go.mod h1:mIe63ggylWYr0cU/l8n11FAkesqfvuP3oktIsxvu0T0= +github.com/invopop/jsonschema v0.7.0/go.mod h1:O9uiLokuu0+MGFlyiaqtWxwqJm41/+8Nj0lD7A36YH0= +github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2/go.mod h1:co9pwDoBCm1kGxawmb4sPq0cSIOOWNPT4KnHotMP1Zg= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= @@ -852,22 +1134,28 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6 github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/jedib0t/go-pretty/v6 v6.6.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ= github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= +github.com/jeremywohl/flatten v1.0.1/go.mod h1:4AmD/VxjWcI5SRB0n6szE2A6s2fsNHDLO0nAlMHgfLQ= +github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/joncrlsn/dque v0.0.0-20241024143830-7723fd131a64 h1:fmH2K7R8pZJ0wVvJyGFmDnECuAE3NLjfAoJkN9mtfc8= github.com/joncrlsn/dque v0.0.0-20241024143830-7723fd131a64/go.mod h1:dNKs71rs2VJGBAmttu7fouEsRQlRjxy0p1Sx+T5wbpY= +github.com/josephspurrier/goversioninfo v1.5.0/go.mod h1:6MoTvFZ6GKJkzcdLnU5T/RGYUbHQbKpYeNP0AgQLd2o= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/native v0.0.0-20200817173448-b6b71def0850/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/jsimonetti/rtnetlink v0.0.0-20190606172950-9527aa82566a/go.mod h1:Oz+70psSo5OFh8DBl0Zv2ACw7Esh6pPUphlvZG9x7uw= github.com/jsimonetti/rtnetlink v0.0.0-20200117123717-f846d4f6c1f4/go.mod h1:WGuG/smIU4J/54PblvSbh+xvCZmpJnFgr3ds6Z55XMQ= github.com/jsimonetti/rtnetlink v0.0.0-20201009170750-9c6f07d100c1/go.mod h1:hqoO/u39cqLeBLebZ8fWdE96O7FxrAsRYhnVOdgHxok= @@ -888,10 +1176,13 @@ 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/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/k-sone/critbitgo v1.4.0/go.mod h1:7E6pyoyADnFxlUBEKcnfS49b7SUAQGMK+OAp/UQvo0s= 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= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/kinbiko/jsonassert v1.2.0 h1:+/JthIVXdIrThrOtSN9ry0mNtWKXMWuvxR0nU7gQ+tI= github.com/kinbiko/jsonassert v1.2.0/go.mod h1:pCc3uudOt+lVAbkji9O0uw8MSVt4s+1ZJ0y8Ux2F1Og= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -904,6 +1195,9 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f/go.mod h1:q59u9px8b7UTj0nIjEjvmTWekazka6xIt6Uogz5Dm+8= +github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23/go.mod h1:lUaIXCWzf7BRKTY5iEcrYy1TfgbYLYVIS/B2vPkJzOc= +github.com/knqyf263/go-plugin v0.9.0/go.mod h1:2z5lCO1/pez6qGo8CvCxSlBFSEat4MEp1DrnA+f7w8Q= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= @@ -922,12 +1216,20 @@ github.com/kubescape/backend v0.0.39 h1:B1QRfKCSFlzuE+jWOnk/l7EpH71/Q3n14KKq0QSn github.com/kubescape/backend v0.0.39/go.mod h1:cMEGP8cXUZgY89YU4GRBGIla9HZW7grZsUtlCwvZgAE= github.com/kubescape/go-logger v0.0.35 h1:o4zVrmRjP74ytfM0iNHhmJHnmCHjmyChwgP+aUmu2dw= github.com/kubescape/go-logger v0.0.35/go.mod h1:tNhDWN8Yn8Tf6h+eIIjT5s//Aqxx2kAJ/HO18souZ18= -github.com/kubescape/inspektor-gadget v0.0.0-20260826074832-06b0d12baca0 h1:kJzq1CnGP4kqAdyVWrb9TGlRpniwsIsVVMfo61OuzCQ= -github.com/kubescape/inspektor-gadget v0.0.0-20260826074832-06b0d12baca0/go.mod h1:cwCFczq1LJ6Frpur0Vr5Ncic77a9ihki07Xpwzy+ItI= +github.com/kubescape/inspektor-gadget v0.0.0-20261026143616-a449c3216e84 h1:OrOIfnTMzUYZ8z12LNnedtNnsnOG/YnsN+3/y1ZNQn4= +github.com/kubescape/inspektor-gadget v0.0.0-20261026143616-a449c3216e84/go.mod h1:cwCFczq1LJ6Frpur0Vr5Ncic77a9ihki07Xpwzy+ItI= +github.com/kubescape/inspektor-gadget v0.0.0-20261027101712-fe1de4618904 h1:wwklv1WjqA8XkBidBA1NL34wAwMU+amEGG+WlESu3LY= +github.com/kubescape/inspektor-gadget v0.0.0-20261027101712-fe1de4618904/go.mod h1:cwCFczq1LJ6Frpur0Vr5Ncic77a9ihki07Xpwzy+ItI= +github.com/kubescape/inspektor-gadget v0.0.0-20261027103046-6b8b24afaf6f h1:ZSYQ1kf32Hc2Y3jib49tX8Cv7JTQcdWVuRUczJJfYcA= +github.com/kubescape/inspektor-gadget v0.0.0-20261027103046-6b8b24afaf6f/go.mod h1:cwCFczq1LJ6Frpur0Vr5Ncic77a9ihki07Xpwzy+ItI= github.com/kubescape/k8s-interface v0.0.214 h1:j7KP0/5VvYOoQdBGV2+gRM3qnR8PWLAGF8RM/k/DmJ0= github.com/kubescape/k8s-interface v0.0.214/go.mod h1:WNYUG93aZ5kDmuaRKFLtVhp18Yc6EfaHdD1gLYtVTN4= -github.com/kubescape/storage v0.0.303 h1:0nXI6E07lbWsg7iEH04vR4kwiekj//uCQl/La+8j4aM= -github.com/kubescape/storage v0.0.303/go.mod h1:d/1hqWPda2clsjx2wmQgysnB5dThIo3rDKP7RWx+v+M= +github.com/kubescape/kubescape/v3 v3.0.4/go.mod h1:upPCVTCRT3+LuZ1bawGtjreRmr/Xa+LT0fHtPzlylRU= +github.com/kubescape/opa-utils v0.0.283/go.mod h1:N/UnbZHpoiHQH7O50yadhIXZvVl0IVtTGBmePPrSQSg= +github.com/kubescape/rbac-utils v0.0.21-0.20230806101615-07e36f555520/go.mod h1:wuxMUSDzGUyWd25IJfBzEJ/Udmw2Vy7npj+MV3u3GrU= +github.com/kubescape/regolibrary v1.0.317-0.20240320124840-1d84ac7186ea/go.mod h1:RK9dHjllKFnISDmVExQlI1B1z93TlQsAu/Kq9c0mt2U= +github.com/kubescape/storage v0.0.352-0.20261013133511-eb6406e01cb9 h1:pPFehIHOZNX/9nQn+vsegF46Crbeqaq2qp6Q3DIan6c= +github.com/kubescape/storage v0.0.352-0.20261013133511-eb6406e01cb9/go.mod h1:OSYXj9TzVAuuz+Gn2bIzp3Vg8rofTPH7x5Asek0N7mE= 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= @@ -935,21 +1237,37 @@ github.com/kubescape/workerpool v0.0.0-20250526074519-0e4a4e7f44cf/go.mod h1:Il5 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= +github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx v1.2.29/go.mod h1:hU8k2l6WF0ncx20uQdOmik/Gjg6E3/wIRtXSNFeZuB8= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/letsencrypt/boulder v0.20251110.0/go.mod h1:ogKCJQwll82m7OVHWyTuf8eeFCjuzdRQlgnZcCl0V+8= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/linuxkit/virtsock v0.0.0-20201010232012-f8cee7dfc7a3/go.mod h1:3r6x7q95whyfWQpmGZTu3gk3v2YkMi05HEzl7Tf7YEo= +github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/lthibault/jitterbug/v2 v2.2.2/go.mod h1:evaHKX+60nFbFnEvGNPybQMJ5vXay9auziApDGo47Sw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w= +github.com/lyft/protoc-gen-star/v2 v2.0.4/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk= github.com/mackerelio/go-osstat v0.2.5 h1:+MqTbZUhoIt4m8qzkVoXUJg1EuifwlAJSk4Yl2GXh+o= github.com/mackerelio/go-osstat v0.2.5/go.mod h1:atxwWF+POUZcdtR1wnsUcQxTytoHG4uhl2AKKzrOajY= +github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/masahiro331/go-mvn-version v0.0.0-20250131095131-f4974fa13b8a/go.mod h1:jZ3F25l7DbD7l7DcA8aj7eo1EZ84nbzcQHBB4lCSrI8= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -969,9 +1287,15 @@ github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZ github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= +github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mdlayher/arp v0.0.0-20220512170110-6706a2966875/go.mod h1:kfOoFJuHWp76v1RgZCb9/gVUc7XdY877S2uVYbNliGc= +github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118/go.mod h1:ZFUnHIVchZ9lJoWoEGUg8Q3M4U8aNNWA3CVSUTkW4og= github.com/mdlayher/ethtool v0.0.0-20210210192532-2b88debcdd43/go.mod h1:+t7E0lkKfbBsebllff1xdTmyJt8lH37niI6kwFk9OTo= github.com/mdlayher/genetlink v1.0.0/go.mod h1:0rJ0h4itni50A86M2kHcgS85ttZazNt7a8H2a2cw0Gc= +github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= github.com/mdlayher/netlink v0.0.0-20190409211403-11939a169225/go.mod h1:eQB3mZE4aiYnlUsyGGCOpPETfdQq4Jhsgf1fk3cwQaA= github.com/mdlayher/netlink v1.0.0/go.mod h1:KxeJAFOFLG6AjpyDkQ/iIhxygIUKD+vcwqcnu43w/+M= github.com/mdlayher/netlink v1.1.0/go.mod h1:H4WCitaheIsdF9yOYu8CFmCgQthAPIWZmcKp9uZHgmY= @@ -985,10 +1309,12 @@ github.com/mdlayher/netlink v1.4.1/go.mod h1:e4/KuJ+s8UhfUpO9z00/fDZZmhSrs+oxyqA github.com/mdlayher/netlink v1.6.0/go.mod h1:0o3PlBmGst1xve7wQ7j/hwpNaFaH4qCRyWCdcZk8/vA= github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= +github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4= github.com/mdlayher/socket v0.0.0-20210307095302-262dc9984e00/go.mod h1:GAFlyu4/XV68LkQKYzKhIo/WW7j3Zi0YRAz/BOoanUc= github.com/mdlayher/socket v0.1.1/go.mod h1:mYV5YIZAfHh4dzDVzI8x8tWLWCliuX8Mon5Awbj+qDs= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= +github.com/mdlayher/vsock v1.2.1/go.mod h1:NRfCibel++DgeMD8z/hP+PPTjlNJsdPOmxcnENvE+SE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ= @@ -997,21 +1323,27 @@ github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00v github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= +github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ= +github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0= github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc= github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A= github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= +github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= +github.com/mistifyio/go-zfs/v3 v3.0.1/go.mod h1:CzVgeB0RvF2EGzQnytKVvVSDwmKJXxkOTUGbNrTja/k= github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -1032,6 +1364,7 @@ github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7z github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/sys/signal v0.7.1 h1:PrQxdvxcGijdo6UXXo/lU/TvHUWyPhj7UOpSo8tuvk0= github.com/moby/sys/signal v0.7.1/go.mod h1:Se1VGehYokAkrSQwL4tDzHvETwUZlnY7S5XtQ50mQp8= +github.com/moby/sys/symlink v0.2.0/go.mod h1:7uZVF2dqJjG/NsClqul95CqKOBRQyYSNnJ6BMgR/gFs= github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0= github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= @@ -1048,17 +1381,22 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/multiformats/go-multihash v0.2.1/go.mod h1:WxoMcYG85AZVQUyRyo9s4wULvW5qrI9vb2Lt6evduFc= +github.com/multiformats/go-varint v0.0.6/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/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 v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= @@ -1084,6 +1422,10 @@ github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9l github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A= github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oapi-codegen/runtime v1.7.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= @@ -1092,9 +1434,11 @@ github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8= github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= +github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0/go.mod h1:F/7q8/HZz+TXjlsoZQQKVYvXTZaFH4QRa3y+j1p7MS0= github.com/olvrng/ujson v1.1.0 h1:8xVUzVlqwdMVWh5d1UHBtLQ1D50nxoPuPEq9Wozs8oA= github.com/olvrng/ujson v1.1.0/go.mod h1:Mz4G3RODTUfbkKyvi0lgmPx/7vd3Saksk+1jgk8s9xo= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= @@ -1103,18 +1447,23 @@ github.com/opcoder0/capabilities v0.0.0-20221222060822-17fd73bffd2a h1:sbMMqulR2 github.com/opcoder0/capabilities v0.0.0-20221222060822-17fd73bffd2a/go.mod h1:77JxdABQ4m37PtO4WMtRBrI+DDphomu/8tGeijYXspk= github.com/opcoder0/fanotify v0.4.2 h1:Bp7h8scp/LNoWmC16Z1kf7GfxehhQLcDPxqJ31+SThs= github.com/opcoder0/fanotify v0.4.2/go.mod h1:S0LITNqjkZwifQ+0qB1fT1S/SmDmnFH+h0SLBzoKW2Q= +github.com/open-policy-agent/opa v0.70.0/go.mod h1:Y/nm5NY0BX0BqjBriKUiV81sCl8XOjjvqQG7dXrggtI= +github.com/opencontainers/cgroups v0.0.4/go.mod h1:s8lktyhlGUqM7OSRL5P7eAW6Wb+kWPNvt4qvVfzA5vs= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/opencontainers/runc v1.3.0/go.mod h1:9wbWt42gV+KRxKRVVugNP6D5+PQciRbenB4fLVsqGPs= 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.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/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU= github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +github.com/osrg/gobgp/v3 v3.35.0/go.mod h1:l2nPaHaLmIoKbFxMUzKon/h6c9BTzCp5zJI9Dhnrx5c= github.com/packetcap/go-pcap v0.0.0-20250723190045-d00b185f30b7 h1:MfXxQU9tEe3zmyLVVwE8gJwQVtsG2aqzBkFNz0N6eAo= github.com/packetcap/go-pcap v0.0.0-20250723190045-d00b185f30b7/go.mod h1:1jryUz9E2ndKwZBNHzVhLMzS3WHO0fOKydYi9XWWu9w= github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg= @@ -1134,12 +1483,14 @@ github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 h1:ah1dvbqPMN5+ocrg/ZSgZ6k8bOk+kcZQ7fnyx6UvOm4= github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/picatz/xcel v0.0.0-20260226001349-6958ffac5706 h1:xfPEUCHSHcjpu4WxgtC1lwBkP4Xa7R/pl8sXVF103Yg= github.com/picatz/xcel v0.0.0-20260226001349-6958ffac5706/go.mod h1:bFTXcuU+280rICoGMpVTk/06XNfgvfeplhjWWoLKPys= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -1147,6 +1498,7 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= +github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA= github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM= github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= @@ -1156,6 +1508,9 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/pquerna/cachecontrol v0.1.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI= +github.com/proglottis/gpgme v0.1.4/go.mod h1:5LoXMgpE4bttgwwdv9bLs/vwqv3qV7F4glEEZ7mRKrM= +github.com/prometheus-community/pro-bing v0.5.0/go.mod h1:1joR9oXdMEAcAJJvhs+8vNDvTg5thfAZcRFhcUozG2g= github.com/prometheus/alertmanager v0.27.0 h1:V6nTa2J5V4s8TG4C4HtrBP/WNSebCCTYGGv4qecA/+I= github.com/prometheus/alertmanager v0.27.0/go.mod h1:8Ia/R3urPmbzJ8OsdvmZvIprDwvwmYCmUbwBL+jlPOE= github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= @@ -1175,6 +1530,9 @@ github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8 github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4= github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk= +github.com/prometheus/common/assets v0.2.0/go.mod h1:D17UVUE12bHbim7HzwUvtqm6gwBEaDQ0F+hIGbFbccI= +github.com/prometheus/common/sigv4 v0.1.0/go.mod h1:2Jkxxk9yYvCkE5G1sQT7GuEXm57JrvHu9k5YwTjsNtI= +github.com/prometheus/exporter-toolkit v0.11.0/go.mod h1:BVnENhnNecpwoTLiABx7mrPB/OLRIgN74qlQbV+FK1Q= github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= @@ -1183,8 +1541,11 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= +github.com/prometheus/prom2json v1.3.2/go.mod h1:TQ9o1OxW0eyhl4BBpVpGGsavyJfTDETna4/d0Kib+V0= github.com/puzpuzpuz/xsync/v2 v2.4.1 h1:aGdE1C/HaR/QC6YAFdtZXi60Df8/qBIrs8PKrzkItcM= github.com/puzpuzpuz/xsync/v2 v2.4.1/go.mod h1:gD2H2krq/w52MfPLE+Uy64TzJDVY7lP2znR9qmR35kU= +github.com/quasilyte/go-ruleguard/dsl v0.3.22/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -1195,10 +1556,13 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/rust-secure-code/go-rustaudit v0.0.0-20250226111315-e20ec32e963c h1:8gOLsYwaY2JwlTMT4brS5/9XJdrdIbmk2obvQ748CC0= github.com/rust-secure-code/go-rustaudit v0.0.0-20250226111315-e20ec32e963c/go.mod h1:kwM/7r/rVluTE8qJbHAffduuqmSv4knVQT2IajGvSiA= +github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/s3rj1k/go-fanotify/fanotify v0.0.0-20240229202106-bca3154da60a h1:4VFls9SuqkqeioVevnaeTXrYKQ7JiEsxqKHfxp+/ovA= @@ -1206,12 +1570,14 @@ github.com/s3rj1k/go-fanotify/fanotify v0.0.0-20240229202106-bca3154da60a/go.mod github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg= github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/sasha-s/go-deadlock v0.3.5 h1:tNCOEEDG6tBqrNDOX35j/7hL5FcFViG6awUGROb2NsU= github.com/sasha-s/go-deadlock v0.3.5/go.mod h1:bugP6EGbdGYObIlx7pUZtWqlvo8k9H6vCBBsiChJQ5U= github.com/sassoftware/go-rpmutils v0.4.0 h1:ojND82NYBxgwrV+mX1CWsd5QJvvEZTKddtCdFLPWhpg= @@ -1247,6 +1613,7 @@ github.com/shurcooL/home v0.0.0-20181020052607-80b7ffcb30f9/go.mod h1:+rgNQw2P9A github.com/shurcooL/htmlg v0.0.0-20170918183704-d01228ac9e50/go.mod h1:zPn1wHpTIePGnXSHpsVPWEktKXHr6+SS6x/IKRb7cpw= github.com/shurcooL/httperror v0.0.0-20170206035902-86b7830d14cc/go.mod h1:aYMfkZ6DWSJPJ6c4Wwz3QtW22G7mf/PEgaB9k/ik5+Y= github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= +github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= github.com/shurcooL/httpgzip v0.0.0-20180522190206-b1c53ac65af9/go.mod h1:919LwcH0M7/W4fcZ0/jy0qGght1GIhqyS/EgWGH2j5Q= github.com/shurcooL/issues v0.0.0-20181008053335-6292fdc1e191/go.mod h1:e2qWDig5bLteJ4fwvDAc2NHzqFEthkqn7aOZAOpj+PQ= github.com/shurcooL/issuesapp v0.0.0-20180602232740-048589ce2241/go.mod h1:NPpHK2TI7iSaM0buivtFUc9offApnI0Alt/K8hcHy0I= @@ -1255,7 +1622,9 @@ github.com/shurcooL/octicon v0.0.0-20181028054416-fa4f57f9efb2/go.mod h1:eWdoE5J github.com/shurcooL/reactions v0.0.0-20181006231557-f2e0b4ca5b82/go.mod h1:TCR1lToEk4d2s07G3XGfz2QrgHXg4RJBvjrOozvoWfk= github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4= +github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw= +github.com/sigstore/fulcio v1.6.6/go.mod h1:BhQ22lwaebDgIxVBEYOOqLRcN5+xOV+C9bh/GUXRhOk= github.com/sigstore/protobuf-specs v0.5.0 h1:F8YTI65xOHw70NrvPwJ5PhAzsvTnuJMGLkA4FIkofAY= github.com/sigstore/protobuf-specs v0.5.0/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc= github.com/sigstore/sigstore v1.10.4 h1:ytOmxMgLdcUed3w1SbbZOgcxqwMG61lh1TmZLN+WeZE= @@ -1267,6 +1636,8 @@ 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/smallstep/pkcs7 v0.1.1/go.mod h1:dL6j5AIz9GHjVEBTXtW+QliALcgM19RtXaTeyxI+AfA= +github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik= github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU= github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= @@ -1274,6 +1645,7 @@ github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrel github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spdx/gordf v0.0.0-20201111095634-7098f93598fb/go.mod h1:uKWaldnbMnjsSAXRurWqqrdyZen1R7kxl8TkmWk2OyM= github.com/spdx/gordf v0.0.0-20250128162952-000978ccd6fb h1:7G2Czq97VORM5xNRrD8tSQdhoXPRs8s+Otlc7st9TS0= github.com/spdx/gordf v0.0.0-20250128162952-000978ccd6fb/go.mod h1:uKWaldnbMnjsSAXRurWqqrdyZen1R7kxl8TkmWk2OyM= @@ -1299,6 +1671,9 @@ github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E= github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= +github.com/spiffe/spire-api-sdk v1.11.1/go.mod h1:4uuhFlN6KBWjACRP3xXwrOTNnvaLp1zJs8Lribtr4fI= +github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6/go.mod h1:39R/xuhNgVhi+K0/zst4TLrJrVmbm6LVgl4A0+ZFS5M= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -1328,11 +1703,24 @@ 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/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= +github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d/go.mod h1:RRCYJbIwD5jmqPI9XoAFR0OcDxqUctll6zUj/+B4S48= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= +github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= github.com/terminalstatic/go-xsd-validate v0.1.6 h1:TenYeQ3eY631qNi1/cTmLH/s2slHPRKTTHT+XSHkepo= github.com/terminalstatic/go-xsd-validate v0.1.6/go.mod h1:18lsvYFofBflqCrvo1umpABZ99+GneNTw2kEEc8UPJw= +github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw= github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY= +github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tink-crypto/tink-go/v2 v2.5.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= @@ -1343,10 +1731,13 @@ github.com/uptrace/opentelemetry-go-extra/otelzap v0.4.0 h1:nPz2yF9qIJyuYDWhD7+i github.com/uptrace/opentelemetry-go-extra/otelzap v0.4.0/go.mod h1:ubWgbUJc+h0o4gvsov82+s98XLu6rVQ//tQ47nINT2A= github.com/uptrace/uptrace-go v1.47.0 h1:FQgpAx/DdfDEaJ9xX03RaAc7BXVraBz0qC15oynFDpI= github.com/uptrace/uptrace-go v1.47.0/go.mod h1:xVJgZjczsSa94/pdg5HrtWTY/HyfCXxL7f+MienUh7Y= +github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po= +github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= github.com/vbatts/go-mtree v0.5.4 h1:OMAb8jaCyiFA7zXj0Zc/oARcxBDBoeu2LizjB8BVJl0= github.com/vbatts/go-mtree v0.5.4/go.mod h1:5GqJbVhm9BBiCc4K5uc/c42FPgXulHaQs4sFUEfIWMo= github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= +github.com/vbauerster/mpb/v8 v8.10.2/go.mod h1:+Ja4P92E3/CorSZgfDtK46D7AVbDqmBQRTmyTqPElo0= github.com/veraison/go-cose v1.3.0 h1:2/H5w8kdSpQJyVtIhx8gmwPJ2uSz1PkyWFx0idbd7rk= github.com/veraison/go-cose v1.3.0/go.mod h1:df09OV91aHoQWLmy1KsDdYiagtXgyAwAl8vFeFn1gMc= github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= @@ -1357,6 +1748,8 @@ github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 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-20260303201901-10176f79b2c0 h1:EHsPe0Q0ANoLOZff1dBLAyeWLTA4sbPTpGI+2zb0FnM= @@ -1367,22 +1760,35 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= +github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU= github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= +github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= +github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg= +github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= +github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -1393,11 +1799,24 @@ github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= +github.com/zyedidia/generic v1.2.2-0.20230320175451-4410d2372cb1/go.mod h1:ly2RBz4mnz1yeuVbQA/VFwGjK3mnHGRj1JuoG336Bis= +go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= +go.etcd.io/etcd/api/v3 v3.6.5 h1:pMMc42276sgR1j1raO/Qv3QI9Af/AuyQUW6CBAWuntA= +go.etcd.io/etcd/api/v3 v3.6.5/go.mod h1:ob0/oWA/UQQlT1BmaEkWQzI0sJ1M0Et0mMpaABxguOQ= go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= +go.etcd.io/etcd/client/pkg/v3 v3.6.5 h1:Duz9fAzIZFhYWgRjp/FgNq2gO1jId9Yae/rLn3RrBP8= +go.etcd.io/etcd/client/pkg/v3 v3.6.5/go.mod h1:8Wx3eGRPiy0qOFMZT/hfvdos+DjEaPxdIDiCDUv/FQk= go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs= +go.etcd.io/etcd/client/v3 v3.6.5 h1:yRwZNFBx/35VKHTcLDeO7XVLbCBFbPi+XV4OC3QJf2U= +go.etcd.io/etcd/client/v3 v3.6.5/go.mod h1:ZqwG/7TAFZ0BJ0jXRPoJjKQJtbFo/9NIY8uoFFKcCyo= +go.etcd.io/etcd/pkg/v3 v3.6.5/go.mod h1:uqrXrzmMIJDEy5j00bCqhVLzR5jEJIwDp5wTlLwPGOU= +go.etcd.io/etcd/server/v3 v3.6.5/go.mod h1:PLuhyVXz8WWRhzXDsl3A3zv/+aK9e4A9lpQkqawIaH0= +go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo= go.mongodb.org/mongo-driver v1.17.7 h1:a9w+U3Vt67eYzcfq3k/OAv284/uUUkL0uP75VE5rCOU= go.mongodb.org/mongo-driver v1.17.7/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= @@ -1414,6 +1833,7 @@ go.opentelemetry.io/contrib/bridges/otelslog v0.21.0 h1:/CdgE52i1OEPHhsGUqrggYNR go.opentelemetry.io/contrib/bridges/otelslog v0.21.0/go.mod h1:9pBdbVbuehIDzxN4accnzzxEUPT8Nq7grbZMo+J1VN4= go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw= go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s= +go.opentelemetry.io/contrib/instrumentation/github.com/aws/aws-sdk-go-v2/otelaws v0.67.0/go.mod h1:xOd0/OgHjAtW47zPn48sC7n/pUxunDQfDc9qG3ZtSn0= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0 h1:Tq+E65HaNbZTeqcLw6QUhsV+/EIWdkfUH2CQzcrlE5M= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0/go.mod h1:A+EVhDakAj1waJBPhmTszZaSr08ww8MRRKy1nB1rqck= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 h1:LxwW/9ctSCv+QkE/cLR7M91ZIkXNMqJtEMi1vCw9U8s= @@ -1428,6 +1848,7 @@ go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.23.0 h1:5Ek4qQ5XX go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.23.0/go.mod h1:NUKIMOKSexQdTmS1pSxOQBDiVes3DHRier2ePsGW/gQ= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.23.0 h1:5WVp40k2fJHuc5BtNTgKk+EugKFT8Eg3lywFfCPI/nE= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.23.0/go.mod h1:59nH0RYCGHRlwt15WyioeSYINBbuObeBpTowPhEKWHY= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric v0.41.0/go.mod h1:hG4Fj/y8TR/tlEDREo8tWstl9fO9gcFkn4xrx0Io8xU= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.47.0 h1:LaLVGJtIzHcvHjVs2OftmKcc+M9mdOqS8vZ1fBD1Yes= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.47.0/go.mod h1:vF6H5sNDuQBJWgSX00iZgbIqk2MZZoVMH0ar5t5bOpw= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.47.0 h1:7kszRN4Ze5GeAkUnBjBSUIunVBMA+mZIG04auueqguM= @@ -1466,8 +1887,12 @@ go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqe go.opentelemetry.io/proto/otlp v1.11.1 h1:SCrPqH9NE5CmBrOqpjNCiT0X5mGwMyGERXdogfR7Yjw= go.opentelemetry.io/proto/otlp v1.11.1/go.mod h1:wq8W4aSf6bOy8RZyESOeQFr9Pu7wydWX/TY4sgvrTPg= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/dig v1.17.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= @@ -1518,6 +1943,8 @@ golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EH golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= +golang.org/x/exp/shiny v0.0.0-20230801115018-d63ba01acd4b/go.mod h1:UH99kUObWAZkDnWqppdQe5ZhPYESUw8I0zVV1uWBR+0= +golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= @@ -1759,6 +2186,7 @@ golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518/go.mod h1:i+ivNqjDnTF3WTElsdk5g9V5DTSBYgdNo7xTU9SDwYA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -1854,12 +2282,17 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6/go.mod h1:3rxYc4HtVcSG9gVaTs2GEBdehh+sYPOwKtyUWEOTb80= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/plot v0.14.0 h1:+LBDVFYwFe4LHhdP8coW6296MBEY4nQ+Y4vuUpJopcE= @@ -1910,6 +2343,7 @@ google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= @@ -1981,6 +2415,7 @@ google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgn google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8 h1:cFbkNigDInw+wVTXc+CyhUsoHGJCw0BR6mlNoRVfre0= google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8/go.mod h1:QK6p7IgD7IZEZJBhhSUCib5X2vMOzlrGCGUw2XyVDh0= +google.golang.org/genproto/googleapis/bytestream v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:6TABGosqSqU2l1+fJ3jdvOYPPVryeKybxYF0cCZkTBE= google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 h1:0SXBqli5dnuCKMjI/X3FM226zEaEa0GUcSCJtN1vL2o= google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= @@ -2016,6 +2451,8 @@ google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ5 google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1/go.mod h1:5KF+wpkbTSbGcR9zteSqZV6fqFOWBl4Yde8En8MryZA= +google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6ytKWczdvnpnO+m+JiG9NjEDzR1FJfsnmJdG7B8QVZ8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -2038,14 +2475,20 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/cheggaaa/pb.v1 v1.0.27/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/mcuadros/go-syslog.v2 v2.3.0 h1:kcsiS+WsTKyIEPABJBJtoG0KkOS6yzvJ+/eZlhD79kk= gopkg.in/mcuadros/go-syslog.v2 v2.3.0/go.mod h1:l5LPIyOOyIdQquNg+oU6Z3524YwrcqEm0aKH+5zpt2U= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/telebot.v3 v3.2.1/go.mod h1:GJKwwWqp9nSkIVN51eRKU78aB5f5OnQuWdwiIZfPbko= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -2072,6 +2515,7 @@ honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= +honnef.co/go/tools v0.8.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks= istio.io/pkg v0.0.0-20231221211216-7635388a563e h1:ZlLVbKDlCzfP0MPbWc6VRcY23d9NdjLxwpPQpDrh3Gc= istio.io/pkg v0.0.0-20231221211216-7635388a563e/go.mod h1:fvmqEdHhZjYYwf6dSiIwvwc7db54kMWVTfsb91KmhzY= k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= @@ -2086,20 +2530,27 @@ k8s.io/cli-runtime v0.35.0 h1:PEJtYS/Zr4p20PfZSLCbY6YvaoLrfByd6THQzPworUE= k8s.io/cli-runtime v0.35.0/go.mod h1:VBRvHzosVAoVdP3XwUQn1Oqkvaa8facnokNkD7jOTMY= k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= +k8s.io/code-generator v0.35.0/go.mod h1:iS1gvVf3c/T71N5DOGYO+Gt3PdJ6B9LYSvIyQ4FHzgc= k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94= k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0= +k8s.io/component-helpers v0.34.1/go.mod h1:4VgnUH7UA/shuBur+OWoQC0xfb69sy/93ss0ybZqm3c= k8s.io/cri-api v0.35.0 h1:fxLSKyJHqbyCSUsg1rW4DRpmjSEM/elZ1GXzYTSLoDQ= k8s.io/cri-api v0.35.0/go.mod h1:Cnt29u/tYl1Se1cBRL30uSZ/oJ5TaIp4sZm1xDLvcMc= +k8s.io/endpointslice v0.32.13/go.mod h1:HRXxLyYXerC2cJCATXberX+bMYjonh6hMaq9HbLkAGg= +k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b/go.mod h1:CgujABENc3KuTrcsdpGmrrASjtQsWCT7R99mEV4U/fM= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kms v0.35.0/go.mod h1:VT+4ekZAdrZDMgShK37vvlyHUVhwI9t/9tvh0AyCWmQ= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/kubectl v0.34.1 h1:1qP1oqT5Xc93K+H8J7ecpBjaz511gan89KO9Vbsh/OI= k8s.io/kubectl v0.34.1/go.mod h1:JRYlhJpGPyk3dEmJ+BuBiOB9/dAvnrALJEiY/C5qa6A= k8s.io/kubelet v0.35.0 h1:8cgJHCBCKLYuuQ7/Pxb/qWbJfX1LXIw7790ce9xHq7c= k8s.io/kubelet v0.35.0/go.mod h1:ciRzAXn7C4z5iB7FhG1L2CGPPXLTVCABDlbXt/Zz8YA= +k8s.io/metrics v0.34.1/go.mod h1:Drf5kPfk2NJrlpcNdSiAAHn/7Y9KqxpRNagByM7Ei80= k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc= k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA= 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= @@ -2130,21 +2581,33 @@ modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo= oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/goversion v1.2.0/go.mod h1:Eih9y/uIBS3ulggl7KNJ09xGSLcuNaLgmvvqa07sgfo= rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8= sigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM= +sigs.k8s.io/controller-tools v0.16.5/go.mod h1:8vztuRVzs8IuuJqKqbXCSlXcw+lkAv/M2sTpg55qjMY= +sigs.k8s.io/gateway-api v1.2.1/go.mod h1:EpNfEXNjiYfUJypf0eZ0P5iXA9ekSGWaS1WgPaM42X0= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I= sigs.k8s.io/kustomize/api v0.20.1/go.mod h1:t6hUFxO+Ph0VxIk1sKp1WS0dOjbPCtLJ4p8aADLwqjM= +sigs.k8s.io/kustomize/kustomize/v5 v5.7.1/go.mod h1:+5/SrBcJ4agx1SJknGuR/c9thwRSKLxnKoI5BzXFaLU= sigs.k8s.io/kustomize/kyaml v0.20.1 h1:PCMnA2mrVbRP3NIB6v9kYCAc38uvFLVs8j/CD567A78= sigs.k8s.io/kustomize/kyaml v0.20.1/go.mod h1:0EmkQHRUsJxY8Ug9Niig1pUMSCGHxQ5RklbpV/Ri6po= +sigs.k8s.io/mcs-api v0.1.1-0.20250116162235-62ede9a032dc/go.mod h1:Uicqc5FnWP4dco2y7+AEg2mzNN20mVX1TDB3aDfmvhc= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/release-utils v0.11.1/go.mod h1:ybR2V/uQAOGxYfzYtBenSYeXWkBGNP2qnEiX77ACtpc= +sigs.k8s.io/security-profiles-operator v0.9.1/go.mod h1:p2ErLS2eMxazGk3VU17CvZxXth95m7tsTk/bOUexS4k= +sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= @@ -2152,5 +2615,7 @@ sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck= sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= +tags.cncf.io/container-device-interface v1.0.1/go.mod h1:JojJIOeW3hNbcnOH2q0NrWNha/JuHoDZcmYxAZwb2i0= +tags.cncf.io/container-device-interface/specs-go v0.8.0/go.mod h1:BhJIkjjPh4qpys+qm4DAYtUyryaTDg9zris+AczXyws= zombiezen.com/go/sqlite v1.4.0 h1:N1s3RIljwtp4541Y8rM880qgGIgq3fTD2yks1xftnKU= zombiezen.com/go/sqlite v1.4.0/go.mod h1:0w9F1DN9IZj9AcLS9YDKMboubCACkwYCGkzoy3eG5ik= diff --git a/pkg/config/config.go b/pkg/config/config.go index ebaf399804..62e9abf8c9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "slices" "strings" "sync/atomic" @@ -119,6 +120,11 @@ type Config struct { EnableNodeProfile bool `mapstructure:"nodeProfileServiceEnabled"` EnablePartialProfileGeneration bool `mapstructure:"partialProfileGenerationEnabled"` EnableMetricsExporter bool `mapstructure:"prometheusExporterEnabled"` + EnableSandboxPolicy bool `mapstructure:"sandboxPolicyEnabled"` + SandboxPolicyImage string `mapstructure:"sandboxPolicyImage"` + SandboxPolicyContainerdSocket string `mapstructure:"sandboxPolicyContainerdSocket"` + SandboxPolicyControlRoot string `mapstructure:"sandboxPolicyControlRoot"` + SandboxPolicyMaxOwners uint64 `mapstructure:"sandboxPolicyMaxOwners"` EnableRuntimeDetection bool `mapstructure:"runtimeDetectionEnabled"` EnableSbomFailureReporting bool `mapstructure:"sbomFailureReportingEnabled"` EnableSbomGeneration bool `mapstructure:"sbomGenerationEnabled"` @@ -218,6 +224,9 @@ func LoadConfigOptional(path string, errNotFound bool) (Config, error) { viper.SetConfigType("json") viper.SetOptions(viper.KeyDelimiter("::")) + viper.SetDefault("sandboxPolicyEnabled", false) + viper.SetDefault("sandboxPolicyImage", "") + viper.SetDefault("sandboxPolicyMaxOwners", 4) viper.SetDefault("fullPathTracingEnabled", true) viper.SetDefault("initialDelay", 2*time.Minute) viper.SetDefault("nodeProfileInterval", 10*time.Minute) @@ -336,6 +345,23 @@ func LoadConfigOptional(path string, errNotFound bool) (Config, error) { return Config{}, err } + if config.EnableSandboxPolicy { + if !config.KubernetesMode { + return Config{}, fmt.Errorf("sandboxPolicyEnabled requires kubernetesMode") + } + if config.SandboxPolicyImage == "" || strings.TrimSpace(config.SandboxPolicyImage) != config.SandboxPolicyImage { + return Config{}, fmt.Errorf("sandboxPolicyEnabled requires an explicit sandboxPolicyImage") + } + for name, value := range map[string]string{"sandboxPolicyContainerdSocket": config.SandboxPolicyContainerdSocket, "sandboxPolicyControlRoot": config.SandboxPolicyControlRoot} { + if !filepath.IsAbs(value) || filepath.Clean(value) != value || value == "/" { + return Config{}, fmt.Errorf("sandboxPolicyEnabled requires an explicit canonical non-root %s", name) + } + } + if config.SandboxPolicyMaxOwners == 0 || config.SandboxPolicyMaxOwners > 1024 { + return Config{}, fmt.Errorf("sandboxPolicyMaxOwners must be between 1 and 1024") + } + } + // Validate seccompProfileBackend value if config.SeccompProfileBackend != "" && config.SeccompProfileBackend != SeccompBackendStorage && diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index ab840886e5..5ceea26a12 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -43,6 +43,7 @@ func TestLoadConfig(t *testing.T) { KubernetesMode: true, NetworkStreamingInterval: 2 * time.Minute, InitialDelay: 2 * time.Minute, + SandboxPolicyMaxOwners: 4, HostSBOMRescanInterval: 24 * time.Hour, HostSbomOffloadEnabled: true, MaxSniffingTime: 24 * time.Hour, @@ -1008,3 +1009,37 @@ func TestNormalizeDNSSuffix(t *testing.T) { assert.Equal(t, want, NormalizeDNSSuffix(in), "input %q", in) } } + +func TestSandboxPolicyOptIn(t *testing.T) { + cases := []struct { + name, json string + wantEnabled, wantErr bool + }{ + {"default", `{}`, false, false}, + {"explicit", `{"sandboxPolicyEnabled":true,"sandboxPolicyImage":"enforce_exec:test","sandboxPolicyContainerdSocket":"/run/containerd/containerd.sock","sandboxPolicyControlRoot":"/sys/fs/cgroup","runtimeDetectionEnabled":false,"applicationProfileServiceEnabled":false}`, true, false}, + {"missing-runtime", `{"sandboxPolicyEnabled":true,"sandboxPolicyImage":"enforce_exec:test"}`, false, true}, + {"invalid-owner-budget", `{"sandboxPolicyEnabled":true,"sandboxPolicyImage":"enforce_exec:test","sandboxPolicyContainerdSocket":"/run/containerd/containerd.sock","sandboxPolicyControlRoot":"/sys/fs/cgroup","sandboxPolicyMaxOwners":0}`, false, true}, + {"missing-image", `{"sandboxPolicyEnabled":true}`, false, true}, + {"whitespace-image", `{"sandboxPolicyEnabled":true,"sandboxPolicyImage":" "}`, false, true}, + {"host-mode", `{"sandboxPolicyEnabled":true,"sandboxPolicyImage":"enforce_exec:test","kubernetesMode":false}`, false, true}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + dir := t.TempDir() + require.NoError(t, os.WriteFile(dir+"/config.json", []byte(tt.json), 0600)) + cfg, err := LoadConfigOptional(dir, false) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantEnabled, cfg.EnableSandboxPolicy) + if tt.wantEnabled { + assert.False(t, cfg.EnableRuntimeDetection) + assert.False(t, cfg.EnableApplicationProfile) + } + }) + } +} diff --git a/pkg/sandboxpolicy/compiler.go b/pkg/sandboxpolicy/compiler.go new file mode 100644 index 0000000000..cff260b1a2 --- /dev/null +++ b/pkg/sandboxpolicy/compiler.go @@ -0,0 +1,186 @@ +package sandboxpolicy + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + + api "github.com/kubescape/storage/pkg/apis/sandbox/v1alpha1" +) + +// Limits are deliberately bounded before handing work to the kernel backend, +// which may impose stricter supported-environment limits. +const MaxPoliciesPerBinding = 64 +const MaxRulesPerPolicy = 256 +const MaxPathsPerRule = 256 + +// Compile resolves a single policy without mutating its source objects. A missing +// class/profile is an error, never a wildcard or a silently omitted rule. +func Compile(ctx context.Context, identity PolicyIdentity, spec api.PolicySpec, classes api.PolicyClasses, binding Binding, resolver SourceResolver) (CompiledPolicy, error) { + var result CompiledPolicy + if err := validateShape(spec); err != nil { + return result, err + } + if err := api.ValidatePolicySpec(spec); err != nil { + return result, err + } + if spec.Mode != api.ModeAudit && spec.Mode != api.ModeEnforce { + return result, fmt.Errorf("policy is not active: %q", spec.Mode) + } + if spec.File != nil || spec.Network != nil || spec.Privilege != nil || spec.Identity != nil { + return result, fmt.Errorf("non-exec sections are unsupported") + } + // JSON cloning preserves all pointers and slice fields of the API vocabulary. + data, err := json.Marshal(spec) + if err != nil { + return result, err + } + var copy api.PolicySpec + if err = json.Unmarshal(data, ©); err != nil { + return result, err + } + result = CompiledPolicy{Identity: identity, Mode: copy.Mode, RootRole: copy.RootRole} + if result.RootRole == "" { + result.RootRole = "agent" + } + if copy.Exec != nil { + result.Exec = *copy.Exec + } + names := make(map[string]bool) + for _, r := range result.Exec.Rules { + names[r.Name] = true + } + for i, source := range copy.Sources { + if len(source.Include) == 0 { + return result, fmt.Errorf("source %q has no include sections", source.ContainerProfile.Name) + } + for _, section := range source.Include { + if section != "execs" { + return result, fmt.Errorf("source section %q is unsupported", section) + } + } + if resolver == nil { + return result, fmt.Errorf("ContainerProfile resolver unavailable") + } + rules, err := resolver.ResolveExecs(ctx, binding.Namespace, source.ContainerProfile.Name, binding.ContainerName) + if err != nil { + return result, fmt.Errorf("ContainerProfile %s: %w", source.ContainerProfile.Name, err) + } + for j, rule := range rules { + rule.Name = fmt.Sprintf("source-%d-exec-%d", i, j) + if names[rule.Name] { + return result, fmt.Errorf("source rule name collision: %s", rule.Name) + } + names[rule.Name] = true + if rule.Origin == "" { + rule.Origin = "ContainerProfile/" + binding.Namespace + "/" + source.ContainerProfile.Name + } + // Copy resolver-owned pointers before expanding path classes below. + data, err := json.Marshal(rule) + if err != nil { + return result, err + } + var cloned api.ExecRule + if err := json.Unmarshal(data, &cloned); err != nil { + return result, err + } + result.Exec.Rules = append(result.Exec.Rules, cloned) + } + } + if len(result.Exec.Rules) > MaxRulesPerPolicy { + return result, fmt.Errorf("policy exceeds %d exec rules", MaxRulesPerPolicy) + } + for i := range result.Exec.Rules { + r := &result.Exec.Rules[i] + if r.Action != api.ActionAllow && r.Action != api.ActionDeny { + return result, fmt.Errorf("rule %q has invalid action", r.Name) + } + if r.Mode != "" && r.Mode != api.RuleAudit && r.Mode != api.RuleEnforce { + return result, fmt.Errorf("rule %q has invalid mode", r.Name) + } + for _, name := range r.Target.PathClass { + paths, found := classes.PathClasses[name] + if copy.Classes != nil { + if local, ok := copy.Classes.PathClasses[name]; ok { + paths, found = append(append([]string(nil), paths...), local...), true + } + } + if !found || len(paths) == 0 { + return result, fmt.Errorf("unresolved path class %q", name) + } + r.Target.Path = append(r.Target.Path, paths...) + } + r.Target.Path = sortedUnique(r.Target.Path) + r.Target.PathClass = nil + if len(r.Target.Path) > MaxPathsPerRule { + return result, fmt.Errorf("rule %q exceeds %d paths", r.Name, MaxPathsPerRule) + } + } + // Validate imported rules as well as authored rules after class expansion. + validated := api.PolicySpec{Containers: copy.Containers, Mode: copy.Mode, RootRole: result.RootRole, Exec: &result.Exec} + if err := api.ValidatePolicySpec(validated); err != nil { + return result, err + } + result.Revision = digest(result) + return result, nil +} + +func sortedUnique(values []string) []string { + if len(values) == 0 { + return nil + } + sort.Strings(values) + result := values[:0] + for _, value := range values { + if len(result) == 0 || result[len(result)-1] != value { + result = append(result, value) + } + } + return result +} + +func digest(value any) string { + data, _ := json.Marshal(value) // callers use only concrete JSON-serializable API structs + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +// ValidatePolicySpec supplies semantic checks, not full CRD admission. These +// structural checks also protect direct/fake clients and prevent malformed +// container selectors from authorizing withdrawal of a previous good revision. +func validateShape(spec api.PolicySpec) error { + if len(spec.Containers) == 0 { + return fmt.Errorf("containers must not be empty") + } + for _, name := range spec.Containers { + if name == "" { + return fmt.Errorf("container name must not be empty") + } + } + if spec.Mode != api.ModeOff && spec.Mode != api.ModeAudit && spec.Mode != api.ModeEnforce { + return fmt.Errorf("invalid policy mode %q", spec.Mode) + } + if spec.Mode == api.ModeOff { + return nil + } + if spec.File != nil || spec.Network != nil || spec.Privilege != nil || spec.Identity != nil { + return fmt.Errorf("non-exec sections are unsupported") + } + if spec.Exec != nil { + if spec.Exec.Default != api.ActionAllow && spec.Exec.Default != api.ActionDeny { + return fmt.Errorf("exec requires an allow/deny default") + } + for _, rule := range spec.Exec.Rules { + if rule.Action != api.ActionAllow && rule.Action != api.ActionDeny { + return fmt.Errorf("rule %q has invalid action", rule.Name) + } + if rule.Mode != "" && rule.Mode != api.RuleAudit && rule.Mode != api.RuleEnforce { + return fmt.Errorf("rule %q has invalid mode", rule.Name) + } + } + } + return nil +} diff --git a/pkg/sandboxpolicy/compiler_test.go b/pkg/sandboxpolicy/compiler_test.go new file mode 100644 index 0000000000..9d387a567a --- /dev/null +++ b/pkg/sandboxpolicy/compiler_test.go @@ -0,0 +1,106 @@ +package sandboxpolicy + +import ( + "context" + "fmt" + "reflect" + "testing" + + api "github.com/kubescape/storage/pkg/apis/sandbox/v1alpha1" +) + +type resolverFunc func(context.Context, string, string, string) ([]api.ExecRule, error) + +func (f resolverFunc) ResolveExecs(ctx context.Context, namespace, name, container string) ([]api.ExecRule, error) { + return f(ctx, namespace, name, container) +} + +func execSpec() api.PolicySpec { + return api.PolicySpec{Containers: []string{"*"}, Mode: api.ModeEnforce, Exec: &api.ExecSection{Default: api.ActionDeny, Rules: []api.ExecRule{{Rule: api.Rule{Name: "shell", Action: api.ActionAllow}, Target: api.ExecTarget{PathTarget: api.PathTarget{Path: []string{"/bin/sh"}}}}}}} +} + +func TestCompilePreservesVocabularyAndExpandsClasses(t *testing.T) { + spec := execSpec() + yes, no := true, false + rule := &spec.Exec.Rules[0] + rule.Actor = &api.Actor{Path: []string{"/bin/parent"}, FromImage: &yes, Ancestor: &api.Ancestor{Path: []string{"/bin/root"}, Depth: 3}, Role: []string{"builder"}, UID: []int64{1000}, Container: []string{"app"}} + rule.Mode = api.RuleAudit + rule.Origin = "authored" + rule.Target = api.ExecTarget{PathTarget: api.PathTarget{Path: []string{"/bin/direct"}, PathClass: []string{"tools"}, FromImage: &no, FSType: []string{"overlay"}}, Argv: &api.ArgvPredicate{Contains: []string{"--test"}, Prefix: []string{"sh", "-c"}, StdinIsPipe: &yes}, SetRole: "child"} + spec.RootRole = "root" + spec.Classes = &api.PolicyClasses{PathClasses: map[string][]string{"tools": {"/bin/local", "/bin/direct"}}} + before := digest(spec) + got, err := Compile(context.Background(), PolicyIdentity{UID: "p"}, spec, api.PolicyClasses{PathClasses: map[string][]string{"tools": {"/bin/global"}}}, Binding{}, nil) + if err != nil { + t.Fatal(err) + } + if digest(spec) != before { + t.Fatal("compiler mutated input") + } + actual := got.Exec.Rules[0] + if !reflect.DeepEqual(actual.Actor, rule.Actor) || !reflect.DeepEqual(actual.Target.Argv, rule.Target.Argv) || actual.Target.SetRole != "child" || actual.Mode != api.RuleAudit || actual.Origin != "authored" || got.RootRole != "root" { + t.Fatalf("lost predicates: %#v", got) + } + if !reflect.DeepEqual(actual.Target.Path, []string{"/bin/direct", "/bin/global", "/bin/local"}) || len(actual.Target.PathClass) != 0 || actual.Target.FromImage == nil || *actual.Target.FromImage || actual.Target.FSType[0] != "overlay" { + t.Fatalf("bad expansion: %#v", actual.Target) + } +} + +func TestCompileRejectsUnsupportedMissingAndLimits(t *testing.T) { + cases := map[string]func(*api.PolicySpec){ + "nonexec": func(s *api.PolicySpec) { s.File = &api.FileSection{} }, + "class": func(s *api.PolicySpec) { s.Exec.Rules[0].Target.PathClass = []string{"missing"} }, + "source section": func(s *api.PolicySpec) { + s.Sources = []api.PolicySource{{ContainerProfile: api.ContainerProfileReference{Name: "p"}, Include: []string{"opens"}}} + }, + "source unavailable": func(s *api.PolicySpec) { + s.Sources = []api.PolicySource{{ContainerProfile: api.ContainerProfileReference{Name: "p"}, Include: []string{"execs"}}} + }, + "paths": func(s *api.PolicySpec) { + for i := 0; i <= MaxPathsPerRule; i++ { + s.Exec.Rules[0].Target.Path = append(s.Exec.Rules[0].Target.Path, fmt.Sprintf("/bin/%d", i)) + } + }, + } + for name, change := range cases { + t.Run(name, func(t *testing.T) { + s := execSpec() + change(&s) + if _, err := Compile(context.Background(), PolicyIdentity{}, s, api.PolicyClasses{}, Binding{}, nil); err == nil { + t.Fatal("accepted unsupported revision") + } + }) + } + s := execSpec() + s.Exec.Rules[0].Target.Path = nil + for i := 0; i < MaxPathsPerRule; i++ { + s.Exec.Rules[0].Target.Path = append(s.Exec.Rules[0].Target.Path, fmt.Sprintf("/bin/%d", i)) + } + result, err := Compile(context.Background(), PolicyIdentity{}, s, api.PolicyClasses{}, Binding{}, nil) + if err != nil || len(result.Exec.Rules[0].Target.Path) != MaxPathsPerRule { + t.Fatalf("boundary: %v", err) + } +} + +func TestCompileImportsReadOnly(t *testing.T) { + s := execSpec() + s.Sources = []api.PolicySource{{ContainerProfile: api.ContainerProfileReference{Name: "profile"}, Include: []string{"execs"}}} + imported := []api.ExecRule{{Rule: api.Rule{Name: "original", Action: api.ActionAllow}, Target: api.ExecTarget{PathTarget: api.PathTarget{Path: []string{"/z", "/a"}}}}} + before := digest(imported) + resolver := resolverFunc(func(_ context.Context, namespace, name, container string) ([]api.ExecRule, error) { + if namespace != "ns" || name != "profile" || container != "app" { + t.Fatal("wrong source scope") + } + return imported, nil + }) + got, err := Compile(context.Background(), PolicyIdentity{}, s, api.PolicyClasses{}, Binding{Namespace: "ns", ContainerName: "app"}, resolver) + if err != nil { + t.Fatal(err) + } + if digest(imported) != before { + t.Fatal("mutated resolver objects") + } + if got.Exec.Rules[1].Name != "source-0-exec-0" || got.Exec.Rules[1].Origin != "ContainerProfile/ns/profile" { + t.Fatalf("missing provenance: %+v", got.Exec.Rules[1]) + } +} diff --git a/pkg/sandboxpolicy/controller.go b/pkg/sandboxpolicy/controller.go new file mode 100644 index 0000000000..e9736a7508 --- /dev/null +++ b/pkg/sandboxpolicy/controller.go @@ -0,0 +1,756 @@ +package sandboxpolicy + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "sync" + + "github.com/kubescape/node-agent/pkg/watcher" + api "github.com/kubescape/storage/pkg/apis/sandbox/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" +) + +var PolicyGVR = api.SchemeGroupVersion.WithResource("sandboxpolicies") +var ClusterPolicyGVR = api.SchemeGroupVersion.WithResource("clustersandboxpolicies") +var ClassesGVR = api.SchemeGroupVersion.WithResource("sandboxpolicyclasses") +var NodeStatusGVR = api.SchemeGroupVersion.WithResource("sandboxpolicynodestatuses") + +// Options identifies this node and the namespace for cluster-policy reports. +type Options struct { + Node api.NodeReference + AgentNamespace string +} + +type installed struct { + binding Binding + plan Plan + cleanupPending bool + protectionUnconfirmed bool +} + +type Controller struct { + mu sync.Mutex + client dynamic.Interface + backend Backend + resolver SourceResolver + writer StatusWriter + options Options + objects map[string]*unstructured.Unstructured + bindings map[string]Binding + active map[string]installed + withdrawals map[string]installed + lastError error + bindingError error + configError error +} + +var _ watcher.Adaptor = (*Controller)(nil) + +func NewController(client dynamic.Interface, backend Backend, resolver SourceResolver, writer StatusWriter, options Options) (*Controller, error) { + if client == nil || backend == nil { + return nil, fmt.Errorf("dynamic client and backend are required") + } + if options.Node.Name == "" || options.Node.UID == "" || options.AgentNamespace == "" { + return nil, fmt.Errorf("node name/UID and agent namespace are required") + } + return &Controller{client: client, backend: backend, resolver: resolver, writer: writer, options: options, objects: make(map[string]*unstructured.Unstructured), bindings: make(map[string]Binding), active: make(map[string]installed), withdrawals: make(map[string]installed)}, nil +} + +func (*Controller) WatchResources() []watcher.WatchResource { + return []watcher.WatchResource{watcher.NewWatchResource(PolicyGVR, metav1.ListOptions{}), watcher.NewWatchResource(ClusterPolicyGVR, metav1.ListOptions{}), watcher.NewWatchResource(ClassesGVR, metav1.ListOptions{})} +} + +// InitialSync is also the reconnect/relist recovery path. A failed list never +// commits a partial snapshot or withdraws last-good enforcement. +func (c *Controller) InitialSync(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + snapshot, err := c.loadConfiguration(ctx) + if err != nil { + c.configError = err + c.lastError = c.reconcile(ctx) + return c.lastError + } + c.objects = snapshot + c.configError = nil + c.lastError = c.reconcile(ctx) + return c.lastError +} + +func (c *Controller) AddHandler(ctx context.Context, obj runtime.Object) { c.change(ctx, obj, false) } +func (c *Controller) ModifyHandler(ctx context.Context, obj runtime.Object) { + c.change(ctx, obj, false) +} +func (c *Controller) DeleteHandler(ctx context.Context, obj runtime.Object) { c.change(ctx, obj, true) } + +func (c *Controller) change(ctx context.Context, obj runtime.Object, deleted bool) { + c.mu.Lock() + defer c.mu.Unlock() + raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + c.lastError = err + return + } + u := &unstructured.Unstructured{Object: raw} + switch u.GetKind() { + case "SandboxPolicy", "ClusterSandboxPolicy", "SandboxPolicyClasses": + default: + return + } + // While a coherent configuration snapshot is unavailable, event payloads + // cannot restore freshness or authorize any transition. Only InitialSync can. + if c.configError != nil { + c.lastError = c.reconcile(ctx) + return + } + key := objectKey(u) + previous := c.objects[key] + if deleted { + if previous == nil || previous.GetUID() != u.GetUID() { + return + } + delete(c.objects, key) + } else { + if previous != nil && previous.GetUID() == u.GetUID() && previous.GetGeneration() > u.GetGeneration() { + return + } + if previous != nil && previous.GetUID() == u.GetUID() && previous.GetGeneration() == u.GetGeneration() && digest(previous.Object["spec"]) == digest(u.Object["spec"]) { + return + } + c.objects[key] = u.DeepCopy() + } + c.lastError = c.reconcile(ctx) +} + +// SetBindings replaces an authoritative container snapshot. Call it on container, +// pod-label or namespace-label changes; absent entries are explicitly withdrawn. +// Snapshot subscriptions and live cgroup identity verification belong to the caller +// and Backend respectively. The controller never infers a binding from selectors. +func (c *Controller) SetBindings(ctx context.Context, bindings []Binding) error { + c.mu.Lock() + defer c.mu.Unlock() + next, err := validatedBindings(bindings) + if err != nil { + return c.rejectBindings(ctx, err) + } + c.bindingError = nil + c.bindings = next + c.lastError = c.reconcile(ctx) + return c.lastError +} + +// ReportBindingFailure retains the last authoritative snapshot and publishes +// NotReady when discovery or Kubernetes metadata cannot produce a complete one. +// Only a subsequent successful binding snapshot (SetBindings or Refresh) clears it. +func (c *Controller) ReportBindingFailure(ctx context.Context, failure error) error { + if failure == nil { + return fmt.Errorf("binding failure must be non-nil") + } + c.mu.Lock() + defer c.mu.Unlock() + return c.rejectBindings(ctx, failure) +} + +// Reconcile retries failed backends/status writes and refreshes source imports. +// Invoke on referenced profile updates and periodically for status heartbeats. +func (c *Controller) Reconcile(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + c.lastError = c.reconcile(ctx) + return c.lastError +} +func (c *Controller) LastError() error { c.mu.Lock(); defer c.mu.Unlock(); return c.lastError } + +func bindingKey(b Binding) string { + // Match backend ownership, with PodUID fencing Kubernetes object replacement. + // Quote strings so embedded separators cannot alias another identity. + return fmt.Sprintf("%q/%q/%q/%d/%d/%d/%q", b.Runtime, b.ContainerID, b.CgroupPath, b.CgroupID, b.MountNamespace, b.PID, b.PodUID) +} +func objectKey(u *unstructured.Unstructured) string { + return u.GetKind() + "/" + u.GetNamespace() + "/" + u.GetName() +} +func cloneLabels(in map[string]string) map[string]string { + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +type desiredPolicy struct { + identity PolicyIdentity + spec api.PolicySpec + podSelector labels.Selector + namespaceSelector labels.Selector + err error +} + +func decodeSpec(u *unstructured.Unstructured, target any) error { + data, err := json.Marshal(u.Object["spec"]) + if err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + return decoder.Decode(target) +} +func parsePolicy(u *unstructured.Unstructured) desiredPolicy { + p := desiredPolicy{identity: PolicyIdentity{Kind: u.GetKind(), Namespace: u.GetNamespace(), Name: u.GetName(), UID: u.GetUID(), Generation: u.GetGeneration()}} + var pod metav1.LabelSelector + if u.GetKind() == "SandboxPolicy" { + var spec api.SandboxPolicySpec + p.err = decodeSpec(u, &spec) + p.spec = spec.PolicySpec + pod = spec.Selector.PodSelector + if p.err == nil { + p.err = api.ValidateSandboxPolicySpec(spec) + } + } else { + var spec api.ClusterSandboxPolicySpec + p.err = decodeSpec(u, &spec) + p.spec = spec.PolicySpec + pod = spec.Selector.PodSelector + if p.err == nil { + p.err = api.ValidateClusterSandboxPolicySpec(spec) + } + if spec.Selector.NamespaceSelector != nil { + selector, err := metav1.LabelSelectorAsSelector(spec.Selector.NamespaceSelector) + p.namespaceSelector = selector + p.err = errors.Join(p.err, err) + } + } + selector, err := metav1.LabelSelectorAsSelector(&pod) + p.podSelector = selector + p.err = errors.Join(p.err, err) + if err := validateShape(p.spec); err != nil { + p.err = errors.Join(p.err, err) + } + if p.identity.UID == "" { + p.err = errors.Join(p.err, fmt.Errorf("policy UID is missing")) + } + return p +} + +func (p desiredPolicy) matches(b Binding) bool { + if p.identity.Kind == "SandboxPolicy" && p.identity.Namespace != b.Namespace { + return false + } + if p.podSelector == nil || !p.podSelector.Matches(labels.Set(b.PodLabels)) { + return false + } + if p.namespaceSelector != nil && !p.namespaceSelector.Matches(labels.Set(b.NamespaceLabels)) { + return false + } + for _, name := range p.spec.Containers { + if name == b.ContainerName || name == "*" && b.Kind == "regular" { + return true + } + } + return false +} + +func (c *Controller) reconcile(ctx context.Context) error { + if c.configError != nil { + return c.reportConfigFailure(ctx) + } + if c.bindingError != nil { + return c.reportBindingFailure(ctx, errors.Join(c.bindingError, c.withdrawDisabled(ctx))) + } + var failures []error + policies := make([]desiredPolicy, 0) + classes := api.PolicyClasses{PathClasses: make(map[string][]string)} + var classErr error + // Reject an ambiguous multi-object class namespace rather than choose + // one definition nondeterministically. Local policy additions are merged. + classNames := make(map[string]string) + keys := make([]string, 0, len(c.objects)) + for key := range c.objects { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + obj := c.objects[key] + if obj.GetKind() != "SandboxPolicyClasses" { + policies = append(policies, parsePolicy(obj)) + continue + } + var spec api.PolicyClasses + if err := decodeSpec(obj, &spec); err != nil { + classErr = errors.Join(classErr, err) + continue + } + if err := api.ValidatePolicyClasses(spec); err != nil { + classErr = errors.Join(classErr, err) + continue + } + for name, paths := range spec.PathClasses { + if owner, exists := classNames[name]; exists { + classErr = errors.Join(classErr, fmt.Errorf("path class %q occurs in %s and %s", name, owner, obj.GetName())) + continue + } + classNames[name] = obj.GetName() + classes.PathClasses[name] = paths + } + } + policyErrors := make(map[string]error) + for _, p := range policies { + if p.err != nil { + policyErrors[string(p.identity.UID)] = p.err + failures = append(failures, p.err) + } + } + + // Finish committed removals before admitting a new plan for the same identity. + // Tombstones are cleanup ownership only, never evidence of active protection. + for _, old := range c.withdrawals { + if err := c.withdraw(ctx, old); err != nil { + failures = append(failures, err) + for _, p := range old.plan.Policies { + policyErrors[string(p.Identity.UID)] = errors.Join(policyErrors[string(p.Identity.UID)], err) + } + } + } + for key, old := range c.active { + if _, ok := c.bindings[key]; !ok { + if err := c.withdraw(ctx, old); err != nil { + failures = append(failures, err) + for _, p := range old.plan.Policies { + policyErrors[string(p.Identity.UID)] = err + } + } else { + delete(c.active, key) + } + } + } + for key, binding := range c.bindings { + if c.bindingRetiring(key, binding) { + for _, p := range policies { + if p.err == nil && p.spec.Mode != api.ModeOff && p.matches(binding) { + policyErrors[string(p.identity.UID)] = errors.Join(policyErrors[string(p.identity.UID)], &withdrawalCleanupError{fmt.Errorf("previous binding ownership must retire before installing policy")}) + } + } + continue + } + selected := make([]desiredPolicy, 0) + keep := make(map[string]bool) + for _, p := range policies { + // Invalid revisions retain previous enforcement even when malformed selectors + // cannot be evaluated. Only a valid off/delete/nonmatch authorizes withdrawal. + if p.err != nil { + if p.identity.Kind == "SandboxPolicy" && p.identity.Namespace != binding.Namespace { + continue + } + keep[string(p.identity.UID)] = true + selected = append(selected, p) + continue + } + if p.spec.Mode != api.ModeOff && p.matches(binding) { + selected = append(selected, p) + keep[string(p.identity.UID)] = true + } + } + old, hasOld := c.active[key] + if hasOld { + retained := Plan{} + for _, p := range old.plan.Policies { + if keep[string(p.Identity.UID)] { + retained.Policies = append(retained.Policies, p) + } + } + if len(retained.Policies) != len(old.plan.Policies) { + retained.Revision = digest(retained.Policies) + var err error + if len(retained.Policies) == 0 { + err = c.withdraw(ctx, old) + } else { + err = c.replace(ctx, binding, retained) + } + if err != nil { + failures = append(failures, err) + for _, p := range old.plan.Policies { + policyErrors[string(p.Identity.UID)] = err + } + for _, p := range selected { + policyErrors[string(p.identity.UID)] = errors.Join(policyErrors[string(p.identity.UID)], err) + } + continue + } + if len(retained.Policies) == 0 { + delete(c.active, key) + } + } + } + plan := Plan{} + var candidateErr error + if len(selected) > MaxPoliciesPerBinding { + candidateErr = fmt.Errorf("binding exceeds %d policies", MaxPoliciesPerBinding) + } + for _, p := range selected { + err := p.err + if err == nil { + err = classErr + } + if err == nil { + compiled, compileErr := Compile(ctx, p.identity, p.spec, classes, binding, c.resolver) + err = compileErr + if err == nil { + plan.Policies = append(plan.Policies, compiled) + } + } + candidateErr = errors.Join(candidateErr, err) + } + plan.Revision = digest(plan.Policies) + if candidateErr == nil && len(plan.Policies) > 0 { + // Same-plan Replace is an attestation/cleanup retry, not a gadget restart. + // Never infer current protection merely from a prior successful revision. + candidateErr = c.replace(ctx, binding, plan) + } else if candidateErr != nil { + if old, ok := c.active[key]; ok { + candidateErr = errors.Join(candidateErr, c.replace(ctx, old.binding, old.plan)) + } + } + + if candidateErr != nil { + failures = append(failures, candidateErr) + for _, p := range selected { + policyErrors[string(p.identity.UID)] = errors.Join(policyErrors[string(p.identity.UID)], candidateErr) + } + } + } + if c.writer != nil { + for _, p := range policies { + if p.identity.UID == "" { + continue + } + report := c.report(p, policyErrors[string(p.identity.UID)]) + if err := c.writer.WriteStatus(ctx, report); err != nil { + failures = append(failures, fmt.Errorf("write node status %s: %w", report.Name, err)) + } + } + } + return errors.Join(failures...) +} + +// A transient incomplete runtime snapshot is not authorization to remove working +// bindings. Retain the complete previous snapshot and publish the failed barrier. +func (c *Controller) rejectBindings(ctx context.Context, failure error) error { + c.bindingError = failure + c.lastError = c.reconcile(ctx) + return c.lastError +} + +func (c *Controller) reportBindingFailure(ctx context.Context, failure error) error { + var statusErrors []error + for _, obj := range c.objects { + if obj.GetKind() == "SandboxPolicyClasses" { + continue + } + p := parsePolicy(obj) + if c.writer != nil && p.identity.UID != "" { + statusErrors = append(statusErrors, c.writer.WriteStatus(ctx, c.report(p, failure))) + } + } + return errors.Join(append([]error{failure}, statusErrors...)...) +} + +// A changed physical identity must not acquire a cgroup until its previous +// owner (including committed-removal cleanup) has completely retired. +func (c *Controller) bindingRetiring(key string, binding Binding) bool { + for oldKey, old := range c.active { + if oldKey != key && old.binding.CgroupID == binding.CgroupID { + return true + } + } + for _, old := range c.withdrawals { + if old.binding.CgroupID == binding.CgroupID { + return true + } + } + return false +} + +// Snapshot failures permit health observation, never implicit pending publication. +func (c *Controller) attest(ctx context.Context, old installed) error { + var err error + if backend, ok := c.backend.(ProtectionAttester); ok { + err = backend.Attest(ctx, old.binding, old.plan.Revision) + } else { + err = fmt.Errorf("backend does not support read-only protection attestation") + } + old.protectionUnconfirmed = err != nil + c.active[bindingKey(old.binding)] = old + if err != nil { + return &protectionError{err} + } + return nil +} + +func (c *Controller) replace(ctx context.Context, binding Binding, plan Plan) error { + result, err := c.backend.Replace(ctx, binding, plan) + if result.ProtectionUnconfirmed { + err = &protectionError{errors.Join(err, fmt.Errorf("owned enforcement protection cannot be confirmed"))} + } + key := bindingKey(binding) + if result.Committed { + if result.ActiveRevision != plan.Revision { + return errors.Join(err, fmt.Errorf("backend committed an unexpected revision %q", result.ActiveRevision)) + } + c.active[key] = installed{binding: binding, plan: plan, cleanupPending: err != nil && !result.ProtectionUnconfirmed, protectionUnconfirmed: result.ProtectionUnconfirmed} + if err != nil && !result.ProtectionUnconfirmed { + return &cleanupError{err} + } + } else { + if old, ok := c.active[key]; ok { + old.protectionUnconfirmed = result.ProtectionUnconfirmed + c.active[key] = old + } + if err == nil { + return fmt.Errorf("backend returned success without committing revision %s", plan.Revision) + } + } + return err +} + +type protectionError struct{ error } + +func (e *protectionError) Unwrap() error { return e.error } + +type cleanupError struct{ error } + +func (e *cleanupError) Unwrap() error { return e.error } + +// withdraw distinguishes removal from cleanup: committed failures cannot remain +// in active, or status would falsely claim last-good protection still exists. +func (c *Controller) withdraw(ctx context.Context, old installed) error { + key := bindingKey(old.binding) + result, err := c.backend.Withdraw(ctx, old.binding) + if result.Committed { + delete(c.active, key) + if err != nil { + c.withdrawals[key] = old + return &withdrawalCleanupError{err} + } + delete(c.withdrawals, key) + } else { + if result.ProtectionUnconfirmed { + if retained, ok := c.active[key]; ok { + retained.protectionUnconfirmed = true + c.active[key] = retained + } + } + if err == nil { + err = fmt.Errorf("backend returned withdrawal success without committing removal") + } + + } + // A retry failure cannot undo a previously confirmed removal. + if _, pending := c.withdrawals[key]; pending && err != nil { + return &withdrawalCleanupError{err} + } + return err +} + +type withdrawalCleanupError struct{ error } + +func (e *withdrawalCleanupError) Unwrap() error { return e.error } + +// A failed LIST freezes both installation and withdrawal until all three inputs +// can be listed again. Runtime snapshots are still recorded by SetBindings, so a +// successful recovery withdraws disappeared identities and applies the newest +// authoritative selection. This deliberately retains stale/deleted runtime +// bindings during API loss rather than use an incomplete config snapshot to +// authorize state transitions. Backend.Close remains the shutdown cleanup path. +func (c *Controller) reportConfigFailure(ctx context.Context) error { + // No backend call is safe here: Replace may resume a pending publication. + // Retain ownership without claiming protection until a fresh attestation. + for key, old := range c.active { + old.protectionUnconfirmed = true + c.active[key] = old + } + failure := errors.Join(c.configError, c.bindingError) + if c.writer == nil { + return failure + } + var statusErrors []error + for _, obj := range c.objects { + if obj.GetKind() == "SandboxPolicyClasses" { + continue + } + p := parsePolicy(obj) + if p.identity.UID == "" { + continue + } + report := c.report(p, failure) + report.Status.Stale = true + report.Status.Conditions[0].Reason = "ConfigurationUnavailable" + report.Status.Conditions[0].Message = "Configuration snapshot unavailable; existing ownership retained but protection unconfirmed; new or changed bindings not installed" + if err := c.writer.WriteStatus(ctx, report); err != nil { + statusErrors = append(statusErrors, fmt.Errorf("write node status %s: %w", report.Name, err)) + } + } + return errors.Join(append([]error{failure}, statusErrors...)...) +} + +// BindingSelector snapshots the last coherently loaded policy selectors. It +// performs no I/O and holds no controller lock when invoked. Discovery uses it +// only to determine which authoritative pod/container metadata REQUIRE runtime +// identity: it is not permission to guess an identity or exempt observer-ignored +// workloads. Invalid policies conservatively require every container in their +// owning namespace (or every namespace for cluster policies), retaining last-good +// selection until the invalid revision is repaired or intentionally deleted. +func (c *Controller) BindingSelector() func(Binding) bool { + c.mu.Lock() + defer c.mu.Unlock() + return selectorFor(c.objects) +} + +func selectorFor(objects map[string]*unstructured.Unstructured) func(Binding) bool { + policies := make([]desiredPolicy, 0, len(objects)) + for _, obj := range objects { + if obj.GetKind() == "SandboxPolicy" || obj.GetKind() == "ClusterSandboxPolicy" { + policies = append(policies, parsePolicy(obj)) + } + } + return func(binding Binding) bool { + for _, policy := range policies { + if policy.identity.Kind == "SandboxPolicy" && policy.identity.Namespace != binding.Namespace { + continue + } + if policy.err != nil { + return true + } + if policy.spec.Mode != api.ModeOff && policy.matches(binding) { + return true + } + } + return false + } +} + +func validatedBindings(bindings []Binding) (map[string]Binding, error) { + next := make(map[string]Binding, len(bindings)) + for _, binding := range bindings { + if binding.ContainerID == "" || binding.CgroupID == 0 || binding.PodUID == "" || binding.Namespace == "" || binding.PodName == "" || binding.ContainerName == "" { + return nil, fmt.Errorf("incomplete container binding") + } + if binding.Kind != "regular" && binding.Kind != "init" && binding.Kind != "ephemeral" { + return nil, fmt.Errorf("invalid container kind %q", binding.Kind) + } + key := bindingKey(binding) + if _, ok := next[key]; ok { + return nil, fmt.Errorf("duplicate container binding %s", key) + } + binding.PodLabels = cloneLabels(binding.PodLabels) + binding.NamespaceLabels = cloneLabels(binding.NamespaceLabels) + next[key] = binding + } + return next, nil +} + +func (c *Controller) loadConfiguration(ctx context.Context) (map[string]*unstructured.Unstructured, error) { + snapshot := make(map[string]*unstructured.Unstructured) + for _, resource := range []schema.GroupVersionResource{PolicyGVR, ClusterPolicyGVR, ClassesGVR} { + list, err := c.client.Resource(resource).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("configuration snapshot %s unavailable: %w", resource.Resource, err) + } + for i := range list.Items { + obj := list.Items[i].DeepCopy() + snapshot[objectKey(obj)] = obj + } + } + return snapshot, nil +} + +// Refresh is the integrated configuration/selection readiness barrier. It reads +// all policy inputs, selects from that candidate configuration, then obtains the +// matching authoritative runtime snapshot BEFORE any installation or Ready report. +// The callback executes under the reconciliation lock and must not call Controller +// methods. It must reject incomplete discovery and concurrent churn, not return an +// inferred empty snapshot. The caller remains responsible for subscribe/replay. +// On snapshot failure the fetched config is retained for truthful new-policy +// NotReady reports, while last-good binding state is preserved. Valid explicit +// off/deletion may withdraw previously owned policies; no new plan is installed. +func (c *Controller) Refresh(ctx context.Context, snapshot func(context.Context, func(Binding) bool) ([]Binding, error)) error { + c.mu.Lock() + defer c.mu.Unlock() + configuration, err := c.loadConfiguration(ctx) + if err != nil { + c.configError = err + c.lastError = c.reconcile(ctx) + return c.lastError + } + var bindings []Binding + if snapshot == nil { + err = fmt.Errorf("binding snapshot resolver is required") + } else { + bindings, err = snapshot(ctx, selectorFor(configuration)) + } + if err == nil { + err = ctx.Err() + } + var next map[string]Binding + if err == nil { + next, err = validatedBindings(bindings) + } + c.objects = configuration + c.configError = nil + if err != nil { + return c.rejectBindings(ctx, err) + } + c.bindings = next + c.bindingError = nil + c.lastError = c.reconcile(ctx) + return c.lastError +} + +// During binding uncertainty only an authoritative explicit off/delete permits +// changing enforcement. Selector mismatch from old pod/namespace labels does not. +// Partial withdrawal reuses immutable last-good compiled rules for retained UIDs. +func (c *Controller) withdrawDisabled(ctx context.Context) error { + keep := make(map[string]bool) + for _, obj := range c.objects { + if obj.GetKind() == "SandboxPolicyClasses" { + continue + } + p := parsePolicy(obj) + if p.err != nil || p.spec.Mode != api.ModeOff { + keep[string(p.identity.UID)] = true + } + } + var failures []error + for _, pending := range c.withdrawals { + failures = append(failures, c.withdraw(ctx, pending)) + } + for _, old := range c.active { + retained := Plan{} + for _, p := range old.plan.Policies { + if keep[string(p.Identity.UID)] { + retained.Policies = append(retained.Policies, p) + } + } + if len(retained.Policies) == len(old.plan.Policies) { + // Re-attest the immutable last-good plan on every heartbeat: an init + // exit or quarantine may have invalidated previously healthy protection. + // Replace is not read-only: it may resume an uncommitted candidate. + failures = append(failures, c.attest(ctx, old)) + continue + } + if len(retained.Policies) == 0 { + failures = append(failures, c.withdraw(ctx, old)) + continue + } + retained.Revision = digest(retained.Policies) + failures = append(failures, c.replace(ctx, old.binding, retained)) + } + return errors.Join(failures...) +} diff --git a/pkg/sandboxpolicy/controller_test.go b/pkg/sandboxpolicy/controller_test.go new file mode 100644 index 0000000000..8bde77760c --- /dev/null +++ b/pkg/sandboxpolicy/controller_test.go @@ -0,0 +1,1178 @@ +package sandboxpolicy + +import ( + "context" + "errors" + "strings" + "testing" + + api "github.com/kubescape/storage/pkg/apis/sandbox/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/dynamic/fake" + ktesting "k8s.io/client-go/testing" +) + +type fakeBackend struct { + protectionUnconfirmed bool + attestations, cleanupRetries int + cleanup map[string]bool + postCommit bool + withdrawPostCommit bool + plans map[string]Plan + replacements, withdrawals int + fail bool +} + +func (b *fakeBackend) Replace(_ context.Context, binding Binding, plan Plan) (ReplaceResult, error) { + key := bindingKey(binding) + if b.cleanup == nil { + b.cleanup = make(map[string]bool) + } + current, exists := b.plans[key] + same := exists && current.Revision == plan.Revision + if same { + b.attestations++ + } else { + b.replacements++ + } + if b.fail { + return ReplaceResult{ProtectionUnconfirmed: b.protectionUnconfirmed}, errors.New("candidate failed") + } + result := ReplaceResult{Committed: true, ActiveRevision: plan.Revision, ProtectionUnconfirmed: b.protectionUnconfirmed} + if same { + if b.protectionUnconfirmed { + return result, errors.New("instance externally stopped") + } + if b.cleanup[key] { + b.cleanupRetries++ + if b.postCommit { + return result, errors.New("retirement failed after commit") + } + delete(b.cleanup, key) + } + return result, nil + } + b.plans[key] = plan + if b.protectionUnconfirmed { + return result, errors.New("instance externally stopped") + } + if b.postCommit { + b.cleanup[key] = true + return result, errors.New("retirement failed after commit") + } + return result, nil +} + +func (b *fakeBackend) Attest(_ context.Context, binding Binding, revision string) error { + b.attestations++ + key := bindingKey(binding) + if b.fail || b.protectionUnconfirmed || b.cleanup[key] || b.plans[key].Revision != revision { + return errors.New("retained revision protection unconfirmed") + } + return nil +} + +func (b *fakeBackend) Withdraw(_ context.Context, binding Binding) (WithdrawResult, error) { + b.withdrawals++ + if b.fail { + return WithdrawResult{ProtectionUnconfirmed: b.protectionUnconfirmed}, errors.New("withdraw failed") + } + delete(b.plans, bindingKey(binding)) + if b.withdrawPostCommit { + return WithdrawResult{Committed: true}, errors.New("withdraw cleanup failed after commit") + } + return WithdrawResult{Committed: true}, nil +} + +type fakeWriter struct { + reports map[string]*api.SandboxPolicyNodeStatus + fail bool +} + +func (w *fakeWriter) WriteStatus(_ context.Context, r *api.SandboxPolicyNodeStatus) error { + if w.fail { + return errors.New("status unavailable") + } + w.reports[string(r.Spec.PolicyRef.UID)] = r.DeepCopy() + return nil +} +func policy(name, namespace string) *api.SandboxPolicy { + return &api.SandboxPolicy{TypeMeta: metav1.TypeMeta{APIVersion: api.SchemeGroupVersion.String(), Kind: "SandboxPolicy"}, ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, UID: types.UID(name), Generation: 1}, Spec: api.SandboxPolicySpec{PolicySpec: execSpec()}} +} +func binding() Binding { + return Binding{ContainerID: "container", CgroupID: 12, PodUID: "pod", Namespace: "ns", PodName: "pod", ContainerName: "app", Kind: "regular", PodLabels: map[string]string{"app": "test"}, NamespaceLabels: map[string]string{"team": "red"}} +} +func setup(t *testing.T, objects ...runtime.Object) (*Controller, *fakeBackend, *fakeWriter, *fake.FakeDynamicClient) { + t.Helper() + scheme := runtime.NewScheme() + var seeded []runtime.Object + for _, obj := range objects { + if _, isClasses := obj.(*api.SandboxPolicyClasses); !isClasses { + raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + t.Fatal(err) + } + seeded = append(seeded, &unstructured.Unstructured{Object: raw}) + } + } + client := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{PolicyGVR: "SandboxPolicyList", ClusterPolicyGVR: "ClusterSandboxPolicyList", ClassesGVR: "SandboxPolicyClassesList", NodeStatusGVR: "SandboxPolicyNodeStatusList"}, seeded...) + for _, obj := range objects { + if classes, ok := obj.(*api.SandboxPolicyClasses); ok { + raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(classes) + if err != nil { + t.Fatal(err) + } + if _, err := client.Resource(ClassesGVR).Create(context.Background(), &unstructured.Unstructured{Object: raw}, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + } + } + backend := &fakeBackend{plans: make(map[string]Plan)} + writer := &fakeWriter{reports: make(map[string]*api.SandboxPolicyNodeStatus)} + controller, err := NewController(client, backend, nil, writer, Options{Node: api.NodeReference{Name: "node", UID: "node-uid"}, AgentNamespace: "agent"}) + if err != nil { + t.Fatal(err) + } + return controller, backend, writer, client +} + +func TestInitialSyncUpdateLastGoodAndWithdrawal(t *testing.T) { + ctx := context.Background() + p := policy("policy", "ns") + c, b, w, _ := setup(t, p) + if err := c.SetBindings(ctx, []Binding{binding()}); err != nil { + t.Fatal(err) + } + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + if b.replacements != 1 { + t.Fatalf("initial replacements %d", b.replacements) + } + p.ResourceVersion = "2" + p.Status.ObservedGeneration = 1 + c.ModifyHandler(ctx, p) + if b.replacements != 1 { + t.Fatal("status-only reload") + } + original := b.plans[bindingKey(binding())].Revision + p.Generation = 2 + p.Spec.Exec.Rules[0].Target.Path = []string{"/bin/new"} + b.fail = true + c.ModifyHandler(ctx, p) + if c.LastError() == nil || b.plans[bindingKey(binding())].Revision != original { + t.Fatal("failed revision lost last-good") + } + report := w.reports["policy"] + if report.Status.Conditions[0].Status != metav1.ConditionFalse || report.Status.ObservedGeneration != 2 || !report.Status.Stale || !strings.Contains(report.Status.Conditions[0].Message, "last-good") { + t.Fatalf("misleading report: %+v", report.Status) + } + b.fail = false + if err := c.Reconcile(ctx); err != nil { + t.Fatal(err) + } + if b.plans[bindingKey(binding())].Revision == original { + t.Fatal("retry did not install") + } + p.Generation = 3 + p.Spec.Mode = api.ModeOff + c.ModifyHandler(ctx, p) + if len(b.plans) != 0 || b.withdrawals != 1 { + t.Fatal("off did not withdraw") + } + p.Generation = 4 + p.Spec.Mode = api.ModeEnforce + c.ModifyHandler(ctx, p) + c.DeleteHandler(ctx, p) + if len(b.plans) != 0 || b.withdrawals != 2 { + t.Fatal("delete did not withdraw") + } +} + +func TestSelectionAndChurn(t *testing.T) { + ctx := context.Background() + p := policy("namespaced", "other") + cluster := &api.ClusterSandboxPolicy{TypeMeta: metav1.TypeMeta{APIVersion: api.SchemeGroupVersion.String(), Kind: "ClusterSandboxPolicy"}, ObjectMeta: metav1.ObjectMeta{Name: "cluster", UID: "cluster", Generation: 1}, Spec: api.ClusterSandboxPolicySpec{Selector: api.ClusterPolicySelector{PodSelector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "test"}}, NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"team": "red"}}}, PolicySpec: execSpec()}} + c, b, w, _ := setup(t, p, cluster) + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + regular := binding() + init := regular + init.ContainerID = "init" + init.CgroupID = 13 + init.Kind = "init" + if err := c.SetBindings(ctx, []Binding{regular, init}); err != nil { + t.Fatal(err) + } + if len(b.plans) != 1 || len(b.plans[bindingKey(regular)].Policies) != 1 || b.plans[bindingKey(regular)].Policies[0].Identity.UID != "cluster" { + t.Fatalf("incorrect selection: %+v", b.plans) + } + if w.reports["cluster"].Namespace != "agent" || w.reports["namespaced"].Namespace != "other" { + t.Fatal("incorrect report namespace") + } + regular.NamespaceLabels["team"] = "blue" + if err := c.SetBindings(ctx, []Binding{regular}); err != nil { + t.Fatal(err) + } + if len(b.plans) != 0 { + t.Fatal("namespace label change did not withdraw") + } + regular.NamespaceLabels["team"] = "red" + if err := c.SetBindings(ctx, []Binding{regular}); err != nil { + t.Fatal(err) + } + if err := c.SetBindings(ctx, nil); err != nil { + t.Fatal(err) + } + if len(b.plans) != 0 { + t.Fatal("container deletion did not withdraw") + } +} + +func TestInvalidRevisionDoesNotWithdrawAndExplicitDeleteDoes(t *testing.T) { + ctx := context.Background() + p := policy("policy", "ns") + other := policy("other", "ns") + c, b, _, _ := setup(t, p, other) + c.SetBindings(ctx, []Binding{binding()}) + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + original := b.plans[bindingKey(binding())].Revision + raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(p) + if err != nil { + t.Fatal(err) + } + u := &unstructured.Unstructured{Object: raw} + u.SetGeneration(2) + if err := unstructured.SetNestedField(raw, true, "spec", "unknownPredicate"); err != nil { + t.Fatal(err) + } + c.ModifyHandler(ctx, u) + if c.LastError() == nil || b.plans[bindingKey(binding())].Revision != original { + t.Fatal("unknown field was dropped or last-good lost") + } + c.DeleteHandler(ctx, other) + current := b.plans[bindingKey(binding())] + if len(current.Policies) != 1 || current.Policies[0].Identity.UID != "policy" { + t.Fatalf("explicit deletion retained behind unrelated invalid revision: %+v", current) + } +} + +func TestRelistFailurePreservesSnapshotAndStatusFailureVisible(t *testing.T) { + ctx := context.Background() + p := policy("policy", "ns") + c, b, w, client := setup(t, p) + c.SetBindings(ctx, []Binding{binding()}) + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + client.PrependReactor("list", "clustersandboxpolicies", func(ktesting.Action) (bool, runtime.Object, error) { return true, nil, errors.New("disconnected") }) + if err := c.InitialSync(ctx); err == nil { + t.Fatal("hidden relist failure") + } + if len(b.plans) != 1 { + t.Fatal("partial snapshot withdrawn") + } + w.fail = true + if err := c.Reconcile(ctx); err == nil || !strings.Contains(err.Error(), "status unavailable") { + t.Fatal("hidden status write loss") + } +} + +func TestClassChangesAndStaleDelete(t *testing.T) { + ctx := context.Background() + p := policy("policy", "ns") + p.Spec.Exec.Rules[0].Target.PathClass = []string{"tools"} + classes := &api.SandboxPolicyClasses{TypeMeta: metav1.TypeMeta{APIVersion: api.SchemeGroupVersion.String(), Kind: "SandboxPolicyClasses"}, ObjectMeta: metav1.ObjectMeta{Name: "default", UID: "classes", Generation: 1}, Spec: api.PolicyClasses{PathClasses: map[string][]string{"tools": {"/bin/tool"}}}} + c, b, _, _ := setup(t, p, classes) + c.SetBindings(ctx, []Binding{binding()}) + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + original := b.plans[bindingKey(binding())].Revision + classes.Generation = 2 + classes.Spec.PathClasses["tools"] = []string{"/bin/next"} + c.ModifyHandler(ctx, classes) + if b.plans[bindingKey(binding())].Revision == original { + t.Fatal("class update did not reload") + } + before := b.plans[bindingKey(binding())].Revision + c.DeleteHandler(ctx, classes) + if c.LastError() == nil || b.plans[bindingKey(binding())].Revision != before { + t.Fatal("missing class weakened policy") + } + stale := p.DeepCopy() + stale.UID = "old-uid" + c.DeleteHandler(ctx, stale) + if len(b.plans) != 1 { + t.Fatal("stale delete removed newer policy") + } +} + +func TestProfileRefreshAndRejectedBinding(t *testing.T) { + ctx := context.Background() + p := policy("policy", "ns") + p.Spec.Sources = []api.PolicySource{{ContainerProfile: api.ContainerProfileReference{Name: "profile"}, Include: []string{"execs"}}} + c, b, w, _ := setup(t, p) + path := "/bin/initial" + missing := false + c.resolver = resolverFunc(func(context.Context, string, string, string) ([]api.ExecRule, error) { + if missing { + return nil, errors.New("missing profile") + } + return []api.ExecRule{{Rule: api.Rule{Name: "profile", Action: api.ActionAllow}, Target: api.ExecTarget{PathTarget: api.PathTarget{Path: []string{path}}}}}, nil + }) + if err := c.SetBindings(ctx, []Binding{binding()}); err != nil { + t.Fatal(err) + } + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + original := b.plans[bindingKey(binding())].Revision + path = "/bin/changed" + if err := c.Reconcile(ctx); err != nil { + t.Fatal(err) + } + changed := b.plans[bindingKey(binding())].Revision + if original == changed { + t.Fatal("profile update did not recompile") + } + missing = true + if err := c.Reconcile(ctx); err == nil { + t.Fatal("missing profile accepted") + } + if b.plans[bindingKey(binding())].Revision != changed { + t.Fatal("profile loss removed last-good") + } + incomplete := binding() + incomplete.CgroupID = 0 + if err := c.SetBindings(ctx, []Binding{incomplete}); err == nil { + t.Fatal("incomplete binding accepted") + } + if len(b.plans) != 1 || w.reports["policy"].Status.Conditions[0].Status != metav1.ConditionFalse { + t.Fatal("failed binding barrier did not retain/report") + } +} + +func TestOutdatedGenerationAndMalformedSelection(t *testing.T) { + ctx := context.Background() + p := policy("policy", "ns") + c, b, _, _ := setup(t, p) + c.SetBindings(ctx, []Binding{binding()}) + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + p.Generation = 3 + p.Spec.Exec.Rules[0].Target.Path = []string{"/bin/latest"} + c.ModifyHandler(ctx, p) + latest := b.plans[bindingKey(binding())].Revision + old := p.DeepCopy() + old.Generation = 2 + old.Spec.Mode = api.ModeOff + c.ModifyHandler(ctx, old) + if b.plans[bindingKey(binding())].Revision != latest { + t.Fatal("stale revision changed enforcement") + } + p.Generation = 4 + p.Spec.Containers = nil + c.ModifyHandler(ctx, p) + if c.LastError() == nil || b.plans[bindingKey(binding())].Revision != latest { + t.Fatal("malformed selector authorized withdrawal") + } +} + +func TestPostCommitFailureTracksActualRevisionAndRetriesCleanup(t *testing.T) { + ctx := context.Background() + p := policy("policy", "ns") + c, b, w, _ := setup(t, p) + c.SetBindings(ctx, []Binding{binding()}) + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + previous := b.plans[bindingKey(binding())].Revision + b.postCommit = true + p.Generation = 2 + p.Spec.Exec.Rules[0].Target.Path = []string{"/bin/new"} + c.ModifyHandler(ctx, p) + current := b.plans[bindingKey(binding())] + if current.Revision == previous || c.active[bindingKey(binding())].plan.Revision != current.Revision || !c.active[bindingKey(binding())].cleanupPending { + t.Fatal("committed failure misreported as rollback") + } + report := w.reports["policy"] + if report.Status.Conditions[0].Status != metav1.ConditionFalse || report.Status.Conditions[0].Reason != "CleanupPending" || report.Status.Stale { + t.Fatalf("incorrect committed failure status: %+v", report.Status) + } + if len(report.Status.Conditions) != 2 || !strings.Contains(report.Status.Conditions[1].Message, current.Policies[0].Revision) { + t.Fatal("actual active revision missing") + } + replacements := b.replacements + b.postCommit = false + if err := c.Reconcile(ctx); err != nil { + t.Fatal(err) + } + if b.replacements != replacements || b.cleanupRetries != 1 || c.active[bindingKey(binding())].cleanupPending || w.reports["policy"].Status.Conditions[0].Status != metav1.ConditionTrue { + t.Fatal("cleanup retry did not converge") + } + if b.plans[bindingKey(binding())].Revision != current.Revision { + t.Fatal("cleanup retry changed desired revision") + } +} + +func TestWithdrawalCommitAndCleanupBarrier(t *testing.T) { + ctx := context.Background() + p := policy("policy", "ns") + c, b, w, _ := setup(t, p) + if err := c.SetBindings(ctx, []Binding{binding()}); err != nil { + t.Fatal(err) + } + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + p.Generation = 2 + p.Spec.Mode = api.ModeOff + b.fail = true + c.ModifyHandler(ctx, p) + if len(c.active) != 1 || len(b.plans) != 1 || len(c.withdrawals) != 0 { + t.Fatal("precommit failure discarded last-good") + } + b.fail = false + b.withdrawPostCommit = true + if err := c.Reconcile(ctx); err == nil { + t.Fatal("hidden postcommit failure") + } + if len(c.active) != 0 || len(b.plans) != 0 || len(c.withdrawals) != 1 { + t.Fatal("committed removal still reported active or cleanup forgotten") + } + report := w.reports["policy"] + if report.Status.Conditions[0].Status != metav1.ConditionFalse || report.Status.Conditions[0].Reason != "WithdrawalCleanupPending" || strings.Contains(report.Status.Conditions[0].Message, "last-good") || len(report.Status.Conditions) != 1 { + t.Fatalf("removed protection falsely reported: %+v", report.Status) + } + replacements := b.replacements + // Re-enable while retirement is still pending: no new candidate may be placed + // where a later retry could accidentally withdraw it. + p.Generation = 3 + p.Spec.Mode = api.ModeEnforce + c.ModifyHandler(ctx, p) + if b.replacements != replacements || len(c.active) != 0 || w.reports["policy"].Status.Conditions[0].Status != metav1.ConditionFalse { + t.Fatal("replacement bypassed cleanup barrier") + } + b.fail = true + if err := c.Reconcile(ctx); err == nil { + t.Fatal("retry failure hidden") + } + if len(c.active) != 0 || len(c.withdrawals) != 1 { + t.Fatal("failed retry resurrected withdrawn enforcement") + } + b.fail = false + b.withdrawPostCommit = false + if err := c.Reconcile(ctx); err != nil { + t.Fatal(err) + } + if len(c.withdrawals) != 0 || len(c.active) != 1 || b.replacements != replacements+1 || w.reports["policy"].Status.Conditions[0].Status != metav1.ConditionTrue { + t.Fatal("cleanup/re-enable did not converge") + } +} + +func TestContainerDeletionPostCommitCleanup(t *testing.T) { + ctx := context.Background() + p := policy("policy", "ns") + c, b, w, _ := setup(t, p) + if err := c.SetBindings(ctx, []Binding{binding()}); err != nil { + t.Fatal(err) + } + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + b.withdrawPostCommit = true + if err := c.SetBindings(ctx, nil); err == nil { + t.Fatal("container cleanup failure hidden") + } + if len(c.active) != 0 || len(c.withdrawals) != 1 || w.reports["policy"].Status.Conditions[0].Reason != "WithdrawalCleanupPending" { + t.Fatal("container removal falsely remains protected") + } + withdrawals := b.withdrawals + b.withdrawPostCommit = false + if err := c.Reconcile(ctx); err != nil { + t.Fatal(err) + } + if b.withdrawals != withdrawals+1 || len(c.withdrawals) != 0 || len(c.active) != 0 || len(b.plans) != 0 { + t.Fatal("deleted binding cleanup did not converge") + } +} + +func TestReportBindingFailurePersistsUntilAuthoritativeSnapshot(t *testing.T) { + ctx := context.Background() + p := policy("policy", "ns") + c, b, w, _ := setup(t, p) + if err := c.SetBindings(ctx, []Binding{binding()}); err != nil { + t.Fatal(err) + } + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + revision := b.plans[bindingKey(binding())].Revision + if err := c.ReportBindingFailure(ctx, errors.New("runtime discovery barrier incomplete")); err == nil { + t.Fatal("binding barrier failure hidden") + } + if len(c.bindings) != 1 || len(c.active) != 1 || b.plans[bindingKey(binding())].Revision != revision || w.reports["policy"].Status.Conditions[0].Status != metav1.ConditionFalse { + t.Fatal("binding failure withdrew snapshot or reported Ready") + } + if err := c.Reconcile(ctx); err == nil { + t.Fatal("periodic reconcile cleared failed snapshot barrier") + } + if w.reports["policy"].Status.Conditions[0].Status != metav1.ConditionFalse || len(b.plans) != 1 { + t.Fatal("periodic reconcile lost NotReady or enforcement") + } + if err := c.SetBindings(ctx, []Binding{binding()}); err != nil { + t.Fatal(err) + } + if w.reports["policy"].Status.Conditions[0].Status != metav1.ConditionTrue || c.LastError() != nil || b.plans[bindingKey(binding())].Revision != revision { + t.Fatal("valid snapshot did not clear barrier while preserving revision") + } +} + +func TestConfigurationFailureBarrierSurvivesOtherReconciliation(t *testing.T) { + ctx := context.Background() + p := policy("policy", "ns") + c, b, w, client := setup(t, p) + first := binding() + if err := c.SetBindings(ctx, []Binding{first}); err != nil { + t.Fatal(err) + } + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + revision := b.plans[bindingKey(first)].Revision + replaces := b.replacements + fail := true + client.PrependReactor("list", "clustersandboxpolicies", func(ktesting.Action) (bool, runtime.Object, error) { + if fail { + return true, nil, errors.New("configuration API disconnected") + } + return false, nil, nil + }) + if err := c.InitialSync(ctx); err == nil { + t.Fatal("hidden configuration snapshot failure") + } + report := w.reports["policy"] + if report.Status.Conditions[0].Status != metav1.ConditionFalse || report.Status.Conditions[0].Reason != "ConfigurationUnavailable" || !report.Status.Stale { + t.Fatal("configuration loss not reported") + } + second := first + second.ContainerID = "new-container" + second.CgroupID++ + if err := c.SetBindings(ctx, []Binding{first, second}); err == nil { + t.Fatal("new runtime snapshot cleared configuration barrier") + } + if err := c.Reconcile(ctx); err == nil { + t.Fatal("periodic reconcile cleared configuration barrier") + } + // Delayed change/delete events do not make a partial configuration current. + changed := p.DeepCopy() + changed.Generation++ + changed.Spec.Mode = api.ModeOff + c.ModifyHandler(ctx, changed) + c.DeleteHandler(ctx, p) + if c.LastError() == nil || b.replacements != replaces || b.withdrawals != 0 || len(b.plans) != 1 || b.plans[bindingKey(first)].Revision != revision { + t.Fatal("stale/partial config authorized transition") + } + if w.reports["policy"].Status.Conditions[0].Status != metav1.ConditionFalse { + t.Fatal("watch event cleared NotReady") + } + fail = false + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + if len(b.plans) != 2 || b.replacements != replaces+1 || w.reports["policy"].Status.Conditions[0].Status != metav1.ConditionTrue { + t.Fatal("coherent snapshot did not recover latest bindings") + } +} + +func TestConfigurationFailureDefersWithdrawalUntilRecovery(t *testing.T) { + ctx := context.Background() + p := policy("policy", "ns") + c, b, _, client := setup(t, p) + if err := c.SetBindings(ctx, []Binding{binding()}); err != nil { + t.Fatal(err) + } + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + fail := true + client.PrependReactor("list", "sandboxpolicies", func(ktesting.Action) (bool, runtime.Object, error) { + if fail { + return true, nil, errors.New("list failed") + } + return false, nil, nil + }) + if err := c.InitialSync(ctx); err == nil { + t.Fatal("hidden failure") + } + if err := c.SetBindings(ctx, nil); err == nil { + t.Fatal("empty binding snapshot cleared barrier") + } + if b.withdrawals != 0 || len(b.plans) != 1 { + t.Fatal("withdrawal used incomplete configuration") + } + fail = false + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + if b.withdrawals != 1 || len(b.plans) != 0 { + t.Fatal("recovery did not apply authoritative disappearance") + } +} + +func TestBindingSelectorUsesPolicySelectionWithoutObserverExemptions(t *testing.T) { + ctx := context.Background() + p := policy("policy", "ns") + p.Spec.Selector.PodSelector.MatchLabels = map[string]string{"app": "test"} + c, _, _, _ := setup(t, p) + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + selected := c.BindingSelector() + b := binding() + if !selected(b) { + t.Fatal("matching regular container not selected") + } + b.PodName = "node-agent-own" + if !selected(b) { + t.Fatal("inferred agent exemption") + } + b.Namespace = "other" + if selected(b) { + t.Fatal("namespaced policy escaped namespace") + } + b = binding() + b.Kind = "init" + if selected(b) { + t.Fatal("wildcard selected init container") + } + b = binding() + b.PodLabels = map[string]string{"app": "other"} + if selected(b) { + t.Fatal("pod labels ignored") + } + p.Generation++ + p.Spec.Mode = api.ModeOff + c.ModifyHandler(ctx, p) + if c.BindingSelector()(binding()) { + t.Fatal("off policy still requires runtime discovery") + } + if !selected(binding()) { + t.Fatal("selector closure aliased mutable controller configuration") + } + p.Generation++ + p.Spec.Mode = api.ModeEnforce + c.ModifyHandler(ctx, p) + c.DeleteHandler(ctx, p) + if c.BindingSelector()(binding()) { + t.Fatal("deleted policy still selects") + } +} + +func TestBindingSelectorClusterAndInvalidConservatism(t *testing.T) { + ctx := context.Background() + cluster := &api.ClusterSandboxPolicy{TypeMeta: metav1.TypeMeta{APIVersion: api.SchemeGroupVersion.String(), Kind: "ClusterSandboxPolicy"}, ObjectMeta: metav1.ObjectMeta{Name: "cluster", UID: "cluster", Generation: 1}, Spec: api.ClusterSandboxPolicySpec{Selector: api.ClusterPolicySelector{NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"team": "red"}}}, PolicySpec: execSpec()}} + c, _, _, _ := setup(t, cluster) + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + b := binding() + if !c.BindingSelector()(b) { + t.Fatal("cluster selector not applied") + } + b.NamespaceLabels = map[string]string{"team": "blue"} + if c.BindingSelector()(b) { + t.Fatal("cluster namespace labels ignored") + } + cluster.Generation++ + cluster.Spec.Containers = nil + c.ModifyHandler(ctx, cluster) + b.Kind = "ephemeral" + b.Namespace = "other" + b.PodLabels = nil + if !c.BindingSelector()(b) { + t.Fatal("invalid cluster policy did not conservatively retain discovery") + } + c.DeleteHandler(ctx, cluster) + p := policy("invalid", "ns") + p.Spec.Containers = nil + c.AddHandler(ctx, p) + b.Namespace = "ns" + if !c.BindingSelector()(b) { + t.Fatal("invalid namespaced policy not conservative") + } + b.Namespace = "other" + if c.BindingSelector()(b) { + t.Fatal("invalid namespaced policy escaped owner namespace") + } +} + +type historyStatusWriter struct { + reports []*api.SandboxPolicyNodeStatus +} + +func (w *historyStatusWriter) WriteStatus(_ context.Context, report *api.SandboxPolicyNodeStatus) error { + w.reports = append(w.reports, report.DeepCopy()) + return nil +} + +func TestRefreshNeverReportsReadyBeforeBindingSnapshot(t *testing.T) { + ctx := context.Background() + p := policy("new-policy", "ns") + c, b, _, _ := setup(t, p) + history := &historyStatusWriter{} + c.writer = history + err := c.Refresh(ctx, func(_ context.Context, selected func(Binding) bool) ([]Binding, error) { + if !selected(binding()) { + t.Fatal("snapshot did not use newly fetched policy selectors") + } + if len(history.reports) != 0 || b.replacements != 0 { + t.Fatal("configuration was reconciled before discovery barrier") + } + return nil, errors.New("selected ignored workload unavailable") + }) + if err == nil || len(history.reports) == 0 || b.replacements != 0 { + t.Fatal("failed new binding was not rejected/reported") + } + if err := c.InitialSync(ctx); err == nil { + t.Fatal("InitialSync cleared failed binding barrier") + } + changed := p.DeepCopy() + changed.Generation = 2 + changed.Spec.Exec.Rules[0].Target.Path = []string{"/bin/updated"} + c.ModifyHandler(ctx, changed) + if err := c.Reconcile(ctx); err == nil { + t.Fatal("reconcile cleared failed binding barrier") + } + for _, r := range history.reports { + if r.Spec.PolicyRef.UID == p.UID && r.Status.Conditions[0].Status != metav1.ConditionFalse { + t.Fatalf("unavailable desired policy reported Ready: %+v", r.Status) + } + } + if b.replacements != 0 { + t.Fatal("stale runtime snapshot installed changed policy") + } + snapshotComplete := false + if err := c.Refresh(ctx, func(_ context.Context, selected func(Binding) bool) ([]Binding, error) { + if !selected(binding()) { + t.Fatal("recovery selectors lost") + } + snapshotComplete = true + return []Binding{binding()}, nil + }); err != nil { + t.Fatal(err) + } + if !snapshotComplete || b.replacements != 1 || history.reports[len(history.reports)-1].Status.Conditions[0].Status != metav1.ConditionTrue { + t.Fatal("complete snapshot failed to authorize Ready") + } +} + +func TestRefreshSelectsCandidateConfigurationAtomically(t *testing.T) { + ctx := context.Background() + p := policy("policy", "ns") + p.Spec.Selector.PodSelector.MatchLabels = map[string]string{"app": "old"} + c, b, _, client := setup(t, p) + old := binding() + old.PodLabels = map[string]string{"app": "old"} + if err := c.Refresh(ctx, func(_ context.Context, selected func(Binding) bool) ([]Binding, error) { + if !selected(old) { + t.Fatal("initial selector") + } + return []Binding{old}, nil + }); err != nil { + t.Fatal(err) + } + revision := b.plans[bindingKey(old)].Revision + p.Generation = 2 + p.Spec.Selector.PodSelector.MatchLabels = map[string]string{"app": "new"} + p.Spec.Exec.Rules[0].Target.Path = []string{"/bin/new"} + raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(p) + if err != nil { + t.Fatal(err) + } + if _, err := client.Resource(PolicyGVR).Namespace("ns").Update(ctx, &unstructured.Unstructured{Object: raw}, metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + current := old + current.PodLabels = map[string]string{"app": "new"} + history := &historyStatusWriter{} + c.writer = history + if err := c.Refresh(ctx, func(_ context.Context, selected func(Binding) bool) ([]Binding, error) { + if selected(old) || !selected(current) { + t.Fatal("resolver used previous configuration selectors") + } + if b.plans[bindingKey(old)].Revision != revision || len(history.reports) != 0 { + t.Fatal("candidate applied before matching snapshot completed") + } + return []Binding{current}, nil + }); err != nil { + t.Fatal(err) + } + plan := b.plans[bindingKey(current)] + if plan.Revision == revision || plan.Policies[0].Identity.Generation != 2 || plan.Policies[0].Exec.Rules[0].Target.Path[0] != "/bin/new" || len(history.reports) != 1 || history.reports[0].Status.Conditions[0].Status != metav1.ConditionTrue { + t.Fatal("candidate configuration/bindings not committed together") + } +} + +func TestRefreshBindingFailureAllowsOnlyExplicitOffAndDeletion(t *testing.T) { + ctx := context.Background() + p := policy("off", "ns") + other := policy("retained", "ns") + c, b, _, client := setup(t, p, other) + success := func(context.Context, func(Binding) bool) ([]Binding, error) { return []Binding{binding()}, nil } + failure := func(context.Context, func(Binding) bool) ([]Binding, error) { + return nil, errors.New("binding snapshot incomplete") + } + if err := c.Refresh(ctx, success); err != nil { + t.Fatal(err) + } + p.Generation = 2 + p.Spec.Mode = api.ModeOff + other.Generation = 2 + other.Spec.Exec.Rules[0].Target.Path = []string{"/bin/must-not-install"} + for _, object := range []*api.SandboxPolicy{p, other} { + raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(object) + if err != nil { + t.Fatal(err) + } + if _, err := client.Resource(PolicyGVR).Namespace("ns").Update(ctx, &unstructured.Unstructured{Object: raw}, metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + } + if err := c.Refresh(ctx, failure); err == nil { + t.Fatal("failure hidden") + } + plan := b.plans[bindingKey(binding())] + if len(plan.Policies) != 1 || plan.Policies[0].Identity.UID != "retained" || plan.Policies[0].Identity.Generation != 1 || plan.Policies[0].Exec.Rules[0].Target.Path[0] != "/bin/sh" { + t.Fatal("failed snapshot either retained explicit off or installed revised rules") + } + replacements := b.replacements + if err := c.InitialSync(ctx); err == nil { + t.Fatal("config-only sync cleared binding barrier") + } + if b.replacements != replacements { + t.Fatal("config-only sync installed revised plan") + } + if err := client.Resource(PolicyGVR).Namespace("ns").Delete(ctx, "retained", metav1.DeleteOptions{}); err != nil { + t.Fatal(err) + } + if err := c.Refresh(ctx, failure); err == nil { + t.Fatal("failure hidden after delete") + } + if len(b.plans) != 0 || b.withdrawals != 1 { + t.Fatal("authoritative deletion did not withdraw exact old binding") + } +} + +func TestRefreshConfigurationFailureDoesNotResolvePartialSelectors(t *testing.T) { + ctx := context.Background() + p := policy("policy", "ns") + c, b, _, client := setup(t, p) + resolver := func(context.Context, func(Binding) bool) ([]Binding, error) { return []Binding{binding()}, nil } + if err := c.Refresh(ctx, resolver); err != nil { + t.Fatal(err) + } + original := b.plans[bindingKey(binding())].Revision + client.PrependReactor("list", "sandboxpolicyclasses", func(ktesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("classes unavailable") + }) + invoked := false + if err := c.Refresh(ctx, func(context.Context, func(Binding) bool) ([]Binding, error) { invoked = true; return nil, nil }); err == nil { + t.Fatal("partial configuration accepted") + } + if invoked || b.plans[bindingKey(binding())].Revision != original || b.withdrawals != 0 { + t.Fatal("partial config invoked resolver or altered last-good") + } +} + +func TestFailedBindingSnapshotDefersCommittedOffCleanupUntilRecovery(t *testing.T) { + ctx := context.Background() + p := policy("off", "ns") + other := policy("retained", "ns") + c, b, _, client := setup(t, p, other) + if err := c.Refresh(ctx, func(context.Context, func(Binding) bool) ([]Binding, error) { return []Binding{binding()}, nil }); err != nil { + t.Fatal(err) + } + p.Generation = 2 + p.Spec.Mode = api.ModeOff + raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(p) + if err != nil { + t.Fatal(err) + } + if _, err := client.Resource(PolicyGVR).Namespace("ns").Update(ctx, &unstructured.Unstructured{Object: raw}, metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + b.postCommit = true + if err := c.Refresh(ctx, func(context.Context, func(Binding) bool) ([]Binding, error) { + return nil, errors.New("discovery incomplete") + }); err == nil { + t.Fatal("failure hidden") + } + current := c.active[bindingKey(binding())] + if !current.cleanupPending || len(current.plan.Policies) != 1 { + t.Fatal("partial withdrawal commit not tracked") + } + replacements := b.replacements + b.postCommit = false + if err := c.Reconcile(ctx); err == nil { + t.Fatal("cleanup incorrectly cleared binding barrier") + } + after := c.active[bindingKey(binding())] + if b.replacements != replacements || b.cleanupRetries != 0 || !after.cleanupPending || !after.protectionUnconfirmed || after.plan.Revision != current.plan.Revision { + t.Fatal("failed binding snapshot resumed pending publication") + } + if err := c.SetBindings(ctx, []Binding{binding()}); err != nil { + t.Fatal(err) + } + after = c.active[bindingKey(binding())] + if b.cleanupRetries != 1 || after.cleanupPending || after.protectionUnconfirmed || after.plan.Revision != current.plan.Revision { + t.Fatal("fresh binding snapshot did not finalize committed cleanup") + } +} + +func TestUnconfirmedProtectionIsNotReportedAsLastGood(t *testing.T) { + ctx := context.Background() + p := policy("policy", "ns") + c, b, w, _ := setup(t, p) + if err := c.SetBindings(ctx, []Binding{binding()}); err != nil { + t.Fatal(err) + } + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + b.protectionUnconfirmed = true + if err := c.Reconcile(ctx); err == nil { + t.Fatal("unchanged revision did not re-attest health") + } + current := c.active[bindingKey(binding())] + report := w.reports["policy"] + if !current.protectionUnconfirmed || report.Status.Conditions[0].Status != metav1.ConditionFalse || report.Status.Conditions[0].Reason != "ProtectionUnconfirmed" || strings.Contains(report.Status.Conditions[0].Message, "last-good") || len(report.Status.Conditions) != 1 { + t.Fatalf("unknown protection reported as active/last-good: %+v", report.Status) + } + b.fail = true + p.Generation = 2 + p.Spec.Exec.Rules[0].Target.Path = []string{"/bin/new"} + c.ModifyHandler(ctx, p) + if !c.active[bindingKey(binding())].protectionUnconfirmed || w.reports["policy"].Status.Conditions[0].Reason != "ProtectionUnconfirmed" { + t.Fatal("rejected candidate hid prior loss") + } + b.fail = false + b.protectionUnconfirmed = false + if err := c.Reconcile(ctx); err != nil { + t.Fatal(err) + } + if c.active[bindingKey(binding())].protectionUnconfirmed || w.reports["policy"].Status.Conditions[0].Status != metav1.ConditionTrue || len(w.reports["policy"].Status.Conditions) != 2 { + t.Fatal("confirmed replacement did not restore truthful active report") + } +} + +func TestCompilerRejectionStillAttestsLastGoodHealth(t *testing.T) { + ctx := context.Background() + p := policy("policy", "ns") + c, b, w, _ := setup(t, p) + c.SetBindings(ctx, []Binding{binding()}) + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + b.protectionUnconfirmed = true + p.Generation = 2 + p.Spec.Exec.Rules[0].Target.PathClass = []string{"unresolved"} + c.ModifyHandler(ctx, p) + if c.LastError() == nil || !c.active[bindingKey(binding())].protectionUnconfirmed || w.reports["policy"].Status.Conditions[0].Reason != "ProtectionUnconfirmed" { + t.Fatal("compiler rejection falsely claimed last-good enforcement") + } + b.protectionUnconfirmed = false + if err := c.Reconcile(ctx); err == nil { + t.Fatal("healthy re-attestation hid desired compile rejection") + } + report := w.reports["policy"] + if c.active[bindingKey(binding())].protectionUnconfirmed || report.Status.Conditions[0].Status != metav1.ConditionFalse || report.Status.Conditions[0].Reason == "ProtectionUnconfirmed" || !strings.Contains(report.Status.Error, "unresolved path class") || len(report.Status.Conditions) != 2 { + t.Fatal("healthy rollback did not preserve compile error and confirmed old revision") + } + +} + +func TestUnchangedReconcileAttestsWithoutReinstalling(t *testing.T) { + ctx := context.Background() + p := policy("policy", "ns") + c, b, _, _ := setup(t, p) + c.SetBindings(ctx, []Binding{binding()}) + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + replacements := b.replacements + attestations := b.attestations + if err := c.Reconcile(ctx); err != nil { + t.Fatal(err) + } + if b.replacements != replacements || b.attestations != attestations+1 { + t.Fatal("unchanged revision either skipped health attestation or restarted enforcement") + } +} + +func TestFailedWithdrawalMarksRetainedProtectionUnconfirmed(t *testing.T) { + b := &fakeBackend{plans: map[string]Plan{}, fail: true, protectionUnconfirmed: true} + c := &Controller{backend: b, active: map[string]installed{}, withdrawals: map[string]installed{}} + old := installed{binding: binding()} + key := bindingKey(old.binding) + c.active[key] = old + if err := c.withdraw(context.Background(), old); err == nil { + t.Fatal("expected failed withdrawal") + } + if retained, ok := c.active[key]; !ok || !retained.protectionUnconfirmed { + t.Fatal("retiring owner falsely retained healthy protection") + } +} + +func TestBindingKeyIncludesExactRuntimeIdentity(t *testing.T) { + original := binding() + original.Runtime = "containerd" + original.CgroupPath = "/cgroup/container" + original.PID = 123 + original.MountNamespace = 456 + for name, change := range map[string]func(*Binding){ + "runtime": func(b *Binding) { b.Runtime = "cri-o" }, + "container": func(b *Binding) { b.ContainerID += "-next" }, + "cgroup-path": func(b *Binding) { b.CgroupPath += "-next" }, + "cgroup-id": func(b *Binding) { b.CgroupID++ }, + "mount-namespace": func(b *Binding) { b.MountNamespace++ }, + "pid": func(b *Binding) { b.PID++ }, + "pod-uid": func(b *Binding) { b.PodUID += "-next" }, + } { + t.Run(name, func(t *testing.T) { + next := original + change(&next) + if bindingKey(next) == bindingKey(original) { + t.Fatal("physical identity or PodUID change aliased retained owner") + } + }) + } + metadata := original + metadata.PodLabels = map[string]string{"new": "label"} + metadata.NamespaceLabels = map[string]string{"new": "label"} + if bindingKey(metadata) != bindingKey(original) { + t.Fatal("mutable selector labels changed runtime ownership") + } +} + +func TestConfigurationFailureFreezesPendingCleanupAndSuppressesProtection(t *testing.T) { + ctx := context.Background() + p := policy("policy", "ns") + c, b, w, client := setup(t, p) + if err := c.SetBindings(ctx, []Binding{binding()}); err != nil { + t.Fatal(err) + } + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + // A committed revision still awaiting cleanup must not be resumed during a + // failed LIST: the backend may carry a pending publication behind Replace. + b.postCommit = true + p.Generation++ + p.Spec.Exec.Rules[0].Target.Path = []string{"/bin/new"} + c.ModifyHandler(ctx, p) + if c.LastError() == nil || !c.active[bindingKey(binding())].cleanupPending { + t.Fatal("test requires a committed pending transition") + } + replacements, attestations, retries := b.replacements, b.attestations, b.cleanupRetries + revision := b.plans[bindingKey(binding())].Revision + client.PrependReactor("list", "sandboxpolicies", func(ktesting.Action) (bool, runtime.Object, error) { return true, nil, errors.New("API unavailable") }) + if err := c.InitialSync(ctx); err == nil { + t.Fatal("outage hidden") + } + if err := c.Reconcile(ctx); err == nil { + t.Fatal("outage unexpectedly cleared") + } + if b.replacements != replacements || b.attestations != attestations || b.cleanupRetries != retries || b.withdrawals != 0 || b.plans[bindingKey(binding())].Revision != revision { + t.Fatal("configuration outage resumed or changed backend ownership") + } + current := c.active[bindingKey(binding())] + if !current.protectionUnconfirmed || !current.cleanupPending || len(w.reports["policy"].Status.Conditions) != 1 || !strings.Contains(w.reports["policy"].Status.Conditions[0].Message, "protection unconfirmed") { + t.Fatal("configuration outage advertised unattested pending revision") + } +} + +func TestPhysicalIdentityReplacementWaitsForCommittedRemovalCleanup(t *testing.T) { + ctx := context.Background() + c, b, w, _ := setup(t, policy("policy", "ns")) + old := binding() + old.PID = 100 + if err := c.SetBindings(ctx, []Binding{old}); err != nil { + t.Fatal(err) + } + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + replacements := b.replacements + next := old + next.PID++ + b.withdrawPostCommit = true + if err := c.SetBindings(ctx, []Binding{next}); err == nil { + t.Fatal("cleanup failure hidden") + } + if len(c.active) != 0 || len(c.withdrawals) != 1 || b.replacements != replacements || len(w.reports["policy"].Status.Conditions) != 1 { + t.Fatal("replacement overtook previous physical identity cleanup") + } + if err := c.Reconcile(ctx); err == nil || b.replacements != replacements { + t.Fatal("retry admitted replacement before cleanup", err) + } + b.withdrawPostCommit = false + if err := c.Reconcile(ctx); err != nil { + t.Fatal(err) + } + if len(c.withdrawals) != 0 || len(c.active) != 1 || b.replacements != replacements+1 { + t.Fatal("replacement failed after cleanup completed") + } + if _, ok := c.active[bindingKey(next)]; !ok { + t.Fatal("replacement installed under stale physical identity") + } + if err := c.withdraw(ctx, installed{binding: old}); err != nil { + t.Fatal(err) + } + if _, ok := c.active[bindingKey(next)]; !ok { + t.Fatal("stale controller withdrawal removed replacement") + } +} + +// Embedding the narrow interface deliberately hides optional attestation. +type backendWithoutAttestation struct{ Backend } + +func TestBindingFailureWithoutReadOnlyAttestationSuppressesProtection(t *testing.T) { + ctx := context.Background() + c, b, w, _ := setup(t, policy("policy", "ns")) + if err := c.SetBindings(ctx, []Binding{binding()}); err != nil { + t.Fatal(err) + } + if err := c.InitialSync(ctx); err != nil { + t.Fatal(err) + } + c.backend = backendWithoutAttestation{b} + replacements, attestations := b.replacements, b.attestations + if err := c.ReportBindingFailure(ctx, errors.New("discovery unavailable")); err == nil { + t.Fatal("outage hidden") + } + if err := c.Reconcile(ctx); err == nil { + t.Fatal("outage cleared") + } + if b.replacements != replacements || b.attestations != attestations || b.withdrawals != 0 || len(c.active) != 1 { + t.Fatal("missing attestation changed retained ownership") + } + if !c.active[bindingKey(binding())].protectionUnconfirmed || len(w.reports["policy"].Status.Conditions) != 1 { + t.Fatal("unsupported attestation advertised protection") + } + if err := c.SetBindings(ctx, []Binding{binding()}); err != nil { + t.Fatal(err) + } + if c.active[bindingKey(binding())].protectionUnconfirmed || len(w.reports["policy"].Status.Conditions) != 2 { + t.Fatal("authoritative reconciliation failed to restore protection") + } +} diff --git a/pkg/sandboxpolicy/exporter.go b/pkg/sandboxpolicy/exporter.go new file mode 100644 index 0000000000..5f3d71b821 --- /dev/null +++ b/pkg/sandboxpolicy/exporter.go @@ -0,0 +1,295 @@ +package sandboxpolicy + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + + mapset "github.com/deckarep/golang-set/v2" + "github.com/goradd/maps" + gadgettypes "github.com/inspektor-gadget/inspektor-gadget/pkg/types" + "github.com/kubescape/node-agent/pkg/containerwatcher" + "github.com/kubescape/node-agent/pkg/ebpf/events" + "github.com/kubescape/node-agent/pkg/utils" +) + +// VerdictEvent is an exec authorization attempt, NOT an observed process creation. +// It deliberately does not implement utils.ExecEvent. The raw Verdict retains +// policy/rule provenance and audit/deny semantics even after optional enrichment. +// Unknown enrichment fields are absent (FieldPresent=false), not fabricated from +// the target executable. In particular GetError's zero means unavailable, not a +// successful exec syscall: this event describes an LSM decision, not syscall exit. +type VerdictEvent struct { + Verdict Verdict + Binding Binding + extra any +} + +var _ utils.EnrichEvent = (*VerdictEvent)(nil) + +func NewVerdictEvent(verdict Verdict, binding Binding) *VerdictEvent { + verdict.PolicySet = append([]PolicyRevision(nil), verdict.PolicySet...) + verdict.Matches = append([]RuleMatch(nil), verdict.Matches...) + verdict.Argv = append([]string(nil), verdict.Argv...) + if verdict.UID != nil { + value := *verdict.UID + verdict.UID = &value + } + if verdict.FromImage != nil { + value := *verdict.FromImage + verdict.FromImage = &value + } + binding.PodLabels = cloneLabels(binding.PodLabels) + binding.NamespaceLabels = cloneLabels(binding.NamespaceLabels) + return &VerdictEvent{Verdict: verdict, Binding: binding} +} +func (e *VerdictEvent) GetContainerID() string { return e.Verdict.ContainerID } +func (*VerdictEvent) GetEventType() utils.EventType { return utils.SandboxExecVerdictEventType } +func (e *VerdictEvent) GetNamespace() string { return e.Binding.Namespace } +func (e *VerdictEvent) GetPod() string { return e.Binding.PodName } +func (e *VerdictEvent) GetTimestamp() gadgettypes.Time { + if e.Verdict.Time.IsZero() { + return 0 + } + return gadgettypes.Time(e.Verdict.Time.UnixNano()) +} +func (*VerdictEvent) HasDroppedEvents() bool { return false } // loss is exposed by ExporterStats, never invented per record +func (*VerdictEvent) Release() {} +func (*VerdictEvent) GetComm() string { return "" } +func (e *VerdictEvent) GetContainer() string { return e.Binding.ContainerName } +func (*VerdictEvent) GetContainerImage() string { return "" } +func (*VerdictEvent) GetContainerImageDigest() string { return "" } +func (*VerdictEvent) GetError() int64 { return 0 } +func (e *VerdictEvent) GetExtra() any { return e.extra } +func (e *VerdictEvent) SetExtra(extra any) { e.extra = extra } +func (*VerdictEvent) GetGid() *uint32 { return nil } +func (*VerdictEvent) GetHostNetwork() bool { return false } +func (e *VerdictEvent) GetMountNsID() uint64 { return e.Binding.MountNamespace } +func (*VerdictEvent) GetPcomm() string { return "" } +func (e *VerdictEvent) GetPID() uint32 { return e.Verdict.PID } +func (e *VerdictEvent) GetPID64() uint64 { return uint64(e.Verdict.PID) } +func (e *VerdictEvent) GetPodLabels() map[string]string { return cloneLabels(e.Binding.PodLabels) } +func (e *VerdictEvent) GetPpid() uint32 { return e.Verdict.ParentPID } +func (e *VerdictEvent) GetUid() *uint32 { + if e.Verdict.UID == nil { + return nil + } + value := *e.Verdict.UID + return &value +} +func (*VerdictEvent) GetEcsClusterName() string { return "" } +func (*VerdictEvent) GetEcsClusterARN() string { return "" } +func (*VerdictEvent) GetEcsTaskARN() string { return "" } +func (*VerdictEvent) GetEcsTaskFamily() string { return "" } +func (*VerdictEvent) GetEcsTaskDefinitionARN() string { return "" } +func (*VerdictEvent) GetEcsServiceName() string { return "" } +func (*VerdictEvent) GetEcsContainerName() string { return "" } +func (*VerdictEvent) GetEcsContainerARN() string { return "" } +func (*VerdictEvent) GetEcsContainerInstance() string { return "" } +func (*VerdictEvent) GetEcsAvailabilityZone() string { return "" } +func (*VerdictEvent) GetEcsLaunchType() string { return "" } +func (e *VerdictEvent) FieldPresent(name string) bool { + switch name { + case "timestamp": + return !e.Verdict.Time.IsZero() + case "pid": + return e.Verdict.PID != 0 + case "ppid": + return e.Verdict.ParentPID != 0 + case "uid": + return e.Verdict.UID != nil + case "mntns_id": + return e.Binding.MountNamespace != 0 + default: + return false + } +} + +const MaxVerdictQueueCapacity = 65536 + +var ErrVerdictQueueFull = errors.New("sandbox verdict reporting queue is full") +var ErrVerdictExporterStopped = errors.New("sandbox verdict exporter is stopped") + +type ExporterOptions struct { + Capacity int + Receivers *maps.SafeMap[utils.EventType, mapset.Set[containerwatcher.GenericEventReceiver]] + Enricher containerwatcher.TaskBasedEnricher + // ResolveBinding must be a nonblocking cache lookup. Only return identities + // verified for this event's full container ID and cgroup ID, never a current + // container reusing the same short ID. A missing result leaves metadata absent. + ResolveBinding func(Verdict) (Binding, bool) +} + +type ExporterStats struct { + Enqueued uint64 + Delivered uint64 // successful receiver invocations, not kernel decisions + Dropped uint64 // full queue, cancellation or stopped exporter + ReceiverErrors uint64 + EnrichmentErrors uint64 +} + +// VerdictExporter separates bounded, best-effort telemetry from kernel decisions. +// ReportVerdict never calls receivers/enrichers and never changes enforcement. +// Start exactly one Run goroutine; register receivers under the distinct verdict +// event type, not ExecveEventType. Void receiver/enricher APIs cannot be cancelled +// mid-call, so implementations must return promptly; a slow consumer fills this +// queue and drops telemetry rather than blocking the enforcement producer. +type VerdictExporter struct { + options ExporterOptions + queue chan *VerdictEvent + mu sync.Mutex + stopped bool + started atomic.Bool + enqueued atomic.Uint64 + delivered atomic.Uint64 + dropped atomic.Uint64 + receiverErrors atomic.Uint64 + enrichmentErrors atomic.Uint64 +} + +var _ VerdictReceiver = (*VerdictExporter)(nil) + +func NewVerdictExporter(options ExporterOptions) (*VerdictExporter, error) { + if options.Capacity <= 0 || options.Capacity > MaxVerdictQueueCapacity { + return nil, fmt.Errorf("verdict queue capacity must be between 1 and %d", MaxVerdictQueueCapacity) + } + return &VerdictExporter{options: options, queue: make(chan *VerdictEvent, options.Capacity)}, nil +} +func (e *VerdictExporter) ReportVerdict(ctx context.Context, verdict Verdict) error { + binding := Binding{} + if e.options.ResolveBinding != nil { + resolved, ok := e.options.ResolveBinding(verdict) + if ok && resolved.ContainerID == verdict.ContainerID && resolved.CgroupID == verdict.CgroupID { + binding = resolved + } + } + event := NewVerdictEvent(verdict, binding) + e.mu.Lock() + defer e.mu.Unlock() + if e.stopped { + e.dropped.Add(1) + return ErrVerdictExporterStopped + } + if err := ctx.Err(); err != nil { + e.dropped.Add(1) + return err + } + select { + case e.queue <- event: + e.enqueued.Add(1) + return nil + default: + e.dropped.Add(1) + return ErrVerdictQueueFull + } +} +func (e *VerdictExporter) Stats() ExporterStats { + return ExporterStats{e.enqueued.Load(), e.delivered.Load(), e.dropped.Load(), e.receiverErrors.Load(), e.enrichmentErrors.Load()} +} +func (e *VerdictExporter) Run(ctx context.Context) error { + if !e.started.CompareAndSwap(false, true) { + return fmt.Errorf("verdict exporter Run may only be called once") + } + defer func() { + e.mu.Lock() + defer e.mu.Unlock() + e.stopped = true + for { + select { + case <-e.queue: + e.dropped.Add(1) + default: + return + } + } + }() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case event := <-e.queue: + if err := ctx.Err(); err != nil { + e.dropped.Add(1) + return err + } + e.deliver(ctx, event) + } + } +} +func (e *VerdictExporter) deliver(ctx context.Context, event *VerdictEvent) { + if e.options.Enricher == nil { + e.dispatch(ctx, event) + return + } + done := make(chan struct{}) + var once sync.Once + callback := func(_ utils.K8sEvent, _ string, _ uint32) { + once.Do(func() { + defer close(done) + if ctx.Err() != nil { + e.dropped.Add(1) + return + } + e.dispatch(ctx, event) + }) + } + // No SYS_FORK hint: an allowed/audited attempt is not evidence of process + // creation. Enrichment may attach extra context but cannot replace the verdict. + if err := guardReceiver(func() error { + e.options.Enricher.SubmitEnrichmentTask(event, nil, callback, event.GetContainerID(), event.GetPID()) + return nil + }); err != nil { + e.enrichmentErrors.Add(1) + callback(event, event.GetContainerID(), event.GetPID()) + } + select { + case <-done: + case <-ctx.Done(): + once.Do(func() { e.dropped.Add(1); close(done) }) + } +} +func (e *VerdictExporter) dispatch(ctx context.Context, event *VerdictEvent) { + if e.options.Receivers == nil { + e.dropped.Add(1) + return + } + receivers, ok := e.options.Receivers.Load(utils.SandboxExecVerdictEventType) + if !ok || receivers.Cardinality() == 0 { + e.dropped.Add(1) + return + } + for _, receiver := range receivers.ToSlice() { + copy := NewVerdictEvent(event.Verdict, event.Binding) + copy.extra = event.extra + err := guardReceiver(func() error { + if enriched, ok := receiver.(containerwatcher.EnrichedEventReceiver); ok { + // No process tree is fabricated and no process manager mutation occurs. + enriched.ReportEnrichedEvent(&events.EnrichedEvent{Event: copy, Timestamp: copy.Verdict.Time, ContainerID: copy.Verdict.ContainerID, PID: copy.Verdict.PID, PPID: copy.Verdict.ParentPID, MountNamespaceID: copy.Binding.MountNamespace}) + return nil + } + if raw, ok := receiver.(containerwatcher.EventReceiver); ok { + raw.ReportEvent(utils.SandboxExecVerdictEventType, copy) + return nil + } + if verdict, ok := receiver.(VerdictReceiver); ok { + return verdict.ReportVerdict(ctx, copy.Verdict) + } + return fmt.Errorf("unsupported sandbox verdict receiver %T", receiver) + }) + if err != nil { + e.receiverErrors.Add(1) + } else { + e.delivered.Add(1) + } + } +} +func guardReceiver(call func() error) (err error) { + defer func() { + if recovered := recover(); recovered != nil { + err = fmt.Errorf("verdict receiver panic: %v", recovered) + } + }() + return call() +} diff --git a/pkg/sandboxpolicy/exporter_test.go b/pkg/sandboxpolicy/exporter_test.go new file mode 100644 index 0000000000..a518bdbd91 --- /dev/null +++ b/pkg/sandboxpolicy/exporter_test.go @@ -0,0 +1,266 @@ +package sandboxpolicy + +import ( + "context" + "errors" + "reflect" + "sync/atomic" + "testing" + "time" + + mapset "github.com/deckarep/golang-set/v2" + "github.com/goradd/maps" + "github.com/kubescape/node-agent/pkg/containerwatcher" + "github.com/kubescape/node-agent/pkg/ebpf/events" + "github.com/kubescape/node-agent/pkg/utils" + api "github.com/kubescape/storage/pkg/apis/sandbox/v1alpha1" +) + +type rawVerdictReceiver struct{ received chan utils.K8sEvent } + +func (r *rawVerdictReceiver) ReportEvent(_ utils.EventType, event utils.K8sEvent) { + r.received <- event +} + +type enrichedVerdictReceiver struct{ received chan *events.EnrichedEvent } + +func (r *enrichedVerdictReceiver) ReportEnrichedEvent(event *events.EnrichedEvent) { + r.received <- event +} + +type failingVerdictReceiver struct{ calls atomic.Int32 } + +func (r *failingVerdictReceiver) ReportVerdict(context.Context, Verdict) error { + r.calls.Add(1) + return errors.New("export unavailable") +} + +type panicVerdictReceiver struct{} + +func (*panicVerdictReceiver) ReportEvent(utils.EventType, utils.K8sEvent) { panic("consumer bug") } + +type fakeVerdictEnricher struct { + event utils.EnrichEvent + syscalls []uint64 + calls int + callback containerwatcher.ResultCallback + delayed bool +} + +func (f *fakeVerdictEnricher) SubmitEnrichmentTask(event utils.EnrichEvent, syscalls []uint64, callback containerwatcher.ResultCallback, _ string, _ uint32) { + f.event = event + f.syscalls = syscalls + f.calls++ + f.callback = callback + if !f.delayed { + event.SetExtra("stack-context") + callback(event, "must-not-replace-container", 99999) + } +} + +func receiverMap(receivers ...containerwatcher.GenericEventReceiver) *maps.SafeMap[utils.EventType, mapset.Set[containerwatcher.GenericEventReceiver]] { + result := &maps.SafeMap[utils.EventType, mapset.Set[containerwatcher.GenericEventReceiver]]{} + result.Set(utils.SandboxExecVerdictEventType, mapset.NewSet[containerwatcher.GenericEventReceiver](receivers...)) + return result +} +func sampleVerdict() Verdict { + uid := uint32(1000) + image := false + return Verdict{Time: time.Unix(1710000000, 1234), Policy: PolicyIdentity{Kind: "SandboxPolicy", Namespace: "ns", Name: "policy", UID: "policy-uid", Generation: 3}, Revision: "revision", Rule: "rule", Origin: "authored", Mode: api.RuleEnforce, Verdict: api.ActionDeny, Reason: "matched deny", ContainerID: "container", CgroupID: 12, PID: 41, ParentPID: 40, UID: &uid, ActorPath: "/bin/actor", TargetPath: "/bin/target", Argv: []string{"target", "--flag"}, FromImage: &image} +} +func TestVerdictEventRetainsKnownMetadataWithoutInventingExec(t *testing.T) { + v := sampleVerdict() + b := binding() + b.MountNamespace = 123 + event := NewVerdictEvent(v, b) + if event.GetEventType() != utils.SandboxExecVerdictEventType || event.GetEventType() == utils.ExecveEventType { + t.Fatal("verdict classified as successful exec observation") + } + if _, ok := any(event).(utils.ExecEvent); ok { + t.Fatal("attempt must not implement observer ExecEvent") + } + if !reflect.DeepEqual(event.Verdict, v) || event.GetNamespace() != "ns" || event.GetPod() != "pod" || event.GetContainer() != "app" || event.GetMountNsID() != 123 || event.GetPID() != 41 || event.GetPpid() != 40 || *event.GetUid() != 1000 { + t.Fatalf("lost correlation: %+v", event) + } + if event.FieldPresent("comm") || event.FieldPresent("error") || event.FieldPresent("gid") || event.GetComm() != "" || event.GetGid() != nil || event.GetContainerImage() != "" { + t.Fatal("unknown values fabricated") + } + if !event.FieldPresent("uid") || !event.FieldPresent("pid") { + t.Fatal("known fields hidden") + } + v.Argv[0] = "mutated" + *v.UID = 2 + *v.FromImage = true + b.PodLabels["app"] = "mutated" + if event.Verdict.Argv[0] != "target" || *event.GetUid() != 1000 || *event.Verdict.FromImage || event.GetPodLabels()["app"] != "test" { + t.Fatal("event aliases producer memory") + } +} + +func TestDeniedAuditAndAllowedEventsReachPublicReceivers(t *testing.T) { + raw := &rawVerdictReceiver{make(chan utils.K8sEvent, 3)} + enriched := &enrichedVerdictReceiver{make(chan *events.EnrichedEvent, 3)} + observe := &rawVerdictReceiver{make(chan utils.K8sEvent, 1)} + receivers := receiverMap(raw, enriched) + receivers.Set(utils.ExecveEventType, mapset.NewSet[containerwatcher.GenericEventReceiver](observe)) + exporter, err := NewVerdictExporter(ExporterOptions{Capacity: 4, Receivers: receivers, ResolveBinding: func(Verdict) (Binding, bool) { return binding(), true }}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- exporter.Run(ctx) }() + defer func() { cancel(); <-done }() + for _, test := range []struct { + action api.Action + mode api.RuleMode + would bool + }{{api.ActionDeny, api.RuleEnforce, false}, {api.ActionDeny, api.RuleAudit, true}, {api.ActionAllow, api.RuleEnforce, false}} { + v := sampleVerdict() + v.Verdict = test.action + v.Mode = test.mode + v.WouldDeny = test.would + if err := exporter.ReportVerdict(ctx, v); err != nil { + t.Fatal(err) + } + select { + case event := <-raw.received: + if !reflect.DeepEqual(event.(*VerdictEvent).Verdict, v) { + t.Fatal("raw verdict lost") + } + case <-time.After(3 * time.Second): + t.Fatal("raw receiver did not receive attempt") + } + select { + case event := <-enriched.received: + if !reflect.DeepEqual(event.Event.(*VerdictEvent).Verdict, v) || event.PID != v.PID || event.ContainerID != v.ContainerID || event.ProcessTree.PID != 0 { + t.Fatal("enrichment lost verdict or fabricated process tree") + } + case <-time.After(3 * time.Second): + t.Fatal("enriched receiver did not receive attempt") + } + } + select { + case <-observe.received: + t.Fatal("verdict leaked into observer exec receivers") + default: + } +} + +func TestReceiverFailuresCannotChangeVerdictsOrBlockOtherSubscribers(t *testing.T) { + failing := &failingVerdictReceiver{} + raw := &rawVerdictReceiver{make(chan utils.K8sEvent, 1)} + exporter, err := NewVerdictExporter(ExporterOptions{Capacity: 1, Receivers: receiverMap(failing, &panicVerdictReceiver{}, raw)}) + if err != nil { + t.Fatal(err) + } + original := sampleVerdict() + exporter.dispatch(context.Background(), NewVerdictEvent(original, binding())) + if failing.calls.Load() != 1 || exporter.Stats().ReceiverErrors != 2 || exporter.Stats().Delivered != 1 { + t.Fatalf("errors not isolated/reported: %+v", exporter.Stats()) + } + if got := (<-raw.received).(*VerdictEvent); !reflect.DeepEqual(got.Verdict, original) { + t.Fatal("receiver failures changed enforcement verdict") + } +} + +func TestOptionalEnrichmentDoesNotFabricateForkOrReplaceVerdict(t *testing.T) { + enricher := &fakeVerdictEnricher{} + receiver := &enrichedVerdictReceiver{make(chan *events.EnrichedEvent, 1)} + exporter, err := NewVerdictExporter(ExporterOptions{Capacity: 1, Receivers: receiverMap(receiver), Enricher: enricher}) + if err != nil { + t.Fatal(err) + } + v := sampleVerdict() + exporter.deliver(context.Background(), NewVerdictEvent(v, binding())) + got := <-receiver.received + if enricher.calls != 1 || len(enricher.syscalls) != 0 || got.PID != v.PID || got.ContainerID != v.ContainerID || !reflect.DeepEqual(got.Event.(*VerdictEvent).Verdict, v) || got.Event.(utils.EnrichEvent).GetExtra() != "stack-context" { + t.Fatal("enricher invented fork or replaced authoritative attempt metadata") + } +} + +func TestQueueAndShutdownLossAreExplicit(t *testing.T) { + exporter, err := NewVerdictExporter(ExporterOptions{Capacity: 1}) + if err != nil { + t.Fatal(err) + } + v := sampleVerdict() + if err := exporter.ReportVerdict(context.Background(), v); err != nil { + t.Fatal(err) + } + if err := exporter.ReportVerdict(context.Background(), v); !errors.Is(err, ErrVerdictQueueFull) { + t.Fatalf("expected nonblocking overflow: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := exporter.Run(ctx); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + if err := exporter.ReportVerdict(context.Background(), v); !errors.Is(err, ErrVerdictExporterStopped) { + t.Fatal("accepted after shutdown") + } + if stats := exporter.Stats(); stats.Enqueued != 1 || stats.Dropped != 3 { + t.Fatalf("loss not counted: %+v", stats) + } + if v.Verdict != api.ActionDeny || v.Mode != api.RuleEnforce { + t.Fatal("queue state changed verdict") + } +} + +func TestMissingBindingDoesNotUseReusedIdentity(t *testing.T) { + wrong := binding() + wrong.CgroupID++ + exporter, err := NewVerdictExporter(ExporterOptions{Capacity: 1, ResolveBinding: func(Verdict) (Binding, bool) { return wrong, true }}) + if err != nil { + t.Fatal(err) + } + if err := exporter.ReportVerdict(context.Background(), sampleVerdict()); err != nil { + t.Fatal(err) + } + got := <-exporter.queue + if got.GetNamespace() != "" || got.GetPod() != "" || got.GetMountNsID() != 0 || got.GetContainerID() != "container" { + t.Fatal("reused runtime identity incorrectly enriched") + } +} + +func TestCancelledPendingEnrichmentCountsLossOnce(t *testing.T) { + enricher := &fakeVerdictEnricher{delayed: true} + receiver := &rawVerdictReceiver{make(chan utils.K8sEvent, 1)} + exporter, err := NewVerdictExporter(ExporterOptions{Capacity: 1, Receivers: receiverMap(receiver), Enricher: enricher}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + exporter.deliver(ctx, NewVerdictEvent(sampleVerdict(), binding())) + enricher.callback(enricher.event, "container", 41) + if exporter.Stats().Dropped != 1 || exporter.Stats().Delivered != 0 { + t.Fatalf("late callback double-counted or delivered after cancellation: %+v", exporter.Stats()) + } +} + +func TestFullVerdictProvenanceCloneBoundaries(t *testing.T) { + v := sampleVerdict() + v.PolicySet = []PolicyRevision{{Policy: v.Policy, Revision: v.Revision}} + v.Matches = []RuleMatch{{Policy: v.Policy, Name: "original"}} + v.MatchesComplete = true + a, b := &rawVerdictReceiver{received: make(chan utils.K8sEvent, 1)}, &rawVerdictReceiver{received: make(chan utils.K8sEvent, 1)} + exporter, err := NewVerdictExporter(ExporterOptions{Capacity: 1, Receivers: receiverMap(a, b)}) + if err != nil { + t.Fatal(err) + } + if err = exporter.ReportVerdict(context.Background(), v); err != nil { + t.Fatal(err) + } + v.PolicySet[0].Revision = "producer mutation" + v.Matches[0].Name = "producer mutation" + queued := <-exporter.queue + exporter.dispatch(context.Background(), queued) + first := (<-a.received).(*VerdictEvent) + second := (<-b.received).(*VerdictEvent) + first.Verdict.PolicySet[0].Revision = "receiver mutation" + first.Verdict.Matches[0].Name = "receiver mutation" + if second.Verdict.PolicySet[0].Revision != "revision" || second.Verdict.Matches[0].Name != "original" || queued.Verdict.Matches[0].Name != "original" || !second.Verdict.MatchesComplete { + t.Fatal("provenance aliases producer/queue/receiver memory") + } +} diff --git a/pkg/sandboxpolicy/igbackend/README.md b/pkg/sandboxpolicy/igbackend/README.md new file mode 100644 index 0000000000..bda588d433 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/README.md @@ -0,0 +1,65 @@ +# Persistent execution-policy backend + +The public service now uses `NewFull` with `NewNativeFullEngine` and +`NewContainerdFullSource`. The legacy `NewNative` / path-only converter remains +for historical tests, but is not a fallback for unsupported full policies. + +`FullBackend` installs immutable generation provenance before native startup or +publication. First activation follows successful manager startup. Reload uses +`Owner.Update` / `Resume` on the same maps and links, retaining successful policy +UID epochs. Kernel commit is irreversible even if reopening/finalization fails; +reconciliation resumes that generation rather than rolling it back. Whole-exec +gate closure can disrupt concurrent execs. Unsupported inputs are refused. + +One administrator-configured aggregate budget bounds all native owners. Exact +cgroups stay exclusively reserved until actual detach and release. Startup +failures retain partial owners and untransferred leases. Stop must complete +`BeforeStop` before manager cancellation, join real teardown, then call +`ReleaseAfterDetach`; failed cleanup remains owned and retryable. The trusted +containerd client closes only after every lease has been consumed. + +The combined lease pins the exact proc allocation, cgroup, workload mount +namespace, initial-host PID/mount namespaces, stable root and complete lower +image inode index. It authenticates containerd task/image/CRI metadata, never +policy annotations. The protected socket and administrator host mounts are +trusted deployment inputs, not proven authentic merely by their path names. +The supported root/seccomp/capability and immutable overlay constraints apply +even when the current revision has no image predicate, because later revisions +reuse the same owner and origin index. A changed image/root invalidates that +owner, not an in-place index refresh. No inferred helper exemptions exist. + +`FullVerdictCatalog` keeps at most 16 generation-specific decoders, including +active and staged provenance. Evicted/unknown generations are dropped rather +than attributed to a newer plan. Full verdicts use the kernel's typed four-word +match bitmap, with explicit incomplete matches for operational failures. UID, +argv, actor and image facts remain absent when the event does not supply them. +Exporter failures do not change kernel decisions. + +## Verification boundary + +Compiler, catalogue, transaction and lifecycle unit/race tests do not prove the +combined node-agent native path. The matching IG owner has isolated real-kernel +native OCI proof, and constrained containerd image discovery has separate proof. +The combined native/containerd1.7.30/runc1.3.4 CRI fixture now passes on the +pinned amd64 kernel, including generated managed files, shared memory, readonly +cgroup2, 13 paths, same-owner reload and actual public events. It does not run a +kubelet/API server or establish generic runtime/kernel compatibility. The exact supported kernel BTF gate is intentionally +narrow. Private OTel implementation is outside this public change. + +The expanded IG revision is published and pinned at +`v0.0.0-20261027103046-6b8b24afaf6f`; standalone node-agent targeted tests, race +checks, vet and binary builds pass without a development workspace. The pin is +still on PR #15 rather than main, so the repository merge guard correctly blocks +consumer merging until the upstream change is merged and re-pinned. +No chart version has changed. No image is implicitly downloaded or trusted: the +local OCI archive is privileged administrator-supplied code, and IG skips its +signature verification for explicit local targets. + +## Legacy API + +`NewNative` / `Compile` remain a separate path-only, single-policy API with +immutable candidate replacement and dedicated compatibility tests. Actor/image/ +filesystem/argv/role predicates and multiple policies are rejected there. The +public service never falls back to this API. Legacy events omit richer facts and +only attribute unambiguous decisive rules. Local fake-engine tests do not prove +BPF readiness or kernel enforcement. diff --git a/pkg/sandboxpolicy/igbackend/backend.go b/pkg/sandboxpolicy/igbackend/backend.go new file mode 100644 index 0000000000..0e25bd6e94 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/backend.go @@ -0,0 +1,307 @@ +// Package igbackend owns strict execution-policy gadgets. The public service +// uses the persistent full backend; the explicit legacy API remains separate. +// Unsupported semantics are errors, never silently weakened rules. +package igbackend + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "math" + "sync" + + execpolicy "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/policy" + execruntime "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/runtime" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" +) + +// Engine.Start returns only after native readiness, or after failed-candidate +// cleanup. Every call owns independent maps and links. +type Engine interface { + Start(context.Context, string, execruntime.Config, func(RawVerdict)) (Instance, error) +} +type Instance interface { + Ready() error + Unbind(context.Context, execruntime.ContainerIdentity) error + Stop(context.Context) error +} + +// Lease pins the exact live identity for the entire instance lifetime. +type Lease interface { + Check() error + Close() error +} +type Validator interface { + Acquire(context.Context, sandboxpolicy.Binding) (Lease, error) +} + +type owned struct { + instance Instance + lease Lease +} +type identity struct { + runtime, container, path string + cgroup, mountns uint64 + pid uint32 +} + +func key(b sandboxpolicy.Binding) identity { + return identity{b.Runtime, b.ContainerID, b.CgroupPath, b.CgroupID, b.MountNamespace, b.PID} +} +func native(b sandboxpolicy.Binding) execruntime.ContainerIdentity { + return execruntime.ContainerIdentity{Runtime: b.Runtime, ID: b.ContainerID, CgroupID: b.CgroupID, CgroupPath: b.CgroupPath} +} + +type record struct { + revision string + active *owned + retiring []*owned +} + +type Backend struct { + mu sync.Mutex + engine Engine + validator Validator + receiver sandboxpolicy.VerdictReceiver + onError func(error) + prefix string + sequence uint64 + records map[identity]*record + closed bool +} + +var _ sandboxpolicy.Backend = (*Backend)(nil) + +func New(engine Engine, validator Validator, receiver sandboxpolicy.VerdictReceiver, onError func(error)) (*Backend, error) { + if engine == nil || validator == nil { + return nil, fmt.Errorf("engine and live identity validator are required") + } + var token [16]byte + if _, err := rand.Read(token[:]); err != nil { + return nil, err + } + return &Backend{engine: engine, validator: validator, receiver: receiver, onError: onError, prefix: hex.EncodeToString(token[:]), records: make(map[identity]*record)}, nil +} + +func (b *Backend) Replace(ctx context.Context, binding sandboxpolicy.Binding, plan sandboxpolicy.Plan) (result sandboxpolicy.ReplaceResult, resultErr error) { + b.mu.Lock() + defer b.mu.Unlock() + k := key(binding) + // Attest the old owner even when validation rejects the next candidate before + // startup. Cleanup ownership is not evidence that its LSM links still protect. + if current := b.records[k]; current != nil && current.active != nil { + result.ProtectionUnconfirmed = errors.Join(current.active.lease.Check(), current.active.instance.Ready()) != nil + } + defer func() { + if result.Committed { + return + } + if current := b.records[k]; current != nil && current.active != nil { + health := errors.Join(current.active.lease.Check(), current.active.instance.Ready()) + result.ProtectionUnconfirmed = health != nil + if health != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("existing enforcement protection cannot be confirmed: %w", health)) + } + } + }() + if b.closed { + result.ProtectionUnconfirmed = true + return result, fmt.Errorf("backend is closed") + } + // A failed candidate may temporarily need a record while cleanup is pending, + // but a fully cleaned failed attempt must not leave a durable tombstone. + defer func() { + if state := b.records[k]; state != nil && state.active == nil && len(state.retiring) == 0 { + delete(b.records, k) + } + }() + r := b.records[k] + if r != nil { + // Finish previous cleanup before a new plan can be installed. Never restart + // an already committed same-revision instance just to retry cleanup. + err := cleanup(ctx, r) + if r.active != nil && r.revision == plan.Revision { + health := errors.Join(r.active.lease.Check(), r.active.instance.Ready()) + result = sandboxpolicy.ReplaceResult{Committed: true, ActiveRevision: r.revision, ProtectionUnconfirmed: health != nil} + if health != nil { + health = fmt.Errorf("existing enforcement protection cannot be confirmed: %w", health) + } + return result, errors.Join(err, health) + } + if err != nil { + return result, err + } + if r.active == nil && len(r.retiring) == 0 { + delete(b.records, k) + r = nil + } + } + ownedIdentities := 0 + for other, state := range b.records { + if state.active == nil && len(state.retiring) == 0 { + delete(b.records, other) + continue + } + ownedIdentities++ + if other != k && other.cgroup == k.cgroup { + var health error + if state.active != nil { + health = errors.Join(state.active.lease.Check(), state.active.instance.Ready()) + } + result.ProtectionUnconfirmed = health != nil + return result, errors.Join(fmt.Errorf("cgroup identity is still owned by another binding"), health) + } + } + // Retiring-only identities still own resources. Replacement of an existing + // identity is allowed at capacity, but admitting a new identity is not. + if r == nil && ownedIdentities >= execpolicy.KernelMaxBindings { + return result, fmt.Errorf("owned binding capacity exceeded (%d)", execpolicy.KernelMaxBindings) + } + // Check before incrementing: exhaustion remains sticky and never reuses a + // generation, even when the last candidate failed before commit. + if b.sequence == math.MaxUint64 { + return result, fmt.Errorf("generation exhausted") + } + b.sequence++ + plan.Policies = append([]sandboxpolicy.CompiledPolicy(nil), plan.Policies...) + for i := range plan.Policies { + plan.Policies[i].Exec = *plan.Policies[i].Exec.DeepCopy() + } + config, err := Compile(binding, plan, b.sequence) + if err != nil { + return result, err + } + lease, err := b.validator.Acquire(ctx, binding) + if err != nil { + return result, err + } + id := fmt.Sprintf("sandbox-exec-%s-%d", b.prefix, b.sequence) + candidate, err := b.engine.Start(ctx, id, config, func(raw RawVerdict) { + verdict, err := DecodeVerdict(binding, plan, config.Entries[0].Generation, raw) + if err == nil && b.receiver != nil { + err = b.receiver.ReportVerdict(context.Background(), verdict) + } + if err != nil && b.onError != nil { + b.onError(err) + } + }) + if err != nil { + return result, errors.Join(err, lease.Close()) + } + next := &owned{instance: candidate, lease: lease} + if err := errors.Join(lease.Check(), candidate.Ready()); err != nil { + if r == nil { + r = &record{} + b.records[k] = r + } + r.retiring = append(r.retiring, next) + return result, errors.Join(err, cleanup(ctx, r)) + } + if r == nil { + r = &record{} + b.records[k] = r + } + if r.active != nil { + r.retiring = append(r.retiring, r.active) + } + r.active, r.revision = next, plan.Revision // The new independent instance is now authoritative. + result = sandboxpolicy.ReplaceResult{Committed: true, ActiveRevision: plan.Revision} + return result, cleanup(ctx, r) +} + +func (b *Backend) Withdraw(ctx context.Context, binding sandboxpolicy.Binding) (sandboxpolicy.WithdrawResult, error) { + b.mu.Lock() + defer b.mu.Unlock() + k := key(binding) + r := b.records[k] + if r == nil { + return sandboxpolicy.WithdrawResult{Committed: true}, nil + } + // Retired instances may still enforce during failed cleanup. Do not report + // withdrawal committed while any of those old links may protect the binding. + if err := cleanup(ctx, r); err != nil { + return sandboxpolicy.WithdrawResult{}, err + } + if r.active != nil { + if err := r.active.instance.Unbind(ctx, native(binding)); err != nil { + return sandboxpolicy.WithdrawResult{}, err + } + r.retiring = append(r.retiring, r.active) + r.active = nil + } + result := sandboxpolicy.WithdrawResult{Committed: true} + if err := cleanup(ctx, r); err != nil { + return result, err + } + delete(b.records, k) + return result, nil +} + +func cleanup(ctx context.Context, r *record) error { + var pending []*owned + var errs []error + for _, old := range r.retiring { + if err := old.instance.Stop(ctx); err != nil { + pending = append(pending, old) + errs = append(errs, err) + continue + } + // References outlive links, including when stopping was canceled earlier. + if err := old.lease.Close(); err != nil { + pending = append(pending, old) + errs = append(errs, err) + } + } + r.retiring = pending + return errors.Join(errs...) +} + +// Compile is an explicit interim legacy converter, not the full exec backend. +func Compile(binding sandboxpolicy.Binding, plan sandboxpolicy.Plan, generation uint64) (execruntime.Config, error) { + var config execruntime.Config + if plan.Revision == "" || len(plan.Policies) != 1 { + return config, fmt.Errorf("legacy exec backend requires exactly one policy and a revision") + } + p := plan.Policies[0] + if p.Mode != "audit" && p.Mode != "enforce" { + return config, fmt.Errorf("legacy backend requires an active policy") + } + if p.RootRole != "" && p.RootRole != "agent" { + return config, fmt.Errorf("legacy exec backend cannot represent root roles") + } + doc := execpolicy.Document{Version: 1, Binding: execpolicy.Binding{Container: &execpolicy.Container{Runtime: binding.Runtime, ID: binding.ContainerID}}, Policy: execpolicy.Policy{Mode: string(p.Mode), Exec: execpolicy.Section{Default: string(p.Exec.Default)}}} + for _, r := range p.Exec.Rules { + if r.Actor != nil || len(r.Target.PathClass) > 0 || r.Target.FromImage != nil || len(r.Target.FSType) > 0 || r.Target.Argv != nil || r.Target.SetRole != "" { + return config, fmt.Errorf("rule %q contains predicates unsupported by legacy exec backend", r.Name) + } + doc.Policy.Exec.Rules = append(doc.Policy.Exec.Rules, execpolicy.Rule{Name: r.Name, Target: execpolicy.Target{Path: append([]string(nil), r.Target.Path...)}, Action: string(r.Action), Mode: string(r.Mode), Origin: r.Origin}) + } + entries, err := execpolicy.CompileKernel(doc, generation) + if err != nil { + return config, err + } + config.Entries = entries + // No discovery Targets: the exact prevalidated identity is the only member. + config.InitialBindings = []execruntime.ContainerIdentity{native(binding)} + return config, nil +} + +// Close removes this backend's exact bindings and finishes their cleanup. Call +// again after an error; it does not stop unrelated instances on the runtime. +func (b *Backend) Close(ctx context.Context) error { + b.mu.Lock() + b.closed = true + keys := make([]identity, 0, len(b.records)) + for k := range b.records { + keys = append(keys, k) + } + b.mu.Unlock() + var errs []error + for _, k := range keys { + _, err := b.Withdraw(ctx, sandboxpolicy.Binding{Runtime: k.runtime, ContainerID: k.container, CgroupPath: k.path, CgroupID: k.cgroup, MountNamespace: k.mountns, PID: k.pid}) + errs = append(errs, err) + } + return errors.Join(errs...) +} diff --git a/pkg/sandboxpolicy/igbackend/backend_test.go b/pkg/sandboxpolicy/igbackend/backend_test.go new file mode 100644 index 0000000000..d9ea4165fa --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/backend_test.go @@ -0,0 +1,470 @@ +package igbackend + +import ( + "context" + "errors" + "fmt" + "math" + "strings" + "testing" + + execpolicy "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/policy" + execruntime "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/runtime" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" + api "github.com/kubescape/storage/pkg/apis/sandbox/v1alpha1" +) + +var failure = errors.New("injected failure") + +type fakeLease struct { + checkErr error + closed int +} + +func (l *fakeLease) Check() error { return l.checkErr } +func (l *fakeLease) Close() error { l.closed++; return nil } + +type fakeValidator struct { + leases []*fakeLease + err error + nextCheck error +} + +func (v *fakeValidator) Acquire(context.Context, sandboxpolicy.Binding) (Lease, error) { + if v.err != nil { + return nil, v.err + } + l := &fakeLease{checkErr: v.nextCheck} + v.leases = append(v.leases, l) + return l, nil +} + +type fakeInstance struct { + readyErr, stopErr, unbindErr error + stops, unbound int +} + +func (i *fakeInstance) Ready() error { return i.readyErr } +func (i *fakeInstance) Stop(context.Context) error { i.stops++; return i.stopErr } +func (i *fakeInstance) Unbind(context.Context, execruntime.ContainerIdentity) error { + i.unbound++ + return i.unbindErr +} + +type fakeEngine struct { + instances []*fakeInstance + configs []execruntime.Config + startErr, errorAfterStart error + emit func(RawVerdict) +} + +func (e *fakeEngine) Start(_ context.Context, _ string, c execruntime.Config, emit func(RawVerdict)) (Instance, error) { + if e.startErr != nil { + return nil, e.startErr + } + i := &fakeInstance{readyErr: e.errorAfterStart} + e.instances = append(e.instances, i) + e.configs = append(e.configs, c) + e.emit = emit + return i, nil +} +func fixture(t *testing.T) (*Backend, *fakeEngine, *fakeValidator, sandboxpolicy.Binding, sandboxpolicy.Plan) { + t.Helper() + e, v := &fakeEngine{}, &fakeValidator{} + b, err := New(e, v, nil, nil) + if err != nil { + t.Fatal(err) + } + binding := sandboxpolicy.Binding{Runtime: "containerd", ContainerID: "full-container-id", CgroupID: 42, CgroupPath: "/kubepods/target", PID: 123, MountNamespace: 456} + plan := sandboxpolicy.Plan{Revision: "one", Policies: []sandboxpolicy.CompiledPolicy{{Mode: api.ModeEnforce, RootRole: "agent", Exec: api.ExecSection{Default: api.ActionDeny, Rules: []api.ExecRule{{Rule: api.Rule{Name: "allow-tool", Action: api.ActionAllow, Origin: "test"}, Target: api.ExecTarget{PathTarget: api.PathTarget{Path: []string{"/bin/tool"}}}}}}}}} + return b, e, v, binding, plan +} +func TestReplaceCommitAndCleanupRetry(t *testing.T) { + b, e, v, binding, p := fixture(t) + ctx := context.Background() + first, err := b.Replace(ctx, binding, p) + if err != nil || !first.Committed { + t.Fatal(first, err) + } + if len(e.configs[0].Targets) != 0 || len(e.configs[0].InitialBindings) != 1 { + t.Fatal("not exact initial binding") + } + e.startErr = failure + p.Revision = "two" + failed, err := b.Replace(ctx, binding, p) + if !errors.Is(err, failure) || failed.Committed || e.instances[0].stops != 0 { + t.Fatal(failed, err) + } + e.startErr = nil + e.instances[0].stopErr = failure + next, err := b.Replace(ctx, binding, p) + if !errors.Is(err, failure) || !next.Committed || next.ActiveRevision != "two" { + t.Fatal(next, err) + } + if v.leases[0].closed != 0 { + t.Fatal("released identity before links stopped") + } + again, err := b.Replace(ctx, binding, p) + if !again.Committed || err == nil || len(e.instances) != 2 { + t.Fatal("same revision restarted") + } + e.instances[0].stopErr = nil + if _, err := b.Replace(ctx, binding, p); err != nil { + t.Fatal(err) + } + if len(e.instances) != 2 || v.leases[0].closed != 1 { + t.Fatal("cleanup did not finish without restart") + } +} +func TestWithdrawalCommitAndStaleIdentity(t *testing.T) { + b, e, _, binding, p := fixture(t) + ctx := context.Background() + b.Replace(ctx, binding, p) + stale := binding + stale.ContainerID = "stale" + if r, err := b.Withdraw(ctx, stale); err != nil || !r.Committed || e.instances[0].unbound != 0 { + t.Fatal(r, err) + } + e.instances[0].unbindErr = failure + if r, err := b.Withdraw(ctx, binding); err == nil || r.Committed { + t.Fatal(r, err) + } + e.instances[0].unbindErr = nil + e.instances[0].stopErr = failure + if r, err := b.Withdraw(ctx, binding); err == nil || !r.Committed { + t.Fatal(r, err) + } + if r, err := b.Replace(ctx, binding, p); err == nil || r.Committed { + t.Fatal("reinstalled while cleanup pending", r, err) + } + e.instances[0].stopErr = nil + if r, err := b.Withdraw(ctx, binding); err != nil || !r.Committed { + t.Fatal(r, err) + } + if r, err := b.Replace(ctx, stale, p); err != nil || !r.Committed { + t.Fatal(r, err) + } + if r, err := b.Withdraw(ctx, binding); err != nil || !r.Committed || e.instances[1].unbound != 0 { + t.Fatal("old deletion touched new identity", r, err) + } +} +func TestIdentityAndReadinessFailure(t *testing.T) { + b, e, v, binding, p := fixture(t) + ctx := context.Background() + v.err = failure + if r, err := b.Replace(ctx, binding, p); err == nil || r.Committed || len(e.instances) != 0 { + t.Fatal(r, err) + } + v.err = nil + v.nextCheck = failure + if r, err := b.Replace(ctx, binding, p); err == nil || r.Committed || e.instances[0].stops != 1 || v.leases[0].closed != 1 { + t.Fatal(r, err) + } + v.nextCheck = nil + if _, err := b.Replace(ctx, binding, p); err != nil { + t.Fatal(err) + } + changed := binding + changed.PID++ + if r, err := b.Replace(ctx, changed, p); err == nil || r.Committed { + t.Fatal("cgroup reused without retiring old identity") + } + e.instances[1].readyErr = failure + if r, err := b.Replace(ctx, binding, p); err == nil || !r.Committed { + t.Fatal("lost readiness not reported", r, err) + } + if err := b.Close(ctx); err != nil { + t.Fatal(err) + } + if r, err := b.Replace(ctx, binding, p); err == nil || r.Committed { + t.Fatal("closed backend restarted") + } +} +func TestCompileRejectsNonrepresentableSemantics(t *testing.T) { + _, _, _, binding, p := fixture(t) + cases := map[string]func(*sandboxpolicy.Plan){ + "multiple": func(p *sandboxpolicy.Plan) { p.Policies = append(p.Policies, p.Policies[0]) }, + "root-role": func(p *sandboxpolicy.Plan) { p.Policies[0].RootRole = "admin" }, + "off": func(p *sandboxpolicy.Plan) { p.Policies[0].Mode = api.ModeOff }, + "missing-default": func(p *sandboxpolicy.Plan) { p.Policies[0].Exec.Default = "" }, + "actor": func(p *sandboxpolicy.Plan) { p.Policies[0].Exec.Rules[0].Actor = &api.Actor{} }, + "class": func(p *sandboxpolicy.Plan) { p.Policies[0].Exec.Rules[0].Target.PathClass = []string{"unresolved"} }, + "from-image": func(p *sandboxpolicy.Plan) { f := false; p.Policies[0].Exec.Rules[0].Target.FromImage = &f }, + "fstype": func(p *sandboxpolicy.Plan) { p.Policies[0].Exec.Rules[0].Target.FSType = []string{"overlay"} }, + "argv": func(p *sandboxpolicy.Plan) { p.Policies[0].Exec.Rules[0].Target.Argv = &api.ArgvPredicate{} }, + "role": func(p *sandboxpolicy.Plan) { p.Policies[0].Exec.Rules[0].Target.SetRole = "admin" }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + _, _, _, _, fresh := fixture(t) + mutate(&fresh) + if _, err := Compile(binding, fresh, 1); err == nil { + t.Fatal("silently weakened policy") + } + }) + } + if _, err := Compile(binding, p, 1); err != nil { + t.Fatal(err) + } +} +func TestVerdictAttribution(t *testing.T) { + _, _, _, binding, p := fixture(t) + r := RawVerdict{Generation: 1, Cgroup: binding.CgroupID, Mode: 2, PathResult: 10, Path: "/bin/tool", PID: 12} + got, err := DecodeVerdict(binding, p, 1, r) + if err != nil || got.Rule != "allow-tool" || got.Origin != "test" || got.UID != nil || got.Argv != nil { + t.Fatal(got, err) + } + p.Policies[0].Exec.Rules = append(p.Policies[0].Exec.Rules, p.Policies[0].Exec.Rules[0]) + got, err = DecodeVerdict(binding, p, 1, r) + if err != nil || got.Rule != "" || got.Origin != "" { + t.Fatal("ambiguous attribution fabricated", got, err) + } + r.Generation = 2 + if _, err := DecodeVerdict(binding, p, 1, r); err == nil { + t.Fatal("stale event accepted") + } + r.Generation = 1 + r.PathResult = -1 + r.Deny = 1 + r.Mode = 1 + got, err = DecodeVerdict(binding, p, 1, r) + if err != nil || got.TargetPath != "" || !got.WouldDeny || got.Reason != "path_resolution_error" { + t.Fatal(got, err) + } +} + +type receiverFunc func(context.Context, sandboxpolicy.Verdict) error + +func (f receiverFunc) ReportVerdict(ctx context.Context, v sandboxpolicy.Verdict) error { + return f(ctx, v) +} +func TestExporterFailureDoesNotAffectEnforcement(t *testing.T) { + b, e, _, binding, p := fixture(t) + calls, reports := 0, 0 + b.receiver = receiverFunc(func(_ context.Context, v sandboxpolicy.Verdict) error { + calls++ + if v.Rule != "allow-tool" { + t.Error("callback plan mutated") + } + return failure + }) + b.onError = func(err error) { + if errors.Is(err, failure) { + reports++ + } + } + if _, err := b.Replace(context.Background(), binding, p); err != nil { + t.Fatal(err) + } + p.Policies[0].Exec.Rules[0].Name = "mutated" + e.emit(RawVerdict{Generation: e.configs[0].Entries[0].Generation, Cgroup: binding.CgroupID, Mode: 2, PathResult: 10, Path: "/bin/tool"}) + if calls != 1 || reports != 1 || e.instances[0].stops != 0 || e.instances[0].unbound != 0 { + t.Fatal("export failure changed enforcement") + } +} + +func TestOwnedIdentityCapacityIncludesRetiringRecords(t *testing.T) { + b, e, v, base, p := fixture(t) + ctx := context.Background() + bindingAt := func(i int) sandboxpolicy.Binding { + binding := base + binding.ContainerID = fmt.Sprintf("container-%d", i) + binding.CgroupID = uint64(i + 1) + binding.CgroupPath = fmt.Sprintf("/containers/%d", i) + return binding + } + for i := 0; i < execpolicy.KernelMaxBindings; i++ { + instance := &fakeInstance{} + owner := &owned{instance: instance, lease: &fakeLease{}} + state := &record{revision: "old"} + if i%2 == 0 { + state.retiring = []*owned{owner} + instance.stopErr = failure + } else { + state.active = owner + } + b.records[key(bindingAt(i))] = state + } + // Empty historical records do not consume capacity and are also reclaimed. + b.records[key(bindingAt(execpolicy.KernelMaxBindings+10))] = &record{} + rejected := bindingAt(execpolicy.KernelMaxBindings) + result, err := b.Replace(ctx, rejected, p) + if err == nil || !strings.Contains(err.Error(), "capacity") || result.Committed || len(e.instances) != 0 || len(v.leases) != 0 || b.sequence != 0 { + t.Fatal("capacity failure prepared a partial candidate", result, err) + } + if len(b.records) != execpolicy.KernelMaxBindings { + t.Fatal("empty record retained") + } + // An existing identity can be replaced without requiring an extra identity slot. + if result, err := b.Replace(ctx, bindingAt(1), p); err != nil || !result.Committed { + t.Fatal(result, err) + } + if len(b.records) != execpolicy.KernelMaxBindings || len(e.instances) != 1 { + t.Fatal("replacement changed identity count") + } + // Pending retired-instance cleanup must continue to occupy its identity slot. + if result, err := b.Withdraw(ctx, bindingAt(0)); err == nil || result.Committed { + t.Fatal(result, err) + } + if _, err := b.Replace(ctx, rejected, p); err == nil { + t.Fatal("retiring-only identity was not counted") + } + retired := b.records[key(bindingAt(0))].retiring[0].instance.(*fakeInstance) + retired.stopErr = nil + if result, err := b.Withdraw(ctx, bindingAt(0)); err != nil || !result.Committed { + t.Fatal(result, err) + } + if result, err := b.Replace(ctx, rejected, p); err != nil || !result.Committed { + t.Fatal("released slot was not reusable", result, err) + } + if len(b.records) != execpolicy.KernelMaxBindings { + t.Fatal("wrong owned count") + } +} + +func TestGenerationExhaustionNeverWraps(t *testing.T) { + b, e, v, binding, p := fixture(t) + ctx := context.Background() + b.sequence = math.MaxUint64 - 1 + if result, err := b.Replace(ctx, binding, p); err != nil || !result.Committed { + t.Fatal(result, err) + } + if e.configs[0].Entries[0].Generation != math.MaxUint64 { + t.Fatal("last generation not used exactly once") + } + // Rechecking an already committed generation needs no new generation. + if result, err := b.Replace(ctx, binding, p); err != nil || !result.Committed { + t.Fatal(result, err) + } + p.Revision = "next" + for i := 0; i < 3; i++ { + result, err := b.Replace(ctx, binding, p) + if err == nil || !strings.Contains(err.Error(), "exhausted") || result.Committed || b.sequence != math.MaxUint64 { + t.Fatal("generation exhaustion was not sticky", result, err) + } + } + if len(e.instances) != 1 || len(v.leases) != 1 || e.instances[0].stops != 0 { + t.Fatal("exhaustion touched the last-good instance") + } +} + +func TestFailedLastGenerationIsNotReused(t *testing.T) { + b, e, v, binding, p := fixture(t) + b.sequence = math.MaxUint64 - 1 + e.startErr = failure + if result, err := b.Replace(context.Background(), binding, p); !errors.Is(err, failure) || result.Committed { + t.Fatal(result, err) + } + e.startErr = nil + for i := 0; i < 2; i++ { + if result, err := b.Replace(context.Background(), binding, p); err == nil || result.Committed || b.sequence != math.MaxUint64 { + t.Fatal(result, err) + } + } + if len(v.leases) != 1 || v.leases[0].closed != 1 || len(b.records) != 0 || len(e.instances) != 0 { + t.Fatal("failed final generation was retried") + } +} + +func TestFailedCandidateChurnReclaimsEmptyRecords(t *testing.T) { + b, e, _, binding, p := fixture(t) + e.errorAfterStart = failure + for i := 0; i < execpolicy.KernelMaxBindings+1; i++ { + binding.ContainerID = fmt.Sprintf("rejected-%d", i) + binding.CgroupID = uint64(i + 1) + result, err := b.Replace(context.Background(), binding, p) + if !errors.Is(err, failure) || result.Committed { + t.Fatal(result, err) + } + if len(b.records) != 0 { + t.Fatalf("failed candidate %d leaked a record", i) + } + } +} + +func TestExistingProtectionHealthIsDistinctFromCleanupOwnership(t *testing.T) { + for _, test := range []struct { + name string + lose func(*fakeEngine, *fakeValidator) + invalid bool + }{{"stopped", func(e *fakeEngine, _ *fakeValidator) { e.instances[0].readyErr = failure }, false}, {"lease lost", func(_ *fakeEngine, v *fakeValidator) { v.leases[0].checkErr = failure }, false}, {"invalid candidate with lost owner", func(e *fakeEngine, _ *fakeValidator) { e.instances[0].readyErr = failure }, true}} { + t.Run(test.name, func(t *testing.T) { + b, e, v, binding, p := fixture(t) + ctx := context.Background() + if _, err := b.Replace(ctx, binding, p); err != nil { + t.Fatal(err) + } + test.lose(e, v) + if test.invalid { + p.Revision = "invalid" + p.Policies[0].RootRole = "unsupported" + } + result, err := b.Replace(ctx, binding, p) + if err == nil || !result.ProtectionUnconfirmed || b.records[key(binding)].active == nil { + t.Fatalf("lost protection treated as healthy ownership: %+v %v", result, err) + } + if test.invalid && result.Committed { + t.Fatal("invalid candidate reported committed") + } + if len(e.instances) != 1 { + t.Fatal("health check started a replacement") + } + }) + } +} + +func TestHealthyRollbackCleanupAndRecoveryStayConfirmed(t *testing.T) { + b, e, v, binding, p := fixture(t) + ctx := context.Background() + if _, err := b.Replace(ctx, binding, p); err != nil { + t.Fatal(err) + } + invalid := p + invalid.Revision = "invalid" + invalid.Policies = append([]sandboxpolicy.CompiledPolicy(nil), p.Policies...) + invalid.Policies[0].RootRole = "unsupported" + result, err := b.Replace(ctx, binding, invalid) + if err == nil || result.Committed || result.ProtectionUnconfirmed { + t.Fatalf("healthy rollback marked lost: %+v %v", result, err) + } + e.instances[0].readyErr = failure + result, err = b.Replace(ctx, binding, p) + if err == nil || !result.ProtectionUnconfirmed { + t.Fatal("external loss not detected") + } + e.instances[0].readyErr = nil + result, err = b.Replace(ctx, binding, p) + if err != nil || result.ProtectionUnconfirmed || !result.Committed { + t.Fatal("successful re-attestation did not clear uncertainty") + } + v.leases[0].checkErr = failure + e.instances[0].stopErr = failure + p.Revision = "replacement" + result, err = b.Replace(ctx, binding, p) + if err == nil || !result.Committed || result.ProtectionUnconfirmed || result.ActiveRevision != "replacement" { + t.Fatalf("healthy new committed instance confused with lost old owner: %+v %v", result, err) + } + if len(e.instances) != 2 { + t.Fatal("replacement not started") + } + e.instances[0].stopErr = nil + result, err = b.Replace(ctx, binding, p) + if err != nil || result.ProtectionUnconfirmed || !result.Committed { + t.Fatal("cleanup/recovery did not converge") + } +} + +func TestChangedRuntimeIdentityDoesNotHideLostPriorOwner(t *testing.T) { + b, _, v, binding, p := fixture(t) + ctx := context.Background() + if _, err := b.Replace(ctx, binding, p); err != nil { + t.Fatal(err) + } + v.leases[0].checkErr = failure + replacement := binding + replacement.PID++ + result, err := b.Replace(ctx, replacement, p) + if err == nil || result.Committed || !result.ProtectionUnconfirmed || b.records[key(binding)].active == nil { + t.Fatalf("identity conflict hid lost prior protection: %+v %v", result, err) + } +} diff --git a/pkg/sandboxpolicy/igbackend/controller_identity_test.go b/pkg/sandboxpolicy/igbackend/controller_identity_test.go new file mode 100644 index 0000000000..7a36c48b59 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/controller_identity_test.go @@ -0,0 +1,296 @@ +package igbackend + +import ( + "context" + "errors" + "testing" + + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullpolicy" + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullruntime" + + "github.com/kubescape/node-agent/pkg/sandboxpolicy" + api "github.com/kubescape/storage/pkg/apis/sandbox/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic/fake" + ktesting "k8s.io/client-go/testing" +) + +// Use the real controller and backend ownership maps. A controller fake keyed +// with the controller's own helper cannot detect mismatched identity domains. +type controllerIdentitySource struct{ leases []*fullTestLease } + +func (s *controllerIdentitySource) Prepare(context.Context, sandboxpolicy.Binding) (FullBindingLease, error) { + l := &fullTestLease{} + s.leases = append(s.leases, l) + return l, nil +} + +type controllerIdentityInstance struct { + fullTestInstance + health error + checks int + holdUncommitted bool + previousProgram *fullpolicy.KernelProgram + aborts int + abortError error +} + +func (i *controllerIdentityInstance) Check(ctx context.Context) error { + i.checks++ + return errors.Join(i.health, i.fullTestInstance.Check(ctx)) +} + +// Model a failed drain that leaves an uncommitted candidate staged. Resume +// would publish it, whereas AbortPending must preserve the active generation. +func (i *controllerIdentityInstance) Update(ctx context.Context, program *fullpolicy.KernelProgram) (fullruntime.PublicationResult, error) { + if !i.holdUncommitted { + return i.fullTestInstance.Update(ctx, program) + } + i.updates++ + i.previousProgram = i.program + i.program = program + header, _ := fullpolicy.Decode[fullpolicy.KernelHeader](program.Entries[0]) + i.pending = header.Generation + return fullruntime.PublicationResult{}, errors.New("drain incomplete before commit") +} +func (i *controllerIdentityInstance) AbortPending(context.Context) error { + i.aborts++ + if i.abortError != nil { + return i.abortError + } + if i.pending != 0 && i.generation == i.pending { + return errors.New("cannot abort committed publication") + } + i.pending = 0 + i.program = i.previousProgram + i.previousProgram = nil + return nil +} + +type controllerIdentityEngine struct{ instances []*controllerIdentityInstance } + +func (e *controllerIdentityEngine) Start(ctx context.Context, _ string, cfg FullStartConfig, emit func(RawFullVerdict)) (FullInstance, error) { + lease, err := cfg.Acquire(ctx) + if err != nil { + return nil, err + } + i := &controllerIdentityInstance{fullTestInstance: fullTestInstance{program: cfg.Program, lease: lease, emit: emit}} + e.instances = append(e.instances, i) + return i, nil +} + +type controllerIdentityWriter struct{ report *api.SandboxPolicyNodeStatus } + +func (w *controllerIdentityWriter) WriteStatus(_ context.Context, report *api.SandboxPolicyNodeStatus) error { + w.report = report.DeepCopy() + return nil +} + +func controllerIdentityFixture(t *testing.T) (*sandboxpolicy.Controller, *FullBackend, *controllerIdentityEngine, *controllerIdentitySource, *controllerIdentityWriter, *fake.FakeDynamicClient, sandboxpolicy.Binding) { + t.Helper() + p := &api.SandboxPolicy{TypeMeta: metav1.TypeMeta{APIVersion: api.SchemeGroupVersion.String(), Kind: "SandboxPolicy"}, ObjectMeta: metav1.ObjectMeta{Name: "policy", Namespace: "ns", UID: "policy-uid", Generation: 1}, Spec: api.SandboxPolicySpec{PolicySpec: api.PolicySpec{Containers: []string{"*"}, Mode: api.ModeEnforce, Exec: &api.ExecSection{Default: api.ActionDeny, Rules: []api.ExecRule{{Rule: api.Rule{Name: "shell", Action: api.ActionAllow}, Target: api.ExecTarget{PathTarget: api.PathTarget{Path: []string{"/bin/sh"}}}}}}}}} + raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(p) + if err != nil { + t.Fatal(err) + } + client := fake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{sandboxpolicy.PolicyGVR: "SandboxPolicyList", sandboxpolicy.ClusterPolicyGVR: "ClusterSandboxPolicyList", sandboxpolicy.ClassesGVR: "SandboxPolicyClassesList"}, &unstructured.Unstructured{Object: raw}) + engine, source, writer := &controllerIdentityEngine{}, &controllerIdentitySource{}, &controllerIdentityWriter{} + backend, err := NewFull(engine, source, nil, nil) + if err != nil { + t.Fatal(err) + } + controller, err := sandboxpolicy.NewController(client, backend, nil, writer, sandboxpolicy.Options{Node: api.NodeReference{Name: "node", UID: "node-uid"}, AgentNamespace: "agent"}) + if err != nil { + t.Fatal(err) + } + binding := sandboxpolicy.Binding{Runtime: "containerd", ContainerID: "id", CgroupPath: "/test", CgroupID: 19, PID: 20, MountNamespace: 21, PodUID: "pod-uid", Namespace: "ns", PodName: "pod", ContainerName: "app", Kind: "regular"} + if err := controller.SetBindings(context.Background(), []sandboxpolicy.Binding{binding}); err != nil { + t.Fatal(err) + } + if err := controller.InitialSync(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := backend.Close(context.Background()); err != nil { + t.Error(err) + } + }) + return controller, backend, engine, source, writer, client, binding +} + +func TestControllerFullBackendPhysicalIdentityReplacement(t *testing.T) { + changes := map[string]func(*sandboxpolicy.Binding){ + "pid": func(b *sandboxpolicy.Binding) { b.PID++ }, + "mount-namespace": func(b *sandboxpolicy.Binding) { b.MountNamespace++ }, + "runtime": func(b *sandboxpolicy.Binding) { b.Runtime = "cri-o" }, + "cgroup-path": func(b *sandboxpolicy.Binding) { b.CgroupPath = "/replacement" }, + } + for name, change := range changes { + for _, failedRetirement := range []bool{false, true} { + suffix := "/immediate" + if failedRetirement { + suffix = "/failed-retirement" + } + t.Run(name+suffix, func(t *testing.T) { + c, b, e, s, w, _, old := controllerIdentityFixture(t) + ctx := context.Background() + next := old + change(&next) + first := e.instances[0] + first.failStop = failedRetirement + err := c.SetBindings(ctx, []sandboxpolicy.Binding{next}) + if failedRetirement { + if err == nil || len(e.instances) != 1 || len(s.leases) != 1 || b.records[key(old)] == nil || !b.records[key(old)].retiring { + t.Fatal("failed retirement lost ownership or admitted replacement", err) + } + if w.report.Status.Conditions[0].Status != metav1.ConditionFalse || len(w.report.Status.Conditions) != 1 { + t.Fatal("failed retirement advertised confirmed protection") + } + if err := c.Reconcile(ctx); err == nil || len(e.instances) != 1 { + t.Fatal("failed retry admitted replacement", err) + } + first.failStop = false + err = c.Reconcile(ctx) + } + if err != nil { + t.Fatal(err) + } + if len(e.instances) != 2 || len(s.leases) != 2 || s.leases[0].closed != 1 || s.leases[1].closed != 0 || b.records[key(old)] != nil || b.records[key(next)] == nil || len(b.records) != 1 { + t.Fatal("physical replacement did not retire exactly the old owner") + } + current := e.instances[1] + generation := current.Generation() + result, err := b.Withdraw(ctx, old) + if err != nil || !result.Committed || current.stops != 0 || s.leases[1].closed != 0 || b.records[key(next)] == nil { + t.Fatal("stale old withdrawal touched replacement", result, err) + } + if err := c.Reconcile(ctx); err != nil || current.Generation() != generation || len(e.instances) != 2 || current.updates != 0 || w.report.Status.Conditions[0].Status != metav1.ConditionTrue { + t.Fatal("replacement failed subsequent attestation", err) + } + }) + } + } +} + +func TestControllerFullBackendSnapshotFailureCannotAdvertiseDeadOwner(t *testing.T) { + for _, configuration := range []bool{false, true} { + name := "binding" + if configuration { + name = "configuration" + } + t.Run(name, func(t *testing.T) { + c, b, e, s, w, client, binding := controllerIdentityFixture(t) + ctx := context.Background() + owner := e.instances[0] + owner.health = errors.New("init exited / owner quarantined") + fail := true + checks := owner.checks + if configuration { + client.PrependReactor("list", "sandboxpolicies", func(ktesting.Action) (bool, runtime.Object, error) { + if fail { + return true, nil, errors.New("API unavailable") + } + return false, nil, nil + }) + if err := c.InitialSync(ctx); err == nil { + t.Fatal("configuration outage hidden") + } + } else if err := c.ReportBindingFailure(ctx, errors.New("discovery unavailable")); err == nil { + t.Fatal("binding outage hidden") + } + if err := c.Reconcile(ctx); err == nil { + t.Fatal("outage cleared prematurely") + } + if len(w.report.Status.Conditions) != 1 || w.report.Status.Conditions[0].Status != metav1.ConditionFalse || !w.report.Status.Stale { + t.Fatal("dead owner advertised as confirmed last-good", w.report.Status) + } + if len(b.records) != 1 || len(e.instances) != 1 || owner.stops != 0 || owner.updates != 0 || s.leases[0].closed != 0 { + t.Fatal("outage changed retained ownership") + } + if configuration && owner.checks != checks { + t.Fatal("configuration freeze called backend") + } + if !configuration && owner.checks <= checks { + t.Fatal("binding outage skipped last-good health attestation") + } + fail = false + var err error + if configuration { + err = c.InitialSync(ctx) + } else { + err = c.SetBindings(ctx, []sandboxpolicy.Binding{binding}) + } + if err == nil || len(w.report.Status.Conditions) != 1 { + t.Fatal("fresh snapshot falsely recovered unhealthy owner", err) + } + owner.health = nil + if err := c.Reconcile(ctx); err != nil { + t.Fatal(err) + } + if len(w.report.Status.Conditions) != 2 || w.report.Status.Conditions[0].Status != metav1.ConditionTrue || len(e.instances) != 1 || owner.updates != 0 { + t.Fatal("fresh healthy attestation failed to restore confirmed protection") + } + }) + } +} + +func stageControllerUncommitted(t *testing.T, c *sandboxpolicy.Controller, client *fake.FakeDynamicClient, owner *controllerIdentityInstance) { + t.Helper() + ctx := context.Background() + p, err := client.Resource(sandboxpolicy.PolicyGVR).Namespace("ns").Get(ctx, "policy", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + p.SetGeneration(p.GetGeneration() + 1) + if err := unstructured.SetNestedField(p.Object, "allow", "spec", "exec", "default"); err != nil { + t.Fatal(err) + } + if _, err := client.Resource(sandboxpolicy.PolicyGVR).Namespace("ns").Update(ctx, p, metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + owner.holdUncommitted = true + c.ModifyHandler(ctx, p) + if c.LastError() == nil || owner.pending == 0 || owner.generation == owner.pending { + t.Fatal("test requires an uncommitted staged candidate") + } +} + +func TestControllerBindingFailureNeverResumesUncommittedCandidate(t *testing.T) { + c, b, e, s, w, client, binding := controllerIdentityFixture(t) + owner := e.instances[0] + ctx := context.Background() + oldGeneration, oldRevision := owner.generation, b.records[key(binding)].revision + stageControllerUncommitted(t, c, client, owner) + pending := owner.pending + updates, resumes, aborts := owner.updates, owner.resumes, owner.aborts + if err := c.ReportBindingFailure(ctx, errors.New("discovery unavailable")); err == nil { + t.Fatal("outage hidden") + } + if err := c.Reconcile(ctx); err == nil { + t.Fatal("outage unexpectedly cleared") + } + if owner.generation != oldGeneration || owner.pending != pending || b.records[key(binding)].revision != oldRevision || owner.updates != updates || owner.resumes != resumes || owner.aborts != aborts || len(e.instances) != 1 || len(s.leases) != 1 { + t.Fatal("snapshot outage published or changed pending candidate") + } + if len(w.report.Status.Conditions) != 1 || w.report.Status.Conditions[0].Status != metav1.ConditionFalse { + t.Fatal("uncommitted candidate advertised protection") + } + // A fresh authoritative snapshot may resume the still-desired candidate. + owner.holdUncommitted = false + if err := c.SetBindings(ctx, []sandboxpolicy.Binding{binding}); err != nil { + t.Fatal(err) + } + if owner.generation != pending || owner.resumes != resumes+1 || owner.updates != updates || len(e.instances) != 1 || w.report.Status.Conditions[0].Status != metav1.ConditionTrue { + t.Fatal("authoritative recovery failed to finalize desired candidate") + } + checks := owner.checks + if err := c.ReportBindingFailure(ctx, errors.New("discovery unavailable again")); err == nil { + t.Fatal("outage hidden") + } + if owner.checks != checks+1 || owner.generation != pending || owner.resumes != resumes+1 || owner.updates != updates || len(w.report.Status.Conditions) != 2 { + t.Fatal("healthy read-only attestation changed owner or failed to confirm retained revision") + } +} diff --git a/pkg/sandboxpolicy/igbackend/fullbackend.go b/pkg/sandboxpolicy/igbackend/fullbackend.go new file mode 100644 index 0000000000..9b38dde146 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/fullbackend.go @@ -0,0 +1,398 @@ +package igbackend + +import ( + "context" + "errors" + "fmt" + "math" + "sync" + "time" + + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullruntime" + execruntime "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/runtime" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" +) + +// FullBindingLease is authenticated runtime evidence, never policy input. Origins +// must describe the same pinned image root for the entire owner lifetime. +// Close consumes all owned resources even on OS close errors; repeated Close is +// harmless. This matches the native Owner.ReleaseAfterDetach contract. +type FullBindingLease interface { + fullruntime.BindingLease + ImageOrigins() *execruntime.ImageOriginSnapshot +} + +// FullBindingSource transfers an owned lease, including any nonnil lease returned +// alongside an error. The backend retains it until cleanup is confirmed. +type FullBindingSource interface { + Prepare(context.Context, sandboxpolicy.Binding) (FullBindingLease, error) +} + +type fullRecord struct { + binding sandboxpolicy.Binding + instance FullInstance + lease FullBindingLease // only until the engine's Acquire transfers it + catalog *FullVerdictCatalog + revision, pendingRevision string + pending uint64 + retiring bool + protocolFault bool +} + +// FullBackendMaxBindings includes owners awaiting cleanup. +const FullBackendMaxBindings = 1024 + +// Bound whole-exec drain/re-attestation even when the service lifetime context +// has no deadline. First admission may need a longer bounded image walk/load. +const FullOperationTimeout = 5 * time.Second +const FullStartupTimeout = 2 * time.Minute + +// FullBackend publishes into one persistent native owner per exact binding. +// Native engine and trusted lease source are explicit: there is no legacy fallback. +type FullBackend struct { + retiredCounters KernelEventTotals + retiredInstances uint64 + retiredUnavailable uint64 + mu sync.Mutex + engine FullEngine + source FullBindingSource + receiver sandboxpolicy.VerdictReceiver + onError func(error) + records map[identity]*fullRecord + sequence uint64 + closed bool + operationTimeout time.Duration +} + +var _ sandboxpolicy.Backend = (*FullBackend)(nil) +var _ sandboxpolicy.ProtectionAttester = (*FullBackend)(nil) + +func NewFull(engine FullEngine, source FullBindingSource, receiver sandboxpolicy.VerdictReceiver, onError func(error)) (*FullBackend, error) { + if engine == nil || source == nil { + return nil, fmt.Errorf("full engine and trusted binding source required") + } + return &FullBackend{engine: engine, source: source, receiver: receiver, onError: onError, records: map[identity]*fullRecord{}, operationTimeout: FullOperationTimeout}, nil +} + +// Attest observes only the exact committed owner; it never resumes a pending +// publication, including an uncommitted candidate from a previously failed reload. +func (b *FullBackend) Attest(ctx context.Context, binding sandboxpolicy.Binding, revision string) error { + b.mu.Lock() + defer b.mu.Unlock() + ctx, cancel := context.WithTimeout(ctx, b.operationTimeout) + defer cancel() + r := b.records[key(binding)] + if b.closed || r == nil || r.instance == nil || r.retiring || r.protocolFault { + return fmt.Errorf("binding has no attestable enforcement owner") + } + if !sameFullBindingMetadata(r.binding, binding) || revision == "" || r.revision != revision { + return fmt.Errorf("binding or active revision does not match attestation") + } + if r.pending != 0 || r.instance.PendingGeneration() != 0 { + return fmt.Errorf("pending publication prevents read-only protection attestation") + } + return r.instance.Check(ctx) +} + +func (b *FullBackend) Replace(ctx context.Context, binding sandboxpolicy.Binding, plan sandboxpolicy.Plan) (result sandboxpolicy.ReplaceResult, resultErr error) { + b.mu.Lock() + defer b.mu.Unlock() + k := key(binding) + timeout := b.operationTimeout + if b.records[k] == nil { + timeout = FullStartupTimeout + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + defer func() { + r := b.records[k] + if r != nil && r.instance != nil { + if r.retiring { + result.ProtectionUnconfirmed = true + return + } + health := r.instance.Check(ctx) + result.ProtectionUnconfirmed = result.ProtectionUnconfirmed || r.protocolFault || health != nil + resultErr = errors.Join(resultErr, health) + } + }() + if b.closed { + return sandboxpolicy.ReplaceResult{ProtectionUnconfirmed: true}, fmt.Errorf("backend closed") + } + r := b.records[k] + if r != nil && !r.retiring && !sameFullBindingMetadata(r.binding, binding) { + return result, fmt.Errorf("binding metadata changed for persistent identity") + } + if r != nil && r.retiring { + if err := b.stop(ctx, k, r); err != nil { + return result, err + } + r = nil + } + for other := range b.records { + if other != k && other.cgroup == k.cgroup { + return result, fmt.Errorf("cgroup is still owned by another binding") + } + } + if r != nil && r.pending != 0 && r.pendingRevision != plan.Revision && r.instance.Generation() != r.pending { + // An uncommitted candidate is not authority to publish a now-superseded + // revision. Abort its staging before considering the current request. + aborter, ok := r.instance.(FullPendingAborter) + if !ok { + return sandboxpolicy.ReplaceResult{ProtectionUnconfirmed: true}, fmt.Errorf("superseded pending publication cannot be safely aborted") + } + if err := aborter.AbortPending(ctx); err != nil { + return sandboxpolicy.ReplaceResult{ProtectionUnconfirmed: true}, err + } + if r.instance.PendingGeneration() != 0 { + return sandboxpolicy.ReplaceResult{ProtectionUnconfirmed: true}, fmt.Errorf("superseded publication remains pending after abort") + } + if err := r.catalog.Abort(r.pending); err != nil { + r.protocolFault = true + return sandboxpolicy.ReplaceResult{ProtectionUnconfirmed: true}, err + } + r.pending = 0 + r.pendingRevision = "" + } + if r != nil && r.pending != 0 { + publication, err := r.instance.Resume(ctx) + if e := b.publication(r, publication); e != nil { + err = errors.Join(err, e) + } + if r.revision == plan.Revision { + result.Committed = true + result.ActiveRevision = r.revision + } + if err != nil || r.pending != 0 { + return result, errors.Join(err, pendingError(r)) + } + } + if r != nil && r.revision == plan.Revision { + return sandboxpolicy.ReplaceResult{Committed: true, ActiveRevision: r.revision}, nil + } + if r == nil && len(b.records) >= FullBackendMaxBindings { + return result, fmt.Errorf("full binding ownership capacity exceeded") + } + if b.sequence == math.MaxUint64 { + return result, fmt.Errorf("full generation exhausted") + } + b.sequence++ + generation := b.sequence + var epochs map[string]uint64 + if r != nil { + epochs = r.instance.Epochs() + } + program, err := CompileFull(binding, plan, generation, epochs) + if err != nil { + return result, err + } + if r == nil { + catalog := NewFullVerdictCatalog(binding) + if err := catalog.Stage(generation, plan, program); err != nil { + return result, err + } + lease, err := b.source.Prepare(ctx, binding) + if err != nil || lease == nil { + if lease != nil { + r = &fullRecord{binding: sandboxpolicy.Binding{PodUID: binding.PodUID, Namespace: binding.Namespace, PodName: binding.PodName, ContainerName: binding.ContainerName, Kind: binding.Kind}, lease: lease, retiring: true} + b.records[k] = r + err = errors.Join(err, b.stop(ctx, k, r)) + } + if lease == nil { + err = errors.Join(err, fmt.Errorf("trusted source returned no binding lease")) + } + return result, err + } + r = &fullRecord{binding: sandboxpolicy.Binding{PodUID: binding.PodUID, Namespace: binding.Namespace, PodName: binding.PodName, ContainerName: binding.ContainerName, Kind: binding.Kind}, lease: lease, catalog: catalog, pending: generation, pendingRevision: plan.Revision} + b.records[k] = r + // Acquisition is synchronous during Start. Ownership transfers exactly once; + // a failure before Acquire leaves the lease here for ordered cleanup. + acquire := func(ctx context.Context) (fullruntime.BindingLease, error) { + if r.lease == nil { + return nil, fmt.Errorf("binding lease already transferred") + } + owned := r.lease + r.lease = nil + return owned, nil + } + origins := lease.ImageOrigins() + r.instance, err = b.engine.Start(ctx, fmt.Sprintf("sandbox-full-%d-%d", binding.CgroupID, generation), FullStartConfig{Binding: binding, Program: program, Acquire: acquire, ImageOrigins: origins}, func(raw RawFullVerdict) { + verdict, e := catalog.Decode(raw) + if e == nil && b.receiver != nil { + e = b.receiver.ReportVerdict(context.Background(), verdict) + } + if e != nil && b.onError != nil { + b.onError(e) + } + }) + if err != nil || r.instance == nil { + r.retiring = true + if err == nil { + err = fmt.Errorf("full engine returned no instance") + } + return result, errors.Join(err, b.stop(ctx, k, r)) + } + publication, e := r.instance.Activate(ctx) + err = errors.Join(e, b.publication(r, publication)) + } else { + if err = r.catalog.Stage(generation, plan, program); err != nil { + return result, err + } + r.pending = generation + r.pendingRevision = plan.Revision + publication, e := r.instance.Update(ctx, program) + err = errors.Join(e, b.publication(r, publication)) + } + if r.revision == plan.Revision { + result.Committed = true + result.ActiveRevision = r.revision + } + if r.revision == "" && r.pending == 0 { + r.retiring = true + err = errors.Join(err, b.stop(ctx, k, r)) + } + return result, errors.Join(err, pendingError(r)) +} + +func pendingError(r *fullRecord) error { + if r.pending != 0 { + return fmt.Errorf("full publication %d requires finalization", r.pending) + } + return nil +} +func (b *FullBackend) publication(r *fullRecord, p fullruntime.PublicationResult) (err error) { + defer func() { + if err != nil { + r.protocolFault = true + } + }() + if p.Committed { + if p.Generation != r.pending || r.instance.Generation() != r.pending { + return fmt.Errorf("native publication generation mismatch") + } + if err := r.catalog.Commit(r.pending); err != nil { + return err + } + r.revision = r.pendingRevision + } + if r.instance.PendingGeneration() == 0 { + if !p.Committed { + if err := r.catalog.Abort(r.pending); err != nil { + return err + } + } + r.pending = 0 + r.pendingRevision = "" + } else if r.instance.PendingGeneration() != r.pending { + return fmt.Errorf("native pending generation mismatch") + } + return nil +} +func (b *FullBackend) stop(ctx context.Context, k identity, r *fullRecord) error { + ctx, cancel := context.WithTimeout(ctx, b.operationTimeout) + defer cancel() + if r.instance != nil { + if err := r.instance.Stop(ctx); err != nil { + return err + } + b.retainKernelCounters(r.instance) + r.instance = nil + } + if r.lease != nil { + if err := r.lease.Close(); err != nil { + return err + } + r.lease = nil + } + delete(b.records, k) + return nil +} +func (b *FullBackend) Withdraw(ctx context.Context, binding sandboxpolicy.Binding) (sandboxpolicy.WithdrawResult, error) { + b.mu.Lock() + defer b.mu.Unlock() + k := key(binding) + r := b.records[k] + if r == nil || !sameFullBindingMetadata(r.binding, binding) { + return sandboxpolicy.WithdrawResult{Committed: true}, nil + } + r.retiring = true + if err := b.stop(ctx, k, r); err != nil { + return sandboxpolicy.WithdrawResult{Committed: r.instance != nil && r.instance.Detached(), ProtectionUnconfirmed: true}, err + } + return sandboxpolicy.WithdrawResult{Committed: true}, nil +} +func (b *FullBackend) Close(ctx context.Context) error { + b.mu.Lock() + defer b.mu.Unlock() + b.closed = true + var errs []error + for k, r := range b.records { + r.retiring = true + errs = append(errs, b.stop(ctx, k, r)) + } + return errors.Join(errs...) +} + +func addKernelCounters(total *KernelEventTotals, counters fullruntime.KernelEventCounters) { + total.Submitted += counters.Submitted + total.Lost += counters.Lost + total.ObservationErrors += counters.ObservationErrors +} + +func instanceKernelCounters(instance FullInstance) (fullruntime.KernelEventCounters, error) { + source, ok := instance.(FullKernelCounterSource) + if !ok { + return fullruntime.KernelEventCounters{}, fmt.Errorf("instance kernel counters unavailable") + } + return source.KernelEventCounters() +} + +// Called exactly once before dropping an instance. Never accept a live sample +// as a final lifetime total, and never let telemetry errors block withdrawal. +func (b *FullBackend) retainKernelCounters(instance FullInstance) { + b.retiredInstances++ + counters, err := instanceKernelCounters(instance) + if err != nil || !counters.Final { + b.retiredUnavailable++ + } + addKernelCounters(&b.retiredCounters, counters) +} + +// KernelEventStats reads telemetry only; it does not check/repair enforcement. +// Errors describe incomplete totals, never successful zero-loss observations. +func (b *FullBackend) KernelEventStats(ctx context.Context) (KernelEventStats, error) { + if err := ctx.Err(); err != nil { + return KernelEventStats{}, err + } + b.mu.Lock() + defer b.mu.Unlock() + if err := ctx.Err(); err != nil { + return KernelEventStats{}, err + } + result := KernelEventStats{Retired: b.retiredCounters, RetiredInstances: b.retiredInstances, RetiredUnavailable: b.retiredUnavailable} + var errs []error + if result.RetiredUnavailable != 0 { + errs = append(errs, fmt.Errorf("kernel counters unavailable for %d retired instances", result.RetiredUnavailable)) + } + for _, record := range b.records { + if record.instance == nil { + continue + } + result.OwnedInstances++ + if err := ctx.Err(); err != nil { + return KernelEventStats{}, err + } + counters, err := instanceKernelCounters(record.instance) + addKernelCounters(&result.Owned, counters) + if err != nil { + result.OwnedUnavailable++ + errs = append(errs, err) + continue + } + } + return result, errors.Join(errs...) +} + +func sameFullBindingMetadata(a, b sandboxpolicy.Binding) bool { + return a.PodUID == b.PodUID && a.Namespace == b.Namespace && a.PodName == b.PodName && a.ContainerName == b.ContainerName && a.Kind == b.Kind +} diff --git a/pkg/sandboxpolicy/igbackend/fullbackend_attest_test.go b/pkg/sandboxpolicy/igbackend/fullbackend_attest_test.go new file mode 100644 index 0000000000..6def5bbfde --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/fullbackend_attest_test.go @@ -0,0 +1,147 @@ +package igbackend + +import ( + "context" + "errors" + "testing" + + "github.com/kubescape/node-agent/pkg/sandboxpolicy" +) + +type noPendingAbortInstance struct{ FullInstance } + +func pendingBackendFixture(t *testing.T) (*FullBackend, *controllerIdentityEngine, sandboxpolicy.Binding, sandboxpolicy.Plan) { + t.Helper() + e := &controllerIdentityEngine{} + b, err := NewFull(e, &controllerIdentitySource{}, nil, nil) + if err != nil { + t.Fatal(err) + } + binding := sandboxpolicy.Binding{Runtime: "containerd", ContainerID: "id", ContainerName: "app", CgroupID: 19, PID: 20, MountNamespace: 21, PodUID: "pod", Namespace: "ns", PodName: "pod", Kind: "regular"} + plan := verdictPlan() + if result, err := b.Replace(context.Background(), binding, plan); err != nil || !result.Committed { + t.Fatal(result, err) + } + t.Cleanup(func() { + if err := b.Close(context.Background()); err != nil { + t.Error(err) + } + }) + return b, e, binding, plan +} + +func TestFullBackendAttestIsReadOnlyAndExact(t *testing.T) { + for _, test := range []string{"healthy", "revision", "identity", "metadata", "missing", "retiring", "protocol-fault", "health", "pending", "closed"} { + t.Run(test, func(t *testing.T) { + b, e, binding, plan := pendingBackendFixture(t) + owner := e.instances[0] + record := b.records[key(binding)] + requested := binding + revision := plan.Revision + switch test { + case "revision": + revision = "wrong" + case "identity": + requested.PID++ + case "metadata": + requested.PodUID = "replacement" + case "missing": + requested.ContainerID = "absent" + case "retiring": + record.retiring = true + case "protocol-fault": + record.protocolFault = true + case "health": + owner.health = errors.New("quarantined") + case "pending": + owner.pending = owner.generation + 1 + record.pending = owner.pending + case "closed": + b.closed = true + } + beforeGeneration := owner.generation + err := b.Attest(context.Background(), requested, revision) + if (err == nil) != (test == "healthy") { + t.Fatal("unexpected attestation result", err) + } + if owner.generation != beforeGeneration || owner.updates != 0 || owner.resumes != 0 || owner.aborts != 0 || owner.stops != 0 || len(e.instances) != 1 { + t.Fatal("attestation mutated owned enforcement") + } + if test == "healthy" && owner.checks < 2 { + t.Fatal("attestation skipped native health") + } + }) + } +} + +func TestFullBackendSupersededUncommittedCandidateNeverResumes(t *testing.T) { + for _, test := range []string{"rollback", "new-revision", "abort-fails", "abort-unsupported"} { + t.Run(test, func(t *testing.T) { + b, e, binding, original := pendingBackendFixture(t) + ctx := context.Background() + owner := e.instances[0] + generation := owner.generation + candidate := verdictPlan() + candidate.Revision = "superseded" + owner.holdUncommitted = true + result, err := b.Replace(ctx, binding, candidate) + if err == nil || result.Committed || owner.pending == 0 || owner.generation != generation { + t.Fatal("test requires uncommitted staged candidate", result, err) + } + pending := owner.pending + desired := original + if test == "new-revision" { + desired.Revision = "current-desired" + } + owner.holdUncommitted = false + switch test { + case "abort-fails": + owner.abortError = errors.New("drain incomplete") + case "abort-unsupported": + b.records[key(binding)].instance = noPendingAbortInstance{owner} + } + result, err = b.Replace(ctx, binding, desired) + if test == "abort-fails" || test == "abort-unsupported" { + if err == nil || result.Committed || !result.ProtectionUnconfirmed || owner.pending != pending || owner.generation != generation || owner.updates != 1 || owner.resumes != 0 { + t.Fatal("unsupported/failed abort resumed superseded revision", result, err) + } + owner.abortError = nil + b.records[key(binding)].instance = owner + result, err = b.Replace(ctx, binding, desired) + } + if err != nil || !result.Committed || result.ActiveRevision != desired.Revision || owner.pending != 0 || b.records[key(binding)].pending != 0 || owner.resumes != 0 || owner.aborts < 1 || len(e.instances) != 1 { + t.Fatal("superseded candidate not aborted safely", result, err) + } + if test == "new-revision" { + if owner.generation <= pending || owner.updates != 2 { + t.Fatal("new desired revision not published after abort") + } + } else if owner.generation != generation || owner.updates != 1 { + t.Fatal("rollback unnecessarily published or restarted last-good") + } + if err := b.Attest(ctx, binding, desired.Revision); err != nil { + t.Fatal("post-abort active revision cannot be attested", err) + } + }) + } +} + +func TestFullBackendSupersededCommittedPublicationMayFinalize(t *testing.T) { + b, e, binding, original := pendingBackendFixture(t) + ctx := context.Background() + owner := e.instances[0] + committed := verdictPlan() + committed.Revision = "committed-awaiting-finalization" + owner.commitFailure = true + result, err := b.Replace(ctx, binding, committed) + if err == nil || !result.Committed || owner.pending == 0 || owner.pending != owner.generation { + t.Fatal("test requires committed pending finalization", result, err) + } + committedGeneration := owner.generation + // The previous commit is irreversible, but the new authoritative request may + // finalize it before publishing the requested rollback on the same owner. + result, err = b.Replace(ctx, binding, original) + if err != nil || !result.Committed || result.ActiveRevision != original.Revision || owner.aborts != 0 || owner.resumes != 1 || owner.updates != 2 || owner.generation <= committedGeneration || len(e.instances) != 1 { + t.Fatal("committed publication was aborted or failed to finalize", result, err) + } +} diff --git a/pkg/sandboxpolicy/igbackend/fullbackend_kernel_test.go b/pkg/sandboxpolicy/igbackend/fullbackend_kernel_test.go new file mode 100644 index 0000000000..bbfc6e472b --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/fullbackend_kernel_test.go @@ -0,0 +1,615 @@ +package igbackend + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + goruntime "runtime" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/cilium/ebpf/rlimit" + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullpolicy" + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullruntime" + "github.com/inspektor-gadget/inspektor-gadget/pkg/operators/simple" + "github.com/inspektor-gadget/inspektor-gadget/pkg/runtime/local" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" + api "github.com/kubescape/storage/pkg/apis/sandbox/v1alpha1" + specs "github.com/opencontainers/image-spec/specs-go" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" + "k8s.io/apimachinery/pkg/types" + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/content/memory" + orasoci "oras.land/oras-go/v2/content/oci" +) + +// TestFullBackendContainerdKernel uses the production FullBackend, native OCI +// engine and authenticated ContainerdFullSource. Provision ONLY on a disposable +// supported-kernel VM, not a shared/production node: +// +// - immutable image contains static testdata/fullbackend-helper.c binary as +// /probe/init, /probe/role and /probe/target-{0..12}; +// - exact 64-hex container ID, k8s.io, runc-v2, overlayfs, private PID/mount +// namespaces, supported canonicalRootSeccomp and capability/root barriers; +// - start the task as /probe/init --serve, no other live task in its cgroup; +// - IG_FULL_BACKEND_KERNEL_TEST=1, IG_IMAGE_EVIDENCE_CONTAINER_ID and optionally +// IG_IMAGE_EVIDENCE_SOCKET; IG_FULL_BACKEND_OBJECT and IG_FULL_BACKEND_METADATA +// point to the matching locally built persistent-owner object/metadata. +// +// FIFOs are created in the task's writable upper root and accessed via its pinned +// host PID, not bind-mounted helper exemptions. Runtime/container identity and +// image origins are obtained from the real trusted containerd connection. This +// fixture does not create/delete containers. It proves native public receiver +// delivery, not the separate Kubernetes selection/controller or private exporter. +func TestFullBackendContainerdKernel(t *testing.T) { + if os.Getenv("IG_FULL_BACKEND_KERNEL_TEST") != "1" { + t.Skip("explicit disposable combined kernel fixture opt-in required") + } + require.Equal(t, 0, os.Geteuid()) + require.NoError(t, rlimit.RemoveMemlock()) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + socket := os.Getenv("IG_IMAGE_EVIDENCE_SOCKET") + if socket == "" { + socket = "/run/containerd/containerd.sock" + } + id := os.Getenv("IG_IMAGE_EVIDENCE_CONTAINER_ID") + require.True(t, fullContainerID(id)) + source, err := NewContainerdFullSource(socket, "/", "/proc", "/sys/fs/cgroup", ImageIndexLimits{}) + require.NoError(t, err) + rt := local.New() + require.NoError(t, rt.Init(nil)) + var backend *FullBackend + defer func() { + cleanup, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if backend != nil { + if err := backend.Close(cleanup); err != nil { + t.Errorf("retaining native resources after failed cleanup: %v", err) + return + } + } + require.NoError(t, source.Close()) + require.NoError(t, rt.Close()) + }() + evidence, err := source.source.Evidence(ctx, id) + require.NoError(t, err) + require.Equal(t, []string{"/probe/init", "--serve"}, evidence.Spec.Process.Args, "fixture must be the authenticated init, not a runtime exec helper") + raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/cgroup", evidence.PID)) + require.NoError(t, err) + var cg string + for _, line := range strings.Split(string(raw), "\n") { + if strings.HasPrefix(line, "0::") { + require.Empty(t, cg) + cg = strings.TrimPrefix(line, "0::") + } + } + require.NotEmpty(t, cg) + require.NotEqual(t, "/", cg) + var cgroup, mountns unix.Stat_t + require.NoError(t, unix.Stat("/sys/fs/cgroup"+cg, &cgroup)) + require.NoError(t, unix.Stat(fmt.Sprintf("/proc/%d/ns/mnt", evidence.PID), &mountns)) + name := os.Getenv("IG_FULL_BACKEND_CONTAINER_NAME") + if name == "" { + name = "app" + } + binding := sandboxpolicy.Binding{Runtime: "containerd", ContainerID: id, ContainerName: name, PodUID: types.UID(evidence.PodUID), Namespace: evidence.PodNamespace, PID: evidence.PID, CgroupID: cgroup.Ino, CgroupPath: cg, MountNamespace: mountns.Ino} + control, err := openFullBackendControl(ctx, evidence.PID) + require.NoError(t, err) + defer control.close() + code, err := control.command(ctx, "PING") + require.NoError(t, err) + require.Zero(t, code) + // Prove the exact CRI exec invocation works before policy activation, so a + // later refusal cannot be attributed to a malformed CLI or unavailable RPC. + criExec := func() ([]byte, error) { + attempt, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + return exec.CommandContext(attempt, "/usr/local/bin/crictl", "--runtime-endpoint", "unix://"+socket, "exec", "--sync", id, "/probe/target-11", "--target", "--token=ok").CombinedOutput() + } + output, err := criExec() + require.NoError(t, err, "baseline actual CRI exec: %s", output) + store, image := fullBackendKernelImage(t) + budget, err := fullruntime.NewBudget(fullruntime.BudgetLimits{Owners: 1, MapPayloadBytes: fullruntime.OwnerMapPayloadLimit, ProgramInstructions: fullruntime.OwnerInstructionLimit, HistoryEntries: fullruntime.OwnerHistoryLimit}) + require.NoError(t, err) + telemetry := &fullBackendKernelReceiver{events: make(chan sandboxpolicy.Verdict, 256)} + failures := make(chan error, 64) + onError := func(err error) { + select { + case failures <- err: + default: + telemetry.lost.Add(1) + } + } + // The observer-enrichment operator is intentionally empty. It provides no + // runtime facts or lease authority; the real source/owner do all admission. + engine, err := NewNativeFullEngine(rt, simple.New("fixture-no-observer-enrichment"), store, image, "/sys/fs/cgroup", budget, onError) + require.NoError(t, err) + backend, err = NewFull(engine, source, telemetry, onError) + require.NoError(t, err) + plan := fullBackendKernelPlan("plan-1", "agent", 0, 1, name) + result, err := backend.Replace(ctx, binding, plan) + require.NoError(t, err) + require.True(t, result.Committed) + require.False(t, result.ProtectionUnconfirmed) + first := backend.records[key(binding)].instance + generation := first.Generation() + require.NotZero(t, generation) + epoch := first.Epochs()["fixture-policy-uid"] + require.Equal(t, generation, epoch) + require.Equal(t, uint64(1), budget.Usage().Owners) + run := func(command string, want int) { + t.Helper() + code, err := control.command(ctx, command) + require.NoError(t, err) + require.Equal(t, want, code, command) + } + // The unflagged application shm bind is accepted only with the verified + // restrictive runtime-device/caps/NNP matrix. Test actual unknown devices, + // not merely lack of CAP_MKNOD (a sibling may create the nodes). + proveFullBackendDeviceBarrier(t, ctx, control, evidence.PID) + // Thirteen authored paths in one matching rule, every real path exercised. + for i := 0; i < 13; i++ { + want := 0 + if i == 0 || i == 12 { + want = 77 + } + run(fmt.Sprintf("RUN %d", i), want) + } + run("CHAIN", 0) // setRole on /probe/role, then real fork inheritance into target12 + expectFullBackendEvent(t, ctx, telemetry, failures, "plan-1", "/probe/target-0", api.ActionDeny, false, "deny-control") + expectFullBackendEvent(t, ctx, telemetry, failures, "plan-1", "/probe/target-1", api.ActionDeny, true, "audit-control") + expectFullBackendEvent(t, ctx, telemetry, failures, "plan-1", "/probe/target-12", api.ActionAllow, false, "inherited-worker") + // Add another allowed attempt after draining earlier telemetry so its event is + // observed explicitly rather than merely inferred from absence of an error. + run("RUN 11", 0) + expectFullBackendEvent(t, ctx, telemetry, failures, "plan-1", "/probe/target-11", api.ActionAllow, false, "root-paths") + plan = fullBackendKernelPlan("plan-2", "edited-root-must-not-reset", 2, 3, name) + result, err = backend.Replace(ctx, binding, plan) + require.NoError(t, err) + require.True(t, result.Committed) + require.False(t, result.ProtectionUnconfirmed) + require.Same(t, first, backend.records[key(binding)].instance) + require.Greater(t, first.Generation(), generation) + generation = first.Generation() + require.Equal(t, epoch, first.Epochs()["fixture-policy-uid"]) + run("RUN 0", 0) + run("RUN 2", 77) + run("RUN 3", 0) + run("RUN 12", 77) + run("CHAIN", 0) + expectFullBackendEvent(t, ctx, telemetry, failures, "plan-2", "/probe/target-2", api.ActionDeny, false, "deny-control") + expectFullBackendEvent(t, ctx, telemetry, failures, "plan-2", "/probe/target-3", api.ActionDeny, true, "audit-control") + expectFullBackendEvent(t, ctx, telemetry, failures, "plan-2", "/probe/target-12", api.ActionAllow, false, "inherited-worker") + // Same revision re-attests without map-owner replacement or generation churn. + result, err = backend.Replace(ctx, binding, plan) + require.NoError(t, err) + require.True(t, result.Committed) + require.Equal(t, generation, first.Generation()) + rejected := fullBackendKernelPlan("too-large", "agent", 4, 5, name) + rejected.Policies[0].Exec.Rules = nil + for i := 0; i < fullpolicy.MaxRules+1; i++ { + rejected.Policies[0].Exec.Rules = append(rejected.Policies[0].Exec.Rules, api.ExecRule{Rule: api.Rule{Name: fmt.Sprintf("excess-%d", i), Action: api.ActionAllow}, Target: api.ExecTarget{PathTarget: api.PathTarget{Path: []string{"/probe/target-2"}}}}) + } + result, err = backend.Replace(ctx, binding, rejected) + require.Error(t, err) + require.False(t, result.Committed) + require.False(t, result.ProtectionUnconfirmed) + require.Equal(t, generation, first.Generation()) + require.Same(t, first, backend.records[key(binding)].instance) + run("RUN 2", 77) + expectFullBackendEvent(t, ctx, telemetry, failures, "plan-2", "/probe/target-2", api.ActionDeny, false, "deny-control") + // Exercise the complete four-word match bitmap at the documented rule + // boundary, not only a beyond-eight-path authoring example. + capacity := fullBackendKernelPlan("capacity-256", "capacity-root-edit", 2, 3, name) + baseRules := len(capacity.Policies[0].Exec.Rules) + for i := baseRules; i < fullpolicy.MaxRules; i++ { + capacity.Policies[0].Exec.Rules = append(capacity.Policies[0].Exec.Rules, api.ExecRule{Rule: api.Rule{Name: fmt.Sprintf("capacity-%03d", i), Action: api.ActionAllow}, Target: api.ExecTarget{PathTarget: api.PathTarget{Path: []string{"/probe/target-11"}}}}) + } + result, err = backend.Replace(ctx, binding, capacity) + require.NoError(t, err) + require.True(t, result.Committed) + require.False(t, result.ProtectionUnconfirmed) + require.Same(t, first, backend.records[key(binding)].instance) + require.Equal(t, epoch, first.Epochs()["fixture-policy-uid"]) + run("RUN 11", 0) + boundary := expectFullBackendEvent(t, ctx, telemetry, failures, "capacity-256", "/probe/target-11", api.ActionAllow, false, "") + require.Len(t, boundary.Matches, fullpolicy.MaxRules-baseRules+1) + require.Equal(t, "capacity-255", boundary.Matches[len(boundary.Matches)-1].Name) + t.Logf("actual %d-rule boundary emitted %d matches including bitmap bit255", fullpolicy.MaxRules, len(boundary.Matches)) + result, err = backend.Replace(ctx, binding, plan) + require.NoError(t, err) + require.True(t, result.Committed) + require.False(t, result.ProtectionUnconfirmed) + generation = first.Generation() + require.Equal(t, epoch, first.Epochs()["fixture-policy-uid"]) + if os.Getenv("IG_FULL_BACKEND_RUNTIME_OUTAGE_TEST") == "1" { + // This separate opt-in controls only the owned disposable VM daemon. + // Runtime transport loss must not discard frozen last-good enforcement. + service := func(action string) error { + attempt, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + out, err := exec.CommandContext(attempt, "systemctl", action, "containerd").CombinedOutput() + if err != nil { + return fmt.Errorf("containerd %s: %w: %s", action, err, out) + } + return nil + } + require.NoError(t, service("stop")) + defer func() { require.NoError(t, service("start")) }() + result, err = backend.Replace(ctx, binding, plan) + require.Error(t, err) + require.True(t, result.Committed) + require.True(t, result.ProtectionUnconfirmed) + require.Equal(t, generation, first.Generation()) + run("RUN 11", 0) + run("RUN 2", 77) + require.NoError(t, service("start")) + var recoveryErr error + require.Eventually(t, func() bool { + result, recoveryErr = backend.Replace(ctx, binding, plan) + return recoveryErr == nil && result.Committed && !result.ProtectionUnconfirmed + }, 20*time.Second, 100*time.Millisecond, "same-owner trusted runtime re-attestation must recover: %v", recoveryErr) + require.Same(t, first, backend.records[key(binding)].instance) + require.Equal(t, generation, first.Generation()) + require.Equal(t, epoch, first.Epochs()["fixture-policy-uid"]) + t.Logf("actual containerd stop/start recovered same owner generation=%d epoch=%d with last-good enforcement intact", generation, epoch) + } + // Runtime helpers receive no inferred exemption. The same real CRI exec + // that succeeded before activation must now be refused by strict policy. + owner := first.(*nativeFullInstance).owner.(*fullruntime.Owner) + before, err := owner.Diagnostics() + require.NoError(t, err) + require.Zero(t, before.Health.Faults) + output, err = criExec() + require.Error(t, err, "runtime helper unexpectedly bypassed strict policy: %s", output) + var after fullruntime.OwnerDiagnostics + require.Eventually(t, func() bool { + var readErr error + after, readErr = owner.Diagnostics() + return readErr == nil && after.Admitted == 0 && after.Health.PendingBirths == 0 + }, 2*time.Second, 10*time.Millisecond, "completed rejected helper must not orphan a gate admission") + // runc may hide kernel EPERM behind procReady-not-received. Correlate actual + // owned kernel counters instead of accepting arbitrary RPC error text. + require.True(t, after.Health.Faults != 0 || after.Health.GateDenials > before.Health.GateDenials, "need actual kernel refusal evidence, not unrelated CLI/RPC failure: %s", output) + t.Logf("strict CRI helper refused: faults=%d gateDenialsDelta=%d admitted=%d: %s", after.Health.Faults, after.Health.GateDenials-before.Health.GateDenials, after.Admitted, strings.TrimSpace(string(output))) + plan = fullBackendKernelPlan("plan-3", "must-not-redeem-lineage", 2, 3, name) + result, err = backend.Replace(ctx, binding, plan) + require.Same(t, first, backend.records[key(binding)].instance) + require.Equal(t, epoch, first.Epochs()["fixture-policy-uid"]) + if after.Health.Faults != 0 { + // Observed unproven ingress invalidates the owner. A new revision must + // never clear structural faults or silently adopt the unknown helper. + require.Equal(t, uint64(4), after.Health.Faults, "only lineage quarantine expected") + require.Error(t, err) + require.False(t, result.Committed) + require.True(t, result.ProtectionUnconfirmed) + require.Equal(t, generation, first.Generation()) + } else { + // CLONE_INTO_CGROUP can be refused before a task exists. That is not + // grounds to poison the legitimate parent's accepted role or owner. + require.NoError(t, err) + require.True(t, result.Committed) + require.False(t, result.ProtectionUnconfirmed) + require.Greater(t, first.Generation(), generation) + generation = first.Generation() + run("RUN 11", 0) + run("RUN 2", 77) + expectFullBackendEvent(t, ctx, telemetry, failures, "plan-3", "/probe/target-11", api.ActionAllow, false, "root-paths") + expectFullBackendEvent(t, ctx, telemetry, failures, "plan-3", "/probe/target-2", api.ActionDeny, false, "deny-control") + } + kernelStats, err := backend.KernelEventStats(ctx) + require.NoError(t, err) + require.Equal(t, uint64(1), kernelStats.OwnedInstances) + require.Zero(t, kernelStats.OwnedUnavailable) + require.Zero(t, kernelStats.RetiredInstances) + require.Greater(t, kernelStats.Owned.Submitted, uint64(0)) + require.Zero(t, kernelStats.Owned.Lost) + withdrawn, err := backend.Withdraw(ctx, binding) + require.NoError(t, err) + require.True(t, withdrawn.Committed) + require.Zero(t, budget.Usage().Owners) + require.Empty(t, backend.records) + finalStats, err := backend.KernelEventStats(ctx) + require.NoError(t, err) + require.Zero(t, finalStats.OwnedInstances) + require.Equal(t, uint64(1), finalStats.RetiredInstances) + require.Zero(t, finalStats.RetiredUnavailable) + require.GreaterOrEqual(t, finalStats.Retired.Submitted, kernelStats.Owned.Submitted) + require.Zero(t, finalStats.Retired.Lost) + t.Logf("actual kernel per-CPU event counters survived withdrawal: submitted=%d ringLost=%d observationErrors=%d", finalStats.Retired.Submitted, finalStats.Retired.Lost, finalStats.Retired.ObservationErrors) + run("RUN 2", 0) + run("RUN 12", 0) + require.Zero(t, telemetry.lost.Load(), "bounded test telemetry overflow") + select { + case err := <-failures: + t.Fatalf("native telemetry error: %v", err) + default: + } + t.Logf("combined persistent backend proved: container=%s pid=%d cgroup=%d epoch=%d generation=%d 13-paths real deny/audit/reload/roles/rejection/withdrawal", id, binding.PID, binding.CgroupID, epoch, generation) +} + +type fullBackendKernelReceiver struct { + backlog []sandboxpolicy.Verdict + events chan sandboxpolicy.Verdict + lost atomic.Uint64 +} + +func (r *fullBackendKernelReceiver) ReportVerdict(_ context.Context, v sandboxpolicy.Verdict) error { + select { + case r.events <- v: + default: + r.lost.Add(1) + } + return nil +} +func expectFullBackendEvent(t *testing.T, ctx context.Context, receiver *fullBackendKernelReceiver, failures <-chan error, revision, path string, action api.Action, audit bool, rule string) sandboxpolicy.Verdict { + t.Helper() + check := func(v sandboxpolicy.Verdict) { + require.Equal(t, action, v.Verdict) + require.Equal(t, audit, v.WouldDeny) + require.Equal(t, rule, v.Rule) + require.True(t, v.MatchesComplete) + require.Equal(t, "matching_rule", v.Reason) + require.NotZero(t, v.CgroupID) + require.NotZero(t, v.PID) + require.Equal(t, os.Getenv("IG_IMAGE_EVIDENCE_CONTAINER_ID"), v.ContainerID) + require.NotZero(t, v.BindingPID) + require.NotZero(t, v.MountNamespace) + require.Len(t, v.PolicySet, 1) + require.Equal(t, types.UID("fixture-policy-uid"), v.PolicySet[0].Policy.UID) + require.Nil(t, v.UID) + require.Nil(t, v.Argv) + require.Nil(t, v.FromImage, "do not invent unreported predicate facts") + } + matches := func(v sandboxpolicy.Verdict) bool { + return v.Revision == revision && v.TargetPath == path && v.Rule == rule + } + for i, v := range receiver.backlog { + if matches(v) { + receiver.backlog = append(receiver.backlog[:i], receiver.backlog[i+1:]...) + check(v) + return v + } + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + for { + select { + case <-ctx.Done(): + t.Fatalf("missing verdict revision=%s path=%s rule=%s: %v", revision, path, rule, ctx.Err()) + case err := <-failures: + t.Fatalf("native telemetry error: %v", err) + case v := <-receiver.events: + if matches(v) { + check(v) + return v + } + require.Less(t, len(receiver.backlog), 256, "bounded fixture event backlog") + receiver.backlog = append(receiver.backlog, v) + } + } +} + +func fullBackendKernelPlan(revision, rootRole string, denied, audited int, container string) sandboxpolicy.Plan { + yes := true + actor := func(role string) *api.Actor { + return &api.Actor{Path: []string{"/probe/init", "/probe/role"}, FromImage: &yes, Ancestor: &api.Ancestor{Path: []string{"/probe/init", "/probe/role"}, Depth: 2}, Role: []string{role}, UID: []int64{0}, Container: []string{container}} + } + target := func(paths []string) api.ExecTarget { + return api.ExecTarget{PathTarget: api.PathTarget{Path: paths, FromImage: &yes, FSType: []string{"overlay"}}, Argv: &api.ArgvPredicate{Contains: []string{"--token=ok"}, Prefix: []string{"--token="}, StdinIsPipe: &yes}} + } + paths := make([]string, 13) + for i := range paths { + paths[i] = fmt.Sprintf("/probe/target-%d", i) + } + stage := target([]string{"/probe/role"}) + stage.SetRole = "worker" + return sandboxpolicy.Plan{Revision: revision, Policies: []sandboxpolicy.CompiledPolicy{{Identity: sandboxpolicy.PolicyIdentity{Kind: "SandboxPolicy", Name: "fixture", UID: "fixture-policy-uid"}, Revision: "policy-" + revision, Mode: "enforce", RootRole: rootRole, Exec: api.ExecSection{Default: api.ActionDeny, Rules: []api.ExecRule{ + {Rule: api.Rule{Name: "root-paths", Origin: "fixture/full-predicates", Action: api.ActionAllow, Actor: actor("agent")}, Target: target(paths)}, + {Rule: api.Rule{Name: "deny-control", Action: api.ActionDeny}, Target: api.ExecTarget{PathTarget: api.PathTarget{Path: []string{fmt.Sprintf("/probe/target-%d", denied)}}}}, + {Rule: api.Rule{Name: "audit-control", Action: api.ActionDeny, Mode: api.RuleAudit}, Target: api.ExecTarget{PathTarget: api.PathTarget{Path: []string{fmt.Sprintf("/probe/target-%d", audited)}}}}, + {Rule: api.Rule{Name: "root-role-control", Action: api.ActionDeny, Actor: &api.Actor{Role: []string{"agent"}}}, Target: api.ExecTarget{PathTarget: api.PathTarget{Path: []string{"/probe/target-12"}}}}, + {Rule: api.Rule{Name: "grant-worker", Action: api.ActionAllow, Actor: actor("agent")}, Target: stage}, + {Rule: api.Rule{Name: "inherited-worker", Action: api.ActionAllow, Actor: actor("worker")}, Target: target([]string{"/probe/target-12"})}, + }}}}} +} + +func fullBackendKernelImage(t *testing.T) (oras.ReadOnlyTarget, string) { + t.Helper() + if archive := os.Getenv("IG_FULL_BACKEND_ARCHIVE"); archive != "" { + store, err := orasoci.NewFromTar(context.Background(), archive) + require.NoError(t, err) + return store, "localhost/enforce-exec-owner:trusted-combined-fixture" + } + ctx := context.Background() + store := memory.New() + read := func(env string) []byte { + path := os.Getenv(env) + require.NotEmpty(t, path) + b, err := os.ReadFile(path) + require.NoError(t, err) + return b + } + push := func(media string, b []byte) ocispec.Descriptor { + d := content.NewDescriptorFromBytes(media, b) + require.NoError(t, store.Push(ctx, d, bytes.NewReader(b))) + return d + } + cfg := push("application/vnd.gadget.config.v1+yaml", read("IG_FULL_BACKEND_METADATA")) + layer := push("application/vnd.gadget.ebpf.program.v1+binary", read("IG_FULL_BACKEND_OBJECT")) + raw, err := json.Marshal(ocispec.Manifest{Versioned: specs.Versioned{SchemaVersion: 2}, Config: cfg, Layers: []ocispec.Descriptor{layer}}) + require.NoError(t, err) + manifest := push(ocispec.MediaTypeImageManifest, raw) + manifest.Platform = &ocispec.Platform{Architecture: goruntime.GOARCH, OS: "linux"} + raw, err = json.Marshal(ocispec.Index{Versioned: specs.Versioned{SchemaVersion: 2}, Manifests: []ocispec.Descriptor{manifest}}) + require.NoError(t, err) + desc := push(ocispec.MediaTypeImageIndex, raw) + const image = "localhost/enforce-exec-owner:trusted-combined-fixture" + require.NoError(t, store.Tag(ctx, desc, image)) + return store, image +} + +type fullBackendControl struct { + request, response int + buffer []byte +} + +func openFullBackendControl(ctx context.Context, pid uint32) (*fullBackendControl, error) { + root := fmt.Sprintf("/proc/%d/root/fixture-control", pid) + for { + if err := ctx.Err(); err != nil { + return nil, err + } + request, err := unix.Open(filepath.Join(root, "request"), unix.O_WRONLY|unix.O_NONBLOCK|unix.O_CLOEXEC, 0) + if err != nil { + if errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ENXIO) { + time.Sleep(20 * time.Millisecond) + continue + } + return nil, err + } + response, err := unix.Open(filepath.Join(root, "response"), unix.O_RDONLY|unix.O_NONBLOCK|unix.O_CLOEXEC, 0) + if err != nil { + unix.Close(request) + return nil, err + } + for _, fd := range []int{request, response} { + var stat unix.Stat_t + if err = unix.Fstat(fd, &stat); err != nil || stat.Mode&unix.S_IFMT != unix.S_IFIFO { + unix.Close(request) + unix.Close(response) + return nil, fmt.Errorf("fixture control is not FIFO: %w", err) + } + } + return &fullBackendControl{request: request, response: response}, nil + } +} +func (c *fullBackendControl) close() { unix.Close(c.request); unix.Close(c.response) } +func (c *fullBackendControl) command(ctx context.Context, command string) (int, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + bytes := []byte(command + "\n") + n, err := unix.Write(c.request, bytes) + if err != nil { + return 0, err + } + if n != len(bytes) { + return 0, fmt.Errorf("short fixture command write") + } + for { + if index := bytesIndexNewline(c.buffer); index >= 0 { + line := string(c.buffer[:index]) + c.buffer = c.buffer[index+1:] + value, ok := strings.CutPrefix(line, "RESULT ") + if !ok { + return 0, fmt.Errorf("unexpected fixture reply %q", line) + } + return strconv.Atoi(value) + } + if err := ctx.Err(); err != nil { + return 0, err + } + fds := []unix.PollFd{{Fd: int32(c.response), Events: unix.POLLIN}} + _, err := unix.Poll(fds, 20) + if errors.Is(err, unix.EINTR) { + continue + } + if err != nil { + return 0, err + } + if fds[0].Revents&unix.POLLIN == 0 { + continue + } + var buf [128]byte + n, err := unix.Read(c.response, buf[:]) + if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EINTR) { + continue + } + if err != nil { + return 0, err + } + if n == 0 { + return 0, fmt.Errorf("fixture response closed") + } + c.buffer = append(c.buffer, buf[:n]...) + if len(c.buffer) > 1024 { + return 0, fmt.Errorf("oversized fixture response") + } + } +} +func bytesIndexNewline(b []byte) int { + for i, c := range b { + if c == '\n' { + return i + } + } + return -1 +} + +func TestFullBackendKernelFixturePlan(t *testing.T) { + for _, revision := range []string{"plan-1", "plan-2"} { + p := fullBackendKernelPlan(revision, "agent", 0, 1, "app") + program, err := CompileFull(sandboxpolicy.Binding{ContainerName: "app"}, p, 1, nil) + require.NoError(t, err) + rule, err := fullpolicy.Decode[fullpolicy.KernelRule](program.Entries[fullpolicy.KernelRuleBase]) + require.NoError(t, err) + require.Equal(t, uint32(13), rule.TargetPath.Count) + } +} + +func proveFullBackendDeviceBarrier(t *testing.T, ctx context.Context, control *fullBackendControl, pid uint32) { + t.Helper() + registered, err := os.ReadFile("/proc/devices") + require.NoError(t, err) + used := map[uint32]bool{} + for _, line := range strings.Split(string(registered), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + n, err := strconv.ParseUint(fields[0], 10, 32) + if err == nil { + used[uint32(n)] = true + } + } + var major uint32 + for n := uint32(511); n >= 240; n-- { + if !used[n] { + major = n + break + } + } + require.NotZero(t, major, "need an unregistered fixture device major") + for _, kind := range []struct { + name, sys string + mode uint32 + }{{"char", "char", unix.S_IFCHR}, {"block", "block", unix.S_IFBLK}} { + device := fmt.Sprintf("%d:0", major) + _, err := os.Stat(filepath.Join("/sys/dev", kind.sys, device)) + require.True(t, os.IsNotExist(err), "device must not be registered") + name := fmt.Sprintf("/proc/%d/root/dev/shm/sandbox-policy-device-%s", pid, kind.name) + _, err = os.Lstat(name) + require.True(t, os.IsNotExist(err), "never replace a preexisting shm file") + require.NoError(t, unix.Mknod(name, kind.mode|0666, int(unix.Mkdev(major, 0)))) + func() { + defer func() { require.NoError(t, os.Remove(name)) }() + require.NoError(t, os.Chmod(name, 0666)) + code, err := control.command(ctx, "DEVICE "+kind.name) + require.NoError(t, err) + require.Equal(t, 77, code, "actual bound init must receive EPERM before opening unknown %s device", kind.name) + }() + } + t.Logf("actual bound init rejected unregistered char/block major %d opens with EPERM", major) +} diff --git a/pkg/sandboxpolicy/igbackend/fullbackend_test.go b/pkg/sandboxpolicy/igbackend/fullbackend_test.go new file mode 100644 index 0000000000..cb2454d548 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/fullbackend_test.go @@ -0,0 +1,251 @@ +package igbackend + +import ( + "context" + "errors" + "os" + "testing" + "time" + + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullpolicy" + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullruntime" + execruntime "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/runtime" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" +) + +type fullTestLease struct{ closed int } + +func (*fullTestLease) Check(context.Context) error { return nil } +func (*fullTestLease) InitProc() *os.File { return nil } +func (*fullTestLease) Cgroup() *os.File { return nil } +func (*fullTestLease) MountNamespace() *os.File { return nil } +func (*fullTestLease) HostMountNamespace() *os.File { return nil } +func (*fullTestLease) ImageOrigins() *execruntime.ImageOriginSnapshot { return nil } +func (l *fullTestLease) Close() error { l.closed++; return nil } + +type fullTestSource struct { + lease *fullTestLease + calls int +} + +func (s *fullTestSource) Prepare(context.Context, sandboxpolicy.Binding) (FullBindingLease, error) { + s.calls++ + return s.lease, nil +} + +type fullTestEngine struct { + instance *fullTestInstance + starts int + startErr error + transfer bool +} + +func (e *fullTestEngine) Start(ctx context.Context, _ string, c FullStartConfig, emit func(RawFullVerdict)) (FullInstance, error) { + e.starts++ + e.instance.program = c.Program + e.instance.emit = emit + if e.transfer { + l, err := c.Acquire(ctx) + if err != nil { + return e.instance, err + } + e.instance.lease = l + } + return e.instance, e.startErr +} + +type fullTestInstance struct { + stallUpdate bool + program *fullpolicy.KernelProgram + generation, pending uint64 + epochs map[string]uint64 + failUpdate, commitFailure, failStop bool + updates, stops, resumes int + lease fullruntime.BindingLease + emit func(RawFullVerdict) +} + +func (i *fullTestInstance) publish() (fullruntime.PublicationResult, error) { + h, _ := fullpolicy.Decode[fullpolicy.KernelHeader](i.program.Entries[0]) + if i.failUpdate { + i.failUpdate = false + return fullruntime.PublicationResult{}, errors.New("rejected") + } + i.generation = h.Generation + i.epochs = map[string]uint64{} + for j := uint32(0); j < h.PolicyCount; j++ { + p, _ := fullpolicy.Decode[fullpolicy.KernelPolicy](i.program.Entries[fullpolicy.KernelPolicyBase+j]) + i.epochs[i.program.Identities[p.UID]] = p.RootEpoch + } + if i.commitFailure { + i.pending = h.Generation + return fullruntime.PublicationResult{Committed: true, Generation: h.Generation}, errors.New("finalization") + } + i.pending = 0 + return fullruntime.PublicationResult{Committed: true, Generation: h.Generation, Admitting: true}, nil +} +func (i *fullTestInstance) Activate(context.Context) (fullruntime.PublicationResult, error) { + return i.publish() +} +func (i *fullTestInstance) Update(ctx context.Context, p *fullpolicy.KernelProgram) (fullruntime.PublicationResult, error) { + i.updates++ + if i.stallUpdate { + <-ctx.Done() + return fullruntime.PublicationResult{}, ctx.Err() + } + i.program = p + return i.publish() +} +func (i *fullTestInstance) Resume(context.Context) (fullruntime.PublicationResult, error) { + i.resumes++ + i.commitFailure = false + return i.publish() +} +func (i *fullTestInstance) Generation() uint64 { return i.generation } +func (i *fullTestInstance) PendingGeneration() uint64 { return i.pending } +func (i *fullTestInstance) Detached() bool { return i.lease == nil && i.stops > 0 && !i.failStop } +func (i *fullTestInstance) Epochs() map[string]uint64 { return i.epochs } +func (i *fullTestInstance) Check(context.Context) error { + if i.pending != 0 { + return errors.New("closed gate") + } + return nil +} +func (i *fullTestInstance) Stop(context.Context) error { + i.stops++ + if i.failStop { + return errors.New("drain") + } + if i.lease != nil { + err := i.lease.Close() + i.lease = nil + return err + } + return nil +} +func fullBackendFixture(t *testing.T) (*FullBackend, *fullTestEngine, *fullTestSource, sandboxpolicy.Binding) { + t.Helper() + e := &fullTestEngine{instance: &fullTestInstance{}, transfer: true} + s := &fullTestSource{lease: &fullTestLease{}} + b, err := NewFull(e, s, nil, nil) + if err != nil { + t.Fatal(err) + } + return b, e, s, sandboxpolicy.Binding{Runtime: "containerd", ContainerID: "id", ContainerName: "app", CgroupID: 19, PID: 20, MountNamespace: 21} +} +func TestFullBackendPersistentReloadAndResume(t *testing.T) { + b, e, s, binding := fullBackendFixture(t) + ctx := context.Background() + p := verdictPlan() + r, err := b.Replace(ctx, binding, p) + if err != nil || !r.Committed { + t.Fatal(r, err) + } + original := e.instance.epochs["uid-one"] + p.Revision = "next" + p.Policies[0].Revision = "policy-next" + e.instance.commitFailure = true + r, err = b.Replace(ctx, binding, p) + if err == nil || !r.Committed || !r.ProtectionUnconfirmed || r.ActiveRevision != "next" { + t.Fatal(r, err) + } + generation := e.instance.generation + r, err = b.Replace(ctx, binding, p) + if err != nil || !r.Committed || r.ProtectionUnconfirmed || e.instance.generation != generation || e.instance.resumes != 1 { + t.Fatal(r, err) + } + if e.starts != 1 || s.calls != 1 || e.instance.updates != 1 || e.instance.epochs["uid-one"] != original { + t.Fatal("owner or role epoch reset") + } + r, err = b.Replace(ctx, binding, p) + if err != nil || !r.Committed || e.instance.updates != 1 { + t.Fatal(r, err) + } +} +func TestFullBackendRejectedRevisionKeepsLastGood(t *testing.T) { + b, e, _, binding := fullBackendFixture(t) + ctx := context.Background() + p := verdictPlan() + if _, err := b.Replace(ctx, binding, p); err != nil { + t.Fatal(err) + } + old := e.instance.generation + p.Revision = "bad" + e.instance.failUpdate = true + r, err := b.Replace(ctx, binding, p) + if err == nil || r.Committed || r.ProtectionUnconfirmed || e.instance.generation != old { + t.Fatal(r, err) + } + p.Revision = "good" + r, err = b.Replace(ctx, binding, p) + if err != nil || !r.Committed || e.starts != 1 { + t.Fatal(r, err) + } +} +func TestFullBackendRetainsFailedStartupAndUntransferredLease(t *testing.T) { + for _, transfer := range []bool{false, true} { + t.Run(map[bool]string{false: "untransferred", true: "transferred"}[transfer], func(t *testing.T) { + b, e, s, binding := fullBackendFixture(t) + ctx := context.Background() + e.transfer = transfer + e.startErr = errors.New("startup") + e.instance.failStop = true + if _, err := b.Replace(ctx, binding, verdictPlan()); err == nil { + t.Fatal("accepted failed startup") + } + if s.lease.closed != 0 || len(b.records) != 1 { + t.Fatal("released resources before confirmed cleanup") + } + if r, err := b.Withdraw(ctx, binding); err == nil || r.Committed { + t.Fatal(r, err) + } + e.instance.failStop = false + if r, err := b.Withdraw(ctx, binding); err != nil || !r.Committed { + t.Fatal(r, err) + } + if s.lease.closed != 1 || len(b.records) != 0 { + t.Fatal("leaked or double closed lease") + } + }) + } +} + +func TestFullBackendRetiringOwnerIsNotHealthyLastGood(t *testing.T) { + b, e, _, binding := fullBackendFixture(t) + ctx := context.Background() + p := verdictPlan() + if _, err := b.Replace(ctx, binding, p); err != nil { + t.Fatal(err) + } + e.instance.failStop = true + if _, err := b.Withdraw(ctx, binding); err == nil { + t.Fatal("expected stop refusal") + } + r, err := b.Replace(ctx, binding, p) + if err == nil || r.Committed || !r.ProtectionUnconfirmed { + t.Fatal(r, err) + } +} + +func TestFullBackendBoundsStalledPublication(t *testing.T) { + b, e, _, binding := fullBackendFixture(t) + ctx := context.Background() + p := verdictPlan() + if _, err := b.Replace(ctx, binding, p); err != nil { + t.Fatal(err) + } + old := e.instance.Generation() + b.operationTimeout = 20 * time.Millisecond + e.instance.stallUpdate = true + p.Revision = "stalled" + started := time.Now() + r, err := b.Replace(ctx, binding, p) + if !errors.Is(err, context.DeadlineExceeded) || r.Committed || e.instance.Generation() != old || time.Since(started) > time.Second { + t.Fatal(r, err) + } + e.instance.stallUpdate = false + r, err = b.Replace(ctx, binding, p) + if err != nil || !r.Committed || e.starts != 1 { + t.Fatal(r, err) + } +} diff --git a/pkg/sandboxpolicy/igbackend/fullcatalog.go b/pkg/sandboxpolicy/igbackend/fullcatalog.go new file mode 100644 index 0000000000..138e6684a8 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/fullcatalog.go @@ -0,0 +1,104 @@ +package igbackend + +import ( + "fmt" + "sync" + + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullpolicy" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" +) + +// FullVerdictCatalogCapacity bounds provenance retained per persistent owner, +// including its active generation and at most one pending publication. Late +// events for evicted generations are dropped, never decoded with newer rules. +const FullVerdictCatalogCapacity = 16 + +// FullVerdictCatalog is local provenance, not proof of kernel publication or +// health. It must not be copied after first use. One catalogue belongs to one +// exact binding and must outlive that owner's event subscription. +type FullVerdictCatalog struct { + mu sync.RWMutex + binding sandboxpolicy.Binding + last, active, pending uint64 + decoders map[uint64]*FullVerdictDecoder +} + +func NewFullVerdictCatalog(binding sandboxpolicy.Binding) *FullVerdictCatalog { + return &FullVerdictCatalog{binding: binding, decoders: make(map[uint64]*FullVerdictDecoder)} +} + +// Stage validates and snapshots provenance BEFORE calling the kernel publisher. +// Once admitted (no pending publication), an attempt number is consumed even if +// validation fails. Resumption uses the existing staged generation, not Stage. +// Staged events can be decoded because kernel commit may precede its Go receipt. +func (c *FullVerdictCatalog) Stage(generation uint64, plan sandboxpolicy.Plan, program *fullpolicy.KernelProgram) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.pending != 0 { + return fmt.Errorf("verdict generation %d still pending", c.pending) + } + if generation == 0 || generation <= c.last { + return fmt.Errorf("verdict generation must increase beyond %d", c.last) + } + c.last = generation + d, err := NewFullVerdictDecoder(c.binding, plan, program) + if err != nil { + return err + } + if d.generation != generation { + return fmt.Errorf("verdict program generation does not match attempt") + } + if len(c.decoders) == FullVerdictCatalogCapacity { + var oldest uint64 + for g := range c.decoders { + if g != c.active && (oldest == 0 || g < oldest) { + oldest = g + } + } + delete(c.decoders, oldest) + } + c.decoders[generation] = d + c.pending = generation + return nil +} + +// Commit records an irreversible kernel commit, even if publication finalization +// failed. It is idempotent for the active generation. Never Abort a committed +// publication: retain its provenance while resuming the same kernel generation. +func (c *FullVerdictCatalog) Commit(generation uint64) error { + c.mu.Lock() + defer c.mu.Unlock() + if generation != 0 && c.active == generation { + return nil + } + if generation == 0 || c.pending != generation { + return fmt.Errorf("verdict generation %d is not pending", generation) + } + c.active, c.pending = generation, 0 + return nil +} + +// Abort removes only an uncommitted pending attempt after the kernel publisher +// confirms it did not commit. The attempt number is never reusable. +func (c *FullVerdictCatalog) Abort(generation uint64) error { + c.mu.Lock() + defer c.mu.Unlock() + if generation == 0 || c.pending != generation { + return fmt.Errorf("verdict generation %d is not pending", generation) + } + delete(c.decoders, generation) + c.pending = 0 + return nil +} + +func (c *FullVerdictCatalog) Decode(raw RawFullVerdict) (sandboxpolicy.Verdict, error) { + c.mu.RLock() + d := c.decoders[raw.Generation] + c.mu.RUnlock() + if d == nil { + return sandboxpolicy.Verdict{}, fmt.Errorf("unknown or evicted verdict generation %d", raw.Generation) + } + // Decoder snapshots are immutable; do not hold the catalogue lock while + // decoding, and never run exporter callbacks under a publication lock. + return d.Decode(raw) +} diff --git a/pkg/sandboxpolicy/igbackend/fullcatalog_test.go b/pkg/sandboxpolicy/igbackend/fullcatalog_test.go new file mode 100644 index 0000000000..7a30ab8e47 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/fullcatalog_test.go @@ -0,0 +1,205 @@ +package igbackend + +import ( + "fmt" + "math" + "sync" + "testing" + + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullpolicy" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" +) + +func catalogCandidate(t *testing.T, generation uint64) (sandboxpolicy.Plan, *fullpolicy.KernelProgram) { + t.Helper() + p := verdictPlan() + p.Revision = fmt.Sprintf("revision-%d", generation) + p.Policies[0].Exec.Rules[0].Name = fmt.Sprintf("rule-%d", generation) + k, err := CompileFull(sandboxpolicy.Binding{ContainerName: "app"}, p, generation, nil) + if err != nil { + t.Fatal(err) + } + return p, k +} +func catalogRaw(generation uint64) RawFullVerdict { + return RawFullVerdict{RawVerdict: RawVerdict{Generation: generation, Cgroup: 19, Mode: 2}, MatchedRules: [4]uint64{1}} +} +func newTestCatalog() *FullVerdictCatalog { + return NewFullVerdictCatalog(sandboxpolicy.Binding{CgroupID: 19, ContainerName: "app"}) +} +func stageCatalog(t *testing.T, c *FullVerdictCatalog, generation uint64) { + t.Helper() + p, k := catalogCandidate(t, generation) + if err := c.Stage(generation, p, k); err != nil { + t.Fatal(err) + } +} +func assertCatalogDecode(t *testing.T, c *FullVerdictCatalog, generation uint64) { + t.Helper() + v, err := c.Decode(catalogRaw(generation)) + if err != nil || v.Revision != fmt.Sprintf("revision-%d", generation) || v.Rule != fmt.Sprintf("rule-%d", generation) { + t.Fatalf("generation %d: wrong provenance %+v, %v", generation, v, err) + } +} + +func TestFullCatalogPublication(t *testing.T) { + c := newTestCatalog() + stageCatalog(t, c, 1) + assertCatalogDecode(t, c, 1) // Kernel can emit before Go receives commit. + if err := c.Commit(1); err != nil { + t.Fatal(err) + } + stageCatalog(t, c, 2) + assertCatalogDecode(t, c, 1) + assertCatalogDecode(t, c, 2) + p, k := catalogCandidate(t, 3) + if err := c.Stage(3, p, k); err == nil { + t.Fatal("accepted two pending publications") + } + if err := c.Commit(3); err == nil { + t.Fatal("committed an unstaged generation") + } + if err := c.Abort(1); err == nil { + t.Fatal("aborted active provenance") + } + if err := c.Abort(2); err != nil { + t.Fatal(err) + } + if _, err := c.Decode(catalogRaw(2)); err == nil { + t.Fatal("decoded aborted publication") + } + assertCatalogDecode(t, c, 1) + if err := c.Stage(2, p, k); err == nil { + t.Fatal("reused aborted generation") + } + if err := c.Stage(3, p, k); err != nil { + t.Fatal(err) + } + if err := c.Commit(3); err != nil { + t.Fatal(err) + } + if err := c.Commit(3); err != nil { + t.Fatal("commit retry failed", err) + } + if err := c.Abort(3); err == nil { + t.Fatal("aborted partially finalized kernel commit") + } + assertCatalogDecode(t, c, 1) // Delayed events retain old rule names. + assertCatalogDecode(t, c, 3) + stageCatalog(t, c, 4) + if err := c.Commit(3); err != nil { + t.Fatal(err) + } + if c.pending != 4 { + t.Fatal("stale idempotent commit affected newer proposal") + } + if err := c.Abort(3); err == nil { + t.Fatal("stale abort affected newer proposal") + } + if c.pending != 4 { + t.Fatal("stale abort cleared pending") + } +} + +func TestFullCatalogValidationAndSnapshots(t *testing.T) { + c := newTestCatalog() + stageCatalog(t, c, 1) + if err := c.Commit(1); err != nil { + t.Fatal(err) + } + p, k := catalogCandidate(t, 2) + if err := c.Stage(2, p, nil); err == nil { + t.Fatal("accepted nil program") + } + if err := c.Stage(2, p, k); err == nil { + t.Fatal("reused failed validation attempt") + } + if err := c.Stage(3, p, k); err == nil { + t.Fatal("accepted different IR generation") + } + p, k = catalogCandidate(t, 4) + if err := c.Stage(4, p, k); err != nil { + t.Fatal(err) + } + p.Revision = "mutated" + p.Policies[0].Exec.Rules[0].Name = "mutated" + k.Rules[0].Name = "mutated" + k.Entries[0][0] = 0 + assertCatalogDecode(t, c, 1) + assertCatalogDecode(t, c, 4) + raw := catalogRaw(4) + raw.Cgroup++ + if _, err := c.Decode(raw); err == nil { + t.Fatal("accepted wrong binding cgroup") + } + if _, err := c.Decode(catalogRaw(100)); err == nil { + t.Fatal("decoded unknown generation") + } + if err := c.Abort(4); err != nil { + t.Fatal(err) + } + // Reserved/invalid maximum still consumes its attempt and cannot wrap. + if err := c.Stage(math.MaxUint64, p, nil); err == nil { + t.Fatal("accepted invalid maximum") + } + if err := c.Stage(0, p, k); err == nil { + t.Fatal("wrapped generation") + } + if err := c.Stage(5, p, k); err == nil { + t.Fatal("reset attempt watermark") + } + assertCatalogDecode(t, c, 1) +} + +func TestFullCatalogBoundedHistory(t *testing.T) { + c := newTestCatalog() + for g := uint64(1); g <= FullVerdictCatalogCapacity+3; g++ { + stageCatalog(t, c, g) + if len(c.decoders) > FullVerdictCatalogCapacity { + t.Fatal("unbounded provenance") + } + if err := c.Commit(g); err != nil { + t.Fatal(err) + } + } + if _, err := c.Decode(catalogRaw(1)); err == nil { + t.Fatal("decoded evicted generation") + } + active := uint64(FullVerdictCatalogCapacity + 3) + for g := active + 1; g < active+FullVerdictCatalogCapacity*2; g++ { + stageCatalog(t, c, g) + assertCatalogDecode(t, c, active) + if err := c.Abort(g); err != nil { + t.Fatal(err) + } + assertCatalogDecode(t, c, active) + } + if c.active != active || len(c.decoders) > FullVerdictCatalogCapacity { + t.Fatal("failed proposals displaced active") + } +} + +func TestFullCatalogConcurrentReads(t *testing.T) { + c := newTestCatalog() + stageCatalog(t, c, 1) + if err := c.Commit(1); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 1000; j++ { + assertCatalogDecode(t, c, 1) + } + }() + } + for g := uint64(2); g < 40; g++ { + stageCatalog(t, c, g) + if err := c.Abort(g); err != nil { + t.Fatal(err) + } + } + wg.Wait() +} diff --git a/pkg/sandboxpolicy/igbackend/fullcompile.go b/pkg/sandboxpolicy/igbackend/fullcompile.go new file mode 100644 index 0000000000..924ced5494 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/fullcompile.go @@ -0,0 +1,48 @@ +package igbackend + +import ( + "fmt" + + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullpolicy" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" +) + +// CompileFull preserves the complete resolved exec vocabulary and policy-local +// identities. It prepares immutable data only: it neither attests kernel facts +// nor authorizes role bootstrap, image evidence or native publication. The +// interim path-only Compile/Backend remains unchanged until its persistent owner +// is available. activatedRoleEpochs is the persistent owner's SUCCESSFUL UID +// activation catalogue; nil is appropriate only for a fresh owner, not reload. +// The matching expanded IG module is required to build this bridge. +func CompileFull(binding sandboxpolicy.Binding, plan sandboxpolicy.Plan, generation uint64, activatedRoleEpochs map[string]uint64) (*fullpolicy.KernelProgram, error) { + if plan.Revision == "" || binding.ContainerName == "" { + return nil, fmt.Errorf("full exec requires plan revision and exact container name") + } + policies := make([]fullpolicy.Policy, 0, len(plan.Policies)) + for _, p := range plan.Policies { + if p.Identity.UID == "" || p.Revision == "" { + return nil, fmt.Errorf("full exec requires immutable policy UID and revision") + } + policy := fullpolicy.Policy{ID: string(p.Identity.UID), Revision: p.Revision, Mode: fullpolicy.Mode(p.Mode), RootRole: p.RootRole, Default: fullpolicy.Action(p.Exec.Default)} + for _, r := range p.Exec.Rules { + if len(r.Target.PathClass) != 0 { + return nil, fmt.Errorf("rule %q has unresolved path classes", r.Name) + } + rule := fullpolicy.Rule{Name: r.Name, Origin: r.Origin, Action: fullpolicy.Action(r.Action), Mode: fullpolicy.Mode(r.Mode), Target: fullpolicy.Target{Path: r.Target.Path, FromImage: r.Target.FromImage, FSType: r.Target.FSType, SetRole: r.Target.SetRole}} + if a := r.Actor; a != nil { + rule.Actor = fullpolicy.Actor{Path: a.Path, FromImage: a.FromImage, Role: a.Role, UID: a.UID, Container: a.Container} + if a.Ancestor != nil { + rule.Actor.Ancestor = &fullpolicy.Ancestor{Path: a.Ancestor.Path, Depth: a.Ancestor.Depth} + } + } + if a := r.Target.Argv; a != nil { + rule.Target.Argv = &fullpolicy.Argv{Contains: a.Contains, Prefix: a.Prefix, StdinIsPipe: a.StdinIsPipe} + } + policy.Rules = append(policy.Rules, rule) + } + policies = append(policies, policy) + } + // CompileKernel validates and deep-copies all caller-owned slices and pointers; + // no API object survives as mutable kernel matching input. + return fullpolicy.CompileKernelWithRoleEpochs(policies, generation, binding.ContainerName, activatedRoleEpochs) +} diff --git a/pkg/sandboxpolicy/igbackend/fullcompile_test.go b/pkg/sandboxpolicy/igbackend/fullcompile_test.go new file mode 100644 index 0000000000..cf068e0e94 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/fullcompile_test.go @@ -0,0 +1,135 @@ +package igbackend + +import ( + "fmt" + "reflect" + "strings" + "testing" + + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullpolicy" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" + api "github.com/kubescape/storage/pkg/apis/sandbox/v1alpha1" +) + +func fullPlan() sandboxpolicy.Plan { + yes, no := true, false + return sandboxpolicy.Plan{Revision: "combined-revision", Policies: []sandboxpolicy.CompiledPolicy{ + {Identity: sandboxpolicy.PolicyIdentity{UID: "uid-one"}, Revision: "revision-one", Mode: "enforce", RootRole: "root", Exec: api.ExecSection{Rules: []api.ExecRule{{Rule: api.Rule{Name: "full-rule", Origin: "imported/profile", Action: "allow", Mode: "audit", Actor: &api.Actor{Path: []string{"/actor/*"}, FromImage: &no, Ancestor: &api.Ancestor{Path: []string{"/ancestor"}, Depth: 4}, Role: []string{"root", "worker"}, UID: []int64{0, 1000}, Container: []string{"app", "sidecar"}}}, Target: api.ExecTarget{PathTarget: api.PathTarget{Path: []string{"/bin/*"}, FromImage: &yes, FSType: []string{"tmpfs", "memfd", "deleted", "overlay"}}, Argv: &api.ArgvPredicate{Contains: []string{"needle", "other"}, Prefix: []string{"/bin/", "--"}, StdinIsPipe: &no}, SetRole: "worker"}}}}}, + {Identity: sandboxpolicy.PolicyIdentity{UID: "uid-two"}, Revision: "revision-two", Mode: "audit", RootRole: "other-root", Exec: api.ExecSection{Default: "deny"}}, + }} +} +func TestCompileFullPreservesResolvedVocabulary(t *testing.T) { + plan := fullPlan() + got, err := CompileFull(sandboxpolicy.Binding{ContainerName: "app"}, plan, 7, nil) + if err != nil { + t.Fatal(err) + } + h, err := fullpolicy.Decode[fullpolicy.KernelHeader](got.Entries[0]) + if err != nil { + t.Fatal(err) + } + if h.Generation != 7 || h.PolicyCount != 2 || h.RuleCount != 1 { + t.Fatalf("header: %+v", h) + } + r, err := fullpolicy.Decode[fullpolicy.KernelRule](got.Entries[h.RuleBase]) + if err != nil { + t.Fatal(err) + } + if r.Mode != 1 || r.Deny != 0 || r.ActorImage != 0 || r.TargetImage != 1 || r.Stdin != 0 || r.AncestorDepth != 4 || r.SetRole == 0 { + t.Fatalf("scalars: %+v", r) + } + counts := []uint32{r.ActorPath.Count, r.TargetPath.Count, r.AncestorPath.Count, r.ActorRole.Count, r.UID.Count, r.Container.Count, r.FSType.Count, r.ArgContains.Count, r.ArgPrefix.Count} + if !reflect.DeepEqual(counts, []uint32{1, 1, 1, 2, 2, 2, 4, 2, 2}) { + t.Fatalf("lost predicates: %v", counts) + } + for i, want := range []fullpolicy.KernelPolicy{{Default: 0, Mode: 2}, {Default: 2, Mode: 1}} { + p, err := fullpolicy.Decode[fullpolicy.KernelPolicy](got.Entries[fullpolicy.KernelPolicyBase+i]) + if err != nil { + t.Fatal(err) + } + if p.UID == 0 || p.RootRole == 0 || p.Default != want.Default || p.Mode != want.Mode { + t.Fatalf("policy%d: %+v", i, p) + } + } + if len(got.Rules) != 1 || got.Rules[0].PolicyID != "uid-one" || got.Rules[0].Revision != "revision-one" || got.Rules[0].Origin != "imported/profile" { + t.Fatalf("provenance: %+v", got.Rules) + } + // API mutation after compilation cannot alter any frozen-map bytes or names. + before := append([]fullpolicy.Blob(nil), got.Entries...) + plan.Policies[0].Exec.Rules[0].Actor.Path[0] = "/mutated" + *plan.Policies[0].Exec.Rules[0].Target.FromImage = false + if !reflect.DeepEqual(before, got.Entries) { + t.Fatal("compiled entries alias API") + } +} +func TestCompileFullBeyondEightPaths(t *testing.T) { + p := fullPlan() + p.Policies = p.Policies[:1] + r := &p.Policies[0].Exec.Rules[0] + r.Target.Path = nil + for i := 0; i < 128; i++ { + r.Target.Path = append(r.Target.Path, fmt.Sprintf("/bin/tool-%d", i)) + } + got, err := CompileFull(sandboxpolicy.Binding{ContainerName: "app"}, p, 1, nil) + if err != nil { + t.Fatal(err) + } + rule, err := fullpolicy.Decode[fullpolicy.KernelRule](got.Entries[fullpolicy.KernelRuleBase]) + if err != nil { + t.Fatal(err) + } + if rule.TargetPath.Count != 128 { + t.Fatalf("truncated paths: %d", rule.TargetPath.Count) + } +} +func TestCompileFullRefusesUnresolvedOrInvalidCandidates(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(*sandboxpolicy.Plan) + }{ + {"revision", func(p *sandboxpolicy.Plan) { p.Revision = "" }}, + {"uid", func(p *sandboxpolicy.Plan) { p.Policies[0].Identity.UID = "" }}, + {"policy-revision", func(p *sandboxpolicy.Plan) { p.Policies[0].Revision = "" }}, + {"duplicate-uid", func(p *sandboxpolicy.Plan) { p.Policies[1].Identity.UID = p.Policies[0].Identity.UID }}, + {"unresolved-class", func(p *sandboxpolicy.Plan) { p.Policies[0].Exec.Rules[0].Target.PathClass = []string{"class"} }}, + {"unknown-mode", func(p *sandboxpolicy.Plan) { p.Policies[0].Mode = "off" }}, + {"unknown-fs", func(p *sandboxpolicy.Plan) { p.Policies[0].Exec.Rules[0].Target.FSType = []string{"guess"} }}, + {"oversized-path", func(p *sandboxpolicy.Plan) { + p.Policies[0].Exec.Rules[0].Target.Path = []string{"/" + strings.Repeat("x", 256)} + }}, + } { + t.Run(tc.name, func(t *testing.T) { + p := fullPlan() + tc.mutate(&p) + if got, err := CompileFull(sandboxpolicy.Binding{ContainerName: "app"}, p, 1, nil); err == nil || got != nil { + t.Fatal("invalid candidate admitted") + } + }) + } + if _, err := CompileFull(sandboxpolicy.Binding{}, fullPlan(), 1, nil); err == nil { + t.Fatal("missing container name admitted") + } + if _, err := CompileFull(sandboxpolicy.Binding{ContainerName: "app"}, fullPlan(), 0, nil); err == nil { + t.Fatal("zero generation admitted") + } +} + +func TestCompileFullRetainsAcceptedRoleEpochs(t *testing.T) { + p, err := CompileFull(sandboxpolicy.Binding{ContainerName: "app"}, fullPlan(), 9, map[string]uint64{"uid-one": 2}) + if err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + entry, err := fullpolicy.Decode[fullpolicy.KernelPolicy](p.Entries[fullpolicy.KernelPolicyBase+i]) + if err != nil { + t.Fatal(err) + } + want := uint64(9) + if p.Identities[entry.UID] == "uid-one" { + want = 2 + } + if entry.RootEpoch != want { + t.Fatalf("UID epoch lost: %+v", entry) + } + } +} diff --git a/pkg/sandboxpolicy/igbackend/fullengine.go b/pkg/sandboxpolicy/igbackend/fullengine.go new file mode 100644 index 0000000000..3466e0671d --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/fullengine.go @@ -0,0 +1,74 @@ +package igbackend + +import ( + "context" + + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullpolicy" + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullruntime" + execruntime "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/runtime" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" +) + +// FullStartConfig supplies trusted runtime evidence, never policy annotations. +// Acquire transfers ownership of its lease (including partial error results) to +// the instance. ImageOrigins must remain pinned by that lease until Stop succeeds. +type FullStartConfig struct { + Binding sandboxpolicy.Binding + Program *fullpolicy.KernelProgram + Acquire func(context.Context) (fullruntime.BindingLease, error) + ImageOrigins *execruntime.ImageOriginSnapshot +} + +// FullEngine prepares one persistent owner without activating its first policy. +// Install event provenance BEFORE Start and Activate. A non-nil instance returned +// with an error still owns resources: retain it and retry Stop until successful. +// Event callbacks must not reenter lifecycle methods or block on their locks. +type FullEngine interface { + Start(context.Context, string, FullStartConfig, func(RawFullVerdict)) (FullInstance, error) +} + +// FullInstance reloads the same kernel owner; it never replaces the gadget. +// A publication error may have Committed=true. Resume the same pending generation +// rather than creating another owner or rolling back successful role epochs. +type FullInstance interface { + Activate(context.Context) (fullruntime.PublicationResult, error) + Update(context.Context, *fullpolicy.KernelProgram) (fullruntime.PublicationResult, error) + Resume(context.Context) (fullruntime.PublicationResult, error) + Generation() uint64 + PendingGeneration() uint64 + Epochs() map[string]uint64 + Check(context.Context) error + // Detached confirms manager teardown, even if releasing pins still fails. + Detached() bool + Stop(context.Context) error +} + +// FullKernelCounterSource is optional telemetry, independent of readiness. +// Successful Stop must retain a Final snapshot or an explicit unavailable error. +type FullKernelCounterSource interface { + KernelEventCounters() (fullruntime.KernelEventCounters, error) +} + +// KernelEventTotals contains successfully read kernel counter contributions. +// On read failure, the last successful sample is retained as a lower bound. +// These counters exclude early native owner refusals; Lost measures failed ring +// reservations, not userspace queue loss or complete denied-exec event coverage. +type KernelEventTotals struct { + Submitted, Lost, ObservationErrors uint64 +} + +// KernelEventStats covers this backend's lifetime (not process restarts). +// Owned includes partial-startup and retiring instances. Retired retains final +// snapshots after instance removal. Unavailable counts MUST be checked before +// interpreting zero loss. Live snapshots are not atomic across CPUs/instances. +type KernelEventStats struct { + Owned, Retired KernelEventTotals + OwnedInstances, OwnedUnavailable uint64 + RetiredInstances, RetiredUnavailable uint64 +} + +// FullPendingAborter optionally discards an uncommitted candidate without +// changing the last committed policy. A committed candidate must not abort. +type FullPendingAborter interface { + AbortPending(context.Context) error +} diff --git a/pkg/sandboxpolicy/igbackend/fulllease_linux.go b/pkg/sandboxpolicy/igbackend/fulllease_linux.go new file mode 100644 index 0000000000..a39fd77777 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/fulllease_linux.go @@ -0,0 +1,404 @@ +package igbackend + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + + execruntime "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/runtime" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" + "golang.org/x/sys/unix" +) + +// ContainerdFullSource owns one trusted containerd connection. Close must follow +// all native owners' confirmed detach and lease release; it refuses outstanding +// preparations/leases. A failed Close seals admission, and can be retried. +// +// The administrator must provide a protected host root, host proc/cgroup mounts +// and containerd socket/parent directory from the same host. These privileged +// inputs are not authenticated by policy metadata or by merely naming a path. +// The host-proc check additionally requires Linux's initial PID namespace and +// pins PID1's current mount namespace; unsupported/container-local proc fails. +type ContainerdFullSource struct { + mu sync.Mutex + source imageEvidenceSource + closeSource func() error + hostRoot string + validator LinuxValidator + limits ImageIndexLimits + outstanding uint64 + closing, closed bool +} + +func NewContainerdFullSource(socket, hostRoot, procRoot, cgroupRoot string, limits ImageIndexLimits) (*ContainerdFullSource, error) { + for _, p := range []string{hostRoot, procRoot, cgroupRoot} { + if !filepath.IsAbs(p) || filepath.Clean(p) != p { + return nil, fmt.Errorf("canonical administrator host paths required") + } + } + normalized, err := normalizeIndexLimits(limits) + if err != nil { + return nil, err + } + source, err := newContainerdImageSource(socket) + if err != nil { + return nil, err + } + return &ContainerdFullSource{source: source, closeSource: source.Close, hostRoot: hostRoot, validator: LinuxValidator{ProcRoot: procRoot, CgroupRoot: cgroupRoot}, limits: normalized}, nil +} + +func (s *ContainerdFullSource) Prepare(ctx context.Context, binding sandboxpolicy.Binding) (FullBindingLease, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + s.mu.Lock() + if s.closing || s.closed || s.source == nil { + s.mu.Unlock() + return nil, fmt.Errorf("containerd full source is closed") + } + if s.outstanding >= FullBackendMaxBindings { + s.mu.Unlock() + return nil, fmt.Errorf("full binding source capacity exceeded") + } + s.outstanding++ + s.mu.Unlock() + lease := &containerdFullLease{owner: s} + fail := func(err error) (FullBindingLease, error) { + if cleanup := lease.Close(); cleanup != nil { + return lease, errors.Join(err, cleanup) + } + return nil, err + } + var err error + lease.host, err = pinHostNamespaces(s.validator.ProcRoot) + if err != nil { + return fail(err) + } + process, err := s.validator.Acquire(ctx, binding) + if err != nil { + return fail(err) + } + // This concrete validator is local and always returns *linuxLease. + lease.process = process.(*linuxLease) + lease.image, err = acquireImageEvidence(ctx, s.source, lease.process, s.hostRoot, s.limits) + if err != nil { + return fail(err) + } + lease.origins, err = snapshotImageOrigins(lease.image) + if err != nil { + return fail(err) + } + if err = lease.Check(ctx); err != nil { + return fail(err) + } + return lease, nil +} +func (s *ContainerdFullSource) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil + } + s.closing = true + if s.outstanding != 0 { + return fmt.Errorf("containerd full source still owns %d binding leases/preparations", s.outstanding) + } + if s.closeSource != nil { + if err := s.closeSource(); err != nil { + return err + } + } + s.closed = true + return nil +} + +type containerdFullLease struct { + mu sync.Mutex + owner *ContainerdFullSource + process *linuxLease + image *trustedImageLease + host *hostNamespacePins + origins *execruntime.ImageOriginSnapshot + closing, closed bool +} + +func snapshotImageOrigins(image *trustedImageLease) (*execruntime.ImageOriginSnapshot, error) { + image.mu.Lock() + defer image.mu.Unlock() + if image.closed || image.failure != nil || !image.Complete || len(image.Inodes) > execruntime.MaxImageOrigins { + return nil, fmt.Errorf("complete bounded image evidence required") + } + snapshot := &execruntime.ImageOriginSnapshot{Complete: true, Keys: make([]execruntime.ImageOriginKey, len(image.Inodes))} + for i, key := range image.Inodes { + snapshot.Keys[i] = execruntime.ImageOriginKey{Inode: key.Inode, Major: key.Major, Minor: key.Minor} + } + if err := execruntime.ValidateImageOriginSnapshot(snapshot); err != nil { + return nil, err + } + return snapshot, nil +} +func (l *containerdFullLease) Check(ctx context.Context) error { + l.mu.Lock() + defer l.mu.Unlock() + if l.closing || l.closed || l.process == nil || l.image == nil || l.host == nil { + return fmt.Errorf("full binding lease unavailable") + } + if err := ctx.Err(); err != nil { + return err + } + if err := l.host.check(); err != nil { + return err + } + if err := l.image.CheckContext(ctx); err != nil { + return err + } + // A same-host mount namespace is never made safe by policy exemptions. + host, err := namespaceIdentity(l.host.mount, unix.CLONE_NEWNS) + if err != nil { + return err + } + workload, err := namespaceIdentity(l.process.namespace, unix.CLONE_NEWNS) + if err != nil { + return err + } + if host == workload { + return fmt.Errorf("host mount namespace cannot be bound") + } + return nil +} +func (l *containerdFullLease) InitProc() *os.File { + l.mu.Lock() + defer l.mu.Unlock() + if l.process == nil { + return nil + } + return l.process.proc +} +func (l *containerdFullLease) Cgroup() *os.File { + l.mu.Lock() + defer l.mu.Unlock() + if l.process == nil { + return nil + } + return l.process.cgroup +} +func (l *containerdFullLease) MountNamespace() *os.File { + l.mu.Lock() + defer l.mu.Unlock() + if l.process == nil { + return nil + } + return l.process.namespace +} +func (l *containerdFullLease) HostMountNamespace() *os.File { + l.mu.Lock() + defer l.mu.Unlock() + if l.host == nil { + return nil + } + return l.host.mount +} +func (l *containerdFullLease) ImageOrigins() *execruntime.ImageOriginSnapshot { + l.mu.Lock() + defer l.mu.Unlock() + if l.closing || l.closed || l.origins == nil { + return nil + } + out := *l.origins + out.Keys = append([]execruntime.ImageOriginKey(nil), out.Keys...) + return &out +} + +// Close consumes the lease even when an OS close reports an error. The native +// owner drops its reference after ReleaseAfterDetach, so every owned handle and +// the shared-source reservation must be released in this call, not on a retry. +func (l *containerdFullLease) Close() error { + l.mu.Lock() + defer l.mu.Unlock() + if l.closed { + return nil + } + l.closing = true + var errs []error + // Evidence owns borrowed process references, so release it before proc pins. + if l.image != nil { + errs = append(errs, l.image.Close()) + l.image = nil + } + if l.process != nil { + errs = append(errs, l.process.Close()) + l.process = nil + } + if l.host != nil { + errs = append(errs, l.host.close()) + l.host = nil + } + l.origins = nil + l.closed = true + if l.owner != nil { + l.owner.mu.Lock() + l.owner.outstanding-- + l.owner.mu.Unlock() + l.owner = nil + } + return errors.Join(errs...) +} + +// Linux UAPI include/uapi/linux/nsfs.h: enum init_ns_ino. Unlike NS_GET_PARENT +// returning EPERM (also permission failure), this identifies the initial PID +// namespace, not merely a namespace whose parent the caller cannot inspect. +const initialPIDNamespaceInode = uint64(0xEFFFFFFC) + +type hostNamespacePins struct{ proc, pid, mount *os.File } + +func pinHostNamespaces(procRoot string) (*hostNamespacePins, error) { + if !filepath.IsAbs(procRoot) || filepath.Clean(procRoot) != procRoot { + return nil, fmt.Errorf("canonical host proc required") + } + fd, err := unix.Openat2(unix.AT_FDCWD, filepath.Join(procRoot, "1"), &unix.OpenHow{Flags: unix.O_RDONLY | unix.O_DIRECTORY | unix.O_CLOEXEC, Resolve: unix.RESOLVE_NO_SYMLINKS}) + if err != nil { + return nil, err + } + pins := &hostNamespacePins{proc: os.NewFile(uintptr(fd), "host init proc")} + fail := func(err error) (*hostNamespacePins, error) { return nil, errors.Join(err, pins.close()) } + var fs unix.Statfs_t + if err = unix.Fstatfs(fd, &fs); err != nil { + return fail(err) + } + if fs.Type != unix.PROC_SUPER_MAGIC { + return fail(fmt.Errorf("host proc is not procfs")) + } + pins.pid, err = openNamespace(pins.proc, "pid") + if err != nil { + return fail(err) + } + pins.mount, err = openNamespace(pins.proc, "mnt") + if err != nil { + return fail(err) + } + if err = pins.check(); err != nil { + return fail(err) + } + return pins, nil +} +func openNamespace(proc *os.File, name string) (*os.File, error) { + fd, err := unix.Openat(int(proc.Fd()), "ns/"+name, unix.O_RDONLY|unix.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + return os.NewFile(uintptr(fd), name+" namespace"), nil +} +func namespaceIdentity(file *os.File, kind int) ([2]uint64, error) { + if file == nil { + return [2]uint64{}, fmt.Errorf("namespace pin required") + } + fd := int(file.Fd()) + var fs unix.Statfs_t + if err := unix.Fstatfs(fd, &fs); err != nil { + return [2]uint64{}, err + } + if fs.Type != unix.NSFS_MAGIC { + return [2]uint64{}, fmt.Errorf("namespace is not NSFS") + } + actual, err := unix.IoctlRetInt(fd, unix.NS_GET_NSTYPE) + if err != nil { + return [2]uint64{}, err + } + if actual != kind { + return [2]uint64{}, fmt.Errorf("unexpected namespace type") + } + var stat unix.Stat_t + if err = unix.Fstat(fd, &stat); err != nil { + return [2]uint64{}, err + } + return [2]uint64{uint64(stat.Dev), stat.Ino}, nil +} +func (h *hostNamespacePins) check() error { + pid, err := namespaceIdentity(h.pid, unix.CLONE_NEWPID) + if err != nil { + return err + } + if pid[1] != initialPIDNamespaceInode { + return fmt.Errorf("host proc is not in the initial PID namespace") + } + if _, err = namespaceIdentity(h.mount, unix.CLONE_NEWNS); err != nil { + return err + } + fd, err := unix.Openat(int(h.proc.Fd()), "status", unix.O_RDONLY|unix.O_CLOEXEC, 0) + if err != nil { + return err + } + status := os.NewFile(uintptr(fd), "host init status") + data, e := io.ReadAll(io.LimitReader(status, 64*1024+1)) + ce := status.Close() + if e != nil || ce != nil { + return errors.Join(e, ce) + } + if err = validateHostInitStatus(data); err != nil { + return err + } + for _, item := range []struct { + name string + file *os.File + kind int + }{{"pid", h.pid, unix.CLONE_NEWPID}, {"mnt", h.mount, unix.CLONE_NEWNS}} { + current, err := openNamespace(h.proc, item.name) + if err != nil { + return err + } + actual, e := namespaceIdentity(current, item.kind) + ce := current.Close() + if e != nil || ce != nil { + return errors.Join(e, ce) + } + pinned, e := namespaceIdentity(item.file, item.kind) + if e != nil { + return e + } + if actual != pinned { + return fmt.Errorf("host init namespace changed") + } + } + return nil +} +func validateHostInitStatus(data []byte) error { + if len(data) > 64*1024 { + return fmt.Errorf("oversized host init status") + } + required := map[string]bool{"Pid": false, "Tgid": false, "NSpid": false, "NStgid": false} + for _, line := range strings.Split(string(data), "\n") { + key, value, ok := strings.Cut(line, ":") + if !ok { + continue + } + seen, needed := required[key] + if !needed { + continue + } + if seen || strings.TrimSpace(value) != "1" { + return fmt.Errorf("host proc must identify initial PID1") + } + required[key] = true + } + for _, seen := range required { + if !seen { + return fmt.Errorf("incomplete host init status") + } + } + return nil +} +func (h *hostNamespacePins) close() error { + var errs []error + for _, slot := range []**os.File{&h.mount, &h.pid, &h.proc} { + if *slot != nil { + errs = append(errs, (*slot).Close()) + *slot = nil + } + } + return errors.Join(errs...) +} diff --git a/pkg/sandboxpolicy/igbackend/fulllease_linux_test.go b/pkg/sandboxpolicy/igbackend/fulllease_linux_test.go new file mode 100644 index 0000000000..92029f9ab4 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/fulllease_linux_test.go @@ -0,0 +1,195 @@ +package igbackend + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/kubescape/node-agent/pkg/sandboxpolicy" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +func TestFullLeaseHostInitStatus(t *testing.T) { + good := "Name:\tsystemd\nPid:\t1\nTgid:\t1\nNSpid:\t1\nNStgid:\t1\n" + require.NoError(t, validateHostInitStatus([]byte(good))) + for name, bad := range map[string]string{ + "nested": strings.Replace(good, "NSpid:\t1", "NSpid:\t99 1", 1), + "other": strings.Replace(good, "Pid:\t1", "Pid:\t2", 1), + "missing": strings.Replace(good, "NStgid:\t1\n", "", 1), + "duplicate": good + "Pid: 1\n", + "oversized": strings.Repeat("x", 65537), + } { + t.Run(name, func(t *testing.T) { require.Error(t, validateHostInitStatus([]byte(bad))) }) + } +} +func TestFullLeaseHostPinsRejectForgedProcAndNamespaces(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(root, "1"), 0700)) + pins, err := pinHostNamespaces(root) + require.ErrorContains(t, err, "not procfs") + require.Nil(t, pins) + link := filepath.Join(t.TempDir(), "proc") + require.NoError(t, os.Symlink("/proc", link)) + pins, err = pinHostNamespaces(link) + require.Error(t, err) + require.Nil(t, pins) + file, err := os.CreateTemp(t.TempDir(), "not-namespace") + require.NoError(t, err) + defer file.Close() + _, err = namespaceIdentity(file, unix.CLONE_NEWNS) + require.ErrorContains(t, err, "not NSFS") + _, err = namespaceIdentity(nil, unix.CLONE_NEWNS) + require.Error(t, err) +} +func TestFullLeaseCurrentNamespaceType(t *testing.T) { + f, err := os.Open("/proc/self/ns/mnt") + if errors.Is(err, os.ErrPermission) { + t.Skip("namespace handles require proc access") + } + require.NoError(t, err) + defer f.Close() + _, err = namespaceIdentity(f, unix.CLONE_NEWNS) + require.NoError(t, err) + _, err = namespaceIdentity(f, unix.CLONE_NEWPID) + require.ErrorContains(t, err, "unexpected namespace type") +} +func TestFullLeaseSourceOwnershipAndClose(t *testing.T) { + var closes atomic.Int32 + source := &ContainerdFullSource{source: fakeImageEvidenceSource{}, outstanding: 1, closeSource: func() error { closes.Add(1); return nil }} + lease := &containerdFullLease{owner: source} + require.ErrorContains(t, source.Close(), "still owns 1") + require.Equal(t, int32(0), closes.Load()) + got, err := source.Prepare(context.Background(), sandboxpolicy.Binding{}) + require.ErrorContains(t, err, "closed") + require.Nil(t, got) + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { defer wg.Done(); require.NoError(t, lease.Close()) }() + } + wg.Wait() + require.Equal(t, uint64(0), source.outstanding) + require.Equal(t, int32(0), closes.Load(), "lease must not close shared runtime client") + require.NoError(t, source.Close()) + require.NoError(t, source.Close()) + require.Equal(t, int32(1), closes.Load()) +} +func TestFullLeaseFailedPrepareReleasesReservation(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(root, "1"), 0700)) + closes := 0 + source := &ContainerdFullSource{source: fakeImageEvidenceSource{}, validator: LinuxValidator{ProcRoot: root}, closeSource: func() error { closes++; return nil }} + lease, err := source.Prepare(context.Background(), sandboxpolicy.Binding{}) + require.Error(t, err) + require.Nil(t, lease) + require.Zero(t, source.outstanding) + require.NoError(t, source.Close()) + require.Equal(t, 1, closes) +} +func TestFullLeaseSourceCancellationAndCapacity(t *testing.T) { + source := &ContainerdFullSource{source: fakeImageEvidenceSource{}} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + lease, err := source.Prepare(ctx, sandboxpolicy.Binding{}) + require.ErrorIs(t, err, context.Canceled) + require.Nil(t, lease) + require.Zero(t, source.outstanding) + source.outstanding = FullBackendMaxBindings + lease, err = source.Prepare(context.Background(), sandboxpolicy.Binding{}) + require.ErrorContains(t, err, "capacity") + require.Nil(t, lease) + require.Equal(t, uint64(FullBackendMaxBindings), source.outstanding) +} +func TestFullLeaseSourceCloseRetry(t *testing.T) { + fault := errors.New("close runtime") + source := &ContainerdFullSource{closeSource: func() error { return fault }} + require.ErrorIs(t, source.Close(), fault) + require.False(t, source.closed) + require.True(t, source.closing) + source.closeSource = func() error { return nil } + require.NoError(t, source.Close()) + require.True(t, source.closed) +} +func TestFullLeaseOriginsAreBoundedSnapshots(t *testing.T) { + image := &trustedImageLease{Complete: true, Inodes: []ImageInode{{Inode: 10, Major: 8, Minor: 1}}} + snapshot, err := snapshotImageOrigins(image) + require.NoError(t, err) + image.Inodes[0].Inode = 11 + require.Equal(t, uint64(10), snapshot.Keys[0].Inode) + lease := &containerdFullLease{origins: snapshot} + received := lease.ImageOrigins() + received.Keys[0].Inode = 12 + received.Complete = false + require.Equal(t, uint64(10), lease.ImageOrigins().Keys[0].Inode) + require.True(t, lease.ImageOrigins().Complete) + image.Complete = false + _, err = snapshotImageOrigins(image) + require.Error(t, err) + image.Complete = true + image.Inodes = make([]ImageInode, 65537) + _, err = snapshotImageOrigins(image) + require.Error(t, err) + require.NoError(t, lease.Close()) + require.Nil(t, lease.ImageOrigins()) + require.Error(t, lease.Check(context.Background())) +} +func TestFullLeaseImageContextInterruptionNotSticky(t *testing.T) { + for _, fault := range []error{context.Canceled, context.DeadlineExceeded} { + lease := &trustedImageLease{Complete: true, source: fakeImageEvidenceSource{err: fault}, process: &linuxLease{}} + require.ErrorIs(t, lease.CheckContext(context.Background()), fault) + require.True(t, lease.Complete) + require.Nil(t, lease.failure) + } + fault := errors.New("runtime changed") + lease := &trustedImageLease{Complete: true, source: fakeImageEvidenceSource{err: fault}, process: &linuxLease{}} + require.ErrorIs(t, lease.CheckContext(context.Background()), fault) + require.False(t, lease.Complete) + require.ErrorIs(t, lease.failure, fault) +} +func TestFullLeaseCloseErrorConsumesAllPinsAndReservation(t *testing.T) { + source := &ContainerdFullSource{outstanding: 1} + closed, err := os.CreateTemp(t.TempDir(), "already-closed") + require.NoError(t, err) + require.NoError(t, closed.Close()) + process, err := os.CreateTemp(t.TempDir(), "process-pin") + require.NoError(t, err) + host, err := os.CreateTemp(t.TempDir(), "host-pin") + require.NoError(t, err) + lease := &containerdFullLease{owner: source, image: &trustedImageLease{root: closed}, process: &linuxLease{proc: process}, host: &hostNamespacePins{mount: host}} + require.Error(t, lease.Close()) + require.Zero(t, source.outstanding) + require.True(t, lease.closed) + require.Nil(t, lease.image) + require.Nil(t, lease.process) + require.Nil(t, lease.host) + _, err = process.Stat() + require.ErrorIs(t, err, os.ErrClosed) + _, err = host.Stat() + require.ErrorIs(t, err, os.ErrClosed) + require.NoError(t, lease.Close()) + require.NoError(t, source.Close()) +} + +func TestFullLeaseConstructorRejectsInvalidAdminPaths(t *testing.T) { + for _, paths := range [][3]string{{"relative", "/proc", "/sys/fs/cgroup"}, {"/", "relative", "/sys/fs/cgroup"}, {"/", "/proc", "/sys/../sys/fs/cgroup"}} { + source, err := NewContainerdFullSource("/nonexistent/socket", paths[0], paths[1], paths[2], ImageIndexLimits{}) + require.ErrorContains(t, err, "canonical") + require.Nil(t, source) + } + source, err := NewContainerdFullSource("/nonexistent/socket", "/", "/proc", "/sys/fs/cgroup", ImageIndexLimits{Files: -1}) + require.ErrorContains(t, err, "budget") + require.Nil(t, source) +} + +func TestFullLeaseIndexCheckPreservesCallerDeadline(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + index := &lowerIndexLease{} + require.ErrorIs(t, index.CheckContext(ctx), context.Canceled) +} diff --git a/pkg/sandboxpolicy/igbackend/fullverdict.go b/pkg/sandboxpolicy/igbackend/fullverdict.go new file mode 100644 index 0000000000..e5993af337 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/fullverdict.go @@ -0,0 +1,224 @@ +package igbackend + +import ( + "fmt" + "path" + "reflect" + "sort" + "strings" + "time" + + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullpolicy" + "github.com/inspektor-gadget/inspektor-gadget/pkg/datasource" + dsapi "github.com/inspektor-gadget/inspektor-gadget/pkg/gadget-service/api" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" + api "github.com/kubescape/storage/pkg/apis/sandbox/v1alpha1" +) + +type RawFullVerdict struct { + RawVerdict + MatchedRules [4]uint64 + Error uint32 +} + +// FullVerdictDecoder snapshots bounded immutable provenance. It does not attach +// programs or attest that the kernel supplied complete or trustworthy facts. +type FullVerdictDecoder struct { + binding sandboxpolicy.Binding + generation uint64 + revision string + policies []sandboxpolicy.PolicyRevision + defaults []fullpolicy.KernelPolicy + rules []sandboxpolicy.RuleMatch +} + +func NewFullVerdictDecoder(binding sandboxpolicy.Binding, plan sandboxpolicy.Plan, program *fullpolicy.KernelProgram) (*FullVerdictDecoder, error) { + if err := fullpolicy.ValidateKernelProgram(program); err != nil { + return nil, err + } + if binding.CgroupID == 0 { + return nil, fmt.Errorf("full verdict requires a bound cgroup") + } + h, _ := fullpolicy.Decode[fullpolicy.KernelHeader](program.Entries[0]) + epochs := make(map[string]uint64, h.PolicyCount) + for i := uint32(0); i < h.PolicyCount; i++ { + p, _ := fullpolicy.Decode[fullpolicy.KernelPolicy](program.Entries[fullpolicy.KernelPolicyBase+i]) + epochs[program.Identities[p.UID]] = p.RootEpoch + } + // Compare the complete immutable IR, not merely self-consistent metadata: a + // valid program for a different plan must not borrow this plan's provenance. + // Rule-free policy revisions and display identities are not encoded in the + // IR; these remain trusted owner-supplied plan metadata, not kernel evidence. + expected, err := CompileFull(binding, plan, h.Generation, epochs) + if err != nil { + return nil, err + } + if !reflect.DeepEqual(expected, program) { + return nil, fmt.Errorf("full verdict program does not match plan") + } + policies := append([]sandboxpolicy.CompiledPolicy(nil), plan.Policies...) + sort.Slice(policies, func(i, j int) bool { return policies[i].Identity.UID < policies[j].Identity.UID }) + d := &FullVerdictDecoder{binding: sandboxpolicy.Binding{ContainerID: binding.ContainerID, PodUID: binding.PodUID, PID: binding.PID, MountNamespace: binding.MountNamespace, CgroupID: binding.CgroupID}, generation: h.Generation, revision: plan.Revision} + for i, p := range policies { + d.policies = append(d.policies, sandboxpolicy.PolicyRevision{Policy: p.Identity, Revision: p.Revision}) + kp, _ := fullpolicy.Decode[fullpolicy.KernelPolicy](program.Entries[fullpolicy.KernelPolicyBase+i]) + d.defaults = append(d.defaults, kp) + } + for i, m := range program.Rules { + r, _ := fullpolicy.Decode[fullpolicy.KernelRule](program.Entries[int(h.RuleBase)+i]) + action := api.ActionAllow + if r.Deny == 1 { + action = api.ActionDeny + } + d.rules = append(d.rules, sandboxpolicy.RuleMatch{Policy: d.policies[m.PolicyIndex].Policy, Revision: m.Revision, Name: m.Name, Origin: m.Origin, Action: action, Mode: fullMode(r.Mode)}) + } + return d, nil +} +func fullMode(mode uint32) api.RuleMode { + if mode == 2 { + return api.RuleEnforce + } + return api.RuleAudit +} + +func (d *FullVerdictDecoder) Decode(r RawFullVerdict) (sandboxpolicy.Verdict, error) { + bad := func() (sandboxpolicy.Verdict, error) { + return sandboxpolicy.Verdict{}, fmt.Errorf("invalid full verdict identity or decision") + } + if d == nil || d.generation == 0 || len(d.policies) == 0 || r.Generation != d.generation || r.Cgroup != d.binding.CgroupID || r.Deny > 1 || r.Mode < 1 || r.Mode > 2 || r.Error > 3 { + return bad() + } + for i := len(d.rules); i < 256; i++ { + if r.MatchedRules[i/64]&(uint64(1)< 0 { + if r.PathResult > 256 || int(r.PathResult) != len(r.Path)+1 || !path.IsAbs(r.Path) || path.Clean(r.Path) != r.Path || strings.ContainsRune(r.Path, 0) { + return bad() + } + out.TargetPath = r.Path + } + for i, m := range d.rules { + if r.MatchedRules[i/64]&(uint64(1)< 1 { + index := -1 + for i, p := range d.defaults { + if ((p.Default == 2 && deny) || (p.Default == 1 && !deny)) && fullMode(p.Mode) == out.Mode { + if index >= 0 { + index = -2 + break + } + index = i + } + } + if index >= 0 { + out.Policy = d.policies[index].Policy + } + } + return out, nil +} + +// ReadFullVerdict checks schema presence before invoking typed accessors. Bitmap +// words use the producer's byte order, not the host's or the IR encoding order. +func ReadFullVerdict(ds datasource.DataSource, data datasource.Data) (RawFullVerdict, error) { + var out RawFullVerdict + if ds == nil || ds.ByteOrder() == nil { + return out, fmt.Errorf("missing full verdict datasource") + } + for _, name := range []string{"generation", "cgroup", "deny", "mode", "host_pid", "path_result", "path", "matched_rules", "error"} { + if ds.GetField(name) == nil { + return out, fmt.Errorf("missing full verdict field %s", name) + } + } + raw, err := readVerdict(ds, data) + if err != nil { + return out, err + } + out.RawVerdict = raw + out.Error, err = ds.GetField("error").Uint32(data) + if err != nil { + return RawFullVerdict{}, err + } + field := ds.GetField("matched_rules") + if field.Type() != dsapi.ArrayOf(dsapi.Kind_Uint64) || len(field.Get(data)) != 8*len(out.MatchedRules) { + return RawFullVerdict{}, fmt.Errorf("invalid full verdict bitmap schema or length") + } + bitmap, err := field.Uint64Array(data) + if err != nil { + return RawFullVerdict{}, err + } + if len(bitmap) != len(out.MatchedRules) { + return RawFullVerdict{}, fmt.Errorf("invalid full verdict bitmap length") + } + copy(out.MatchedRules[:], bitmap) + return out, nil +} diff --git a/pkg/sandboxpolicy/igbackend/fullverdict_test.go b/pkg/sandboxpolicy/igbackend/fullverdict_test.go new file mode 100644 index 0000000000..ba874ea7b9 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/fullverdict_test.go @@ -0,0 +1,314 @@ +package igbackend + +import ( + "encoding/binary" + "testing" + + "github.com/inspektor-gadget/inspektor-gadget/pkg/datasource" + dsapi "github.com/inspektor-gadget/inspektor-gadget/pkg/gadget-service/api" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" + api "github.com/kubescape/storage/pkg/apis/sandbox/v1alpha1" +) + +func verdictPlan() sandboxpolicy.Plan { + p := fullPlan() + p.Policies[0].Exec.Rules = nil + for _, r := range []api.Rule{{Name: "allow", Action: "allow", Mode: "enforce"}, {Name: "audit-deny", Action: "deny", Mode: "audit"}, {Name: "enforce-deny", Action: "deny", Mode: "enforce"}} { + p.Policies[0].Exec.Rules = append(p.Policies[0].Exec.Rules, api.ExecRule{Rule: r, Target: api.ExecTarget{PathTarget: api.PathTarget{Path: []string{"/bin/tool"}}}}) + } + return p +} +func verdictDecoder(t *testing.T, p sandboxpolicy.Plan) *FullVerdictDecoder { + t.Helper() + b := sandboxpolicy.Binding{CgroupID: 19, ContainerName: "app"} + k, e := CompileFull(b, p, 7, nil) + if e != nil { + t.Fatal(e) + } + d, e := NewFullVerdictDecoder(b, p, k) + if e != nil { + t.Fatal(e) + } + return d +} +func TestFullVerdictUnion(t *testing.T) { + d := verdictDecoder(t, verdictPlan()) + for _, tc := range []struct { + name string + bits uint64 + deny, mode uint32 + rule, reason string + }{ + {"allow overrides unmatched defaults", 1, 0, 2, "allow", "matching_rule"}, + {"audit denial dominates enforced allow", 3, 1, 1, "audit-deny", "matching_rule"}, + {"enforced denial dominates audit", 7, 1, 2, "enforce-deny", "matching_rule"}, + {"global defaults", 0, 1, 1, "", "section_default"}, + } { + t.Run(tc.name, func(t *testing.T) { + v, e := d.Decode(RawFullVerdict{RawVerdict: RawVerdict{Generation: 7, Cgroup: 19, Deny: tc.deny, Mode: tc.mode, PathResult: -1, Path: "partial"}, MatchedRules: [4]uint64{tc.bits}}) + if e != nil { + t.Fatal(e) + } + if v.Rule != tc.rule || v.Reason != tc.reason || len(v.PolicySet) != 2 || !v.MatchesComplete || v.TargetPath != "" || v.UID != nil || v.FromImage != nil || v.ActorPath != "" || v.Argv != nil || v.WouldDeny != (tc.deny == 1 && tc.mode == 1) { + t.Fatalf("unexpected %+v", v) + } + }) + } +} +func TestFullVerdictAmbiguousAndCopies(t *testing.T) { + p := verdictPlan() + p.Policies[1].Exec.Rules = []api.ExecRule{p.Policies[0].Exec.Rules[2]} + d := verdictDecoder(t, p) + p.Policies[0].Identity.Name = "mutated" + p.Policies[0].Exec.Rules[2].Name = "mutated" + r := RawFullVerdict{RawVerdict: RawVerdict{Generation: 7, Cgroup: 19, Deny: 1, Mode: 2}, MatchedRules: [4]uint64{12}} + v, e := d.Decode(r) + if e != nil { + t.Fatal(e) + } + if v.Rule != "" || v.Policy.UID != "" || len(v.Matches) != 2 { + t.Fatalf("invented attribution %+v", v) + } + v.PolicySet[0].Revision = "mutated" + v.Matches[0].Name = "mutated" + v, e = d.Decode(r) + if e != nil || v.PolicySet[0].Revision == "mutated" || v.Matches[0].Name == "mutated" { + t.Fatal("decoder aliases caller", e) + } + // Two same-mode defaults are equally decisive; absence of defaults leaves + // implicit audit allow without inventing a policy contributor. + for _, fallback := range []api.Action{"deny", ""} { + p := verdictPlan() + for i := range p.Policies { + p.Policies[i].Mode = "audit" + p.Policies[i].Exec = api.ExecSection{Default: fallback} + } + d := verdictDecoder(t, p) + r.MatchedRules = [4]uint64{} + r.Mode = 1 + r.Deny = 0 + if fallback == "deny" { + r.Deny = 1 + } + v, e = d.Decode(r) + if e != nil || v.Policy.UID != "" || v.Rule != "" { + t.Fatalf("default attribution %+v %v", v, e) + } + } +} +func TestFullVerdictValidation(t *testing.T) { + d := verdictDecoder(t, verdictPlan()) + valid := RawFullVerdict{RawVerdict: RawVerdict{Generation: 7, Cgroup: 19, Mode: 2, Path: "/bin/tool", PathResult: 10}, MatchedRules: [4]uint64{1}} + if _, e := d.Decode(valid); e != nil { + t.Fatal(e) + } + for name, change := range map[string]func(*RawFullVerdict){"generation": func(r *RawFullVerdict) { r.Generation++ }, "cgroup": func(r *RawFullVerdict) { r.Cgroup++ }, "mode": func(r *RawFullVerdict) { r.Mode = 3 }, "decision": func(r *RawFullVerdict) { r.Deny = 1 }, "bitmap": func(r *RawFullVerdict) { r.MatchedRules[3] = 1 << 63 }, "error": func(r *RawFullVerdict) { r.Error = 4 }, "path length": func(r *RawFullVerdict) { r.PathResult++ }, "relative": func(r *RawFullVerdict) { r.Path = "bin/tool"; r.PathResult = 9 }, "unclean": func(r *RawFullVerdict) { r.Path = "/bin/../x"; r.PathResult = 10 }, "nul": func(r *RawFullVerdict) { r.Path = "/a\x00"; r.PathResult = 4 }} { + t.Run(name, func(t *testing.T) { + r := valid + change(&r) + if _, e := d.Decode(r); e == nil { + t.Fatal("accepted malformed verdict") + } + }) + } + for code := uint32(1); code <= 3; code++ { + r := valid + r.Error = code + if _, e := d.Decode(r); e == nil { + t.Fatal("operational allow") + } + r.Deny = 1 + r.Mode = 1 + if _, e := d.Decode(r); e == nil { + t.Fatal("operational audit") + } + r.Mode = 2 + v, e := d.Decode(r) + if e != nil || v.MatchesComplete || v.Rule != "" || v.Policy.UID != "" || v.Reason != "operational_error" || len(v.Matches) != 1 { + t.Fatalf("fault %+v %v", v, e) + } + } +} +func TestFullVerdictMetadataValidation(t *testing.T) { + b := sandboxpolicy.Binding{CgroupID: 19, ContainerName: "app"} + for _, kind := range []string{"revision", "name", "origin", "uid", "predicate", "generation", "container"} { + t.Run(kind, func(t *testing.T) { + p := verdictPlan() + k, e := CompileFull(b, p, 7, nil) + if e != nil { + t.Fatal(e) + } + switch kind { + case "revision": + p.Policies[0].Revision = "other" + case "name": + k.Rules[0].Name = "other" + case "origin": + k.Rules[0].Origin = "other" + case "uid": + p.Policies[0].Identity.UID = "other" + case "predicate": + p.Policies[0].Exec.Rules[0].Target.Path = []string{"/other"} + case "generation": + k.Entries[0][0] = 0 + case "container": + b.ContainerName = "other" + } + if _, e := NewFullVerdictDecoder(b, p, k); e == nil { + t.Fatal("accepted mismatched provenance") + } + }) + } +} + +func TestReadFullVerdict(t *testing.T) { + for _, order := range []binary.ByteOrder{binary.LittleEndian, binary.BigEndian} { + var flags uint32 + if order == binary.BigEndian { + flags = dsapi.DataSourceFlagsBigEndian + } + ds, e := datasource.NewFromAPI(&dsapi.DataSource{Name: "verdict", Type: uint32(datasource.TypeSingle), Flags: flags}) + if e != nil { + t.Fatal(e) + } + for n, k := range map[string]dsapi.Kind{"generation": dsapi.Kind_Uint64, "cgroup": dsapi.Kind_Uint64, "deny": dsapi.Kind_Uint32, "mode": dsapi.Kind_Uint32, "host_pid": dsapi.Kind_Uint32, "path_result": dsapi.Kind_Int32, "path": dsapi.Kind_String, "matched_rules": dsapi.ArrayOf(dsapi.Kind_Uint64), "error": dsapi.Kind_Uint32} { + if _, e = ds.AddField(n, k); e != nil { + t.Fatal(e) + } + } + data, err := ds.NewPacketSingle() + if err != nil { + t.Fatal(err) + } + defer ds.Release(data) + ds.GetField("generation").PutUint64(data, 7) + ds.GetField("cgroup").PutUint64(data, 19) + ds.GetField("mode").PutUint32(data, 2) + ds.GetField("deny").PutUint32(data, 0) + ds.GetField("error").PutUint32(data, 0) + ds.GetField("host_pid").PutUint32(data, 42) + ds.GetField("path_result").PutInt32(data, -1) + ds.GetField("path").PutString(data, "") + bytes := make([]byte, 32) + want := [4]uint64{1, 1 << 31, 1 << 32, 1 << 63} + for i, word := range want { + order.PutUint64(bytes[i*8:], word) + } + if err := ds.GetField("matched_rules").Set(data, bytes); err != nil { + t.Fatal(err) + } + got, e := ReadFullVerdict(ds, data) + if e != nil || got.MatchedRules != want || got.PID != 42 { + t.Fatalf("read %+v %v", got, e) + } + // Typed accessors alone do not reject a different field kind. + for _, field := range ds.Fields() { + if field.Name != "matched_rules" { + continue + } + for _, kind := range []dsapi.Kind{dsapi.Kind_Bytes, dsapi.Kind_Uint64, dsapi.ArrayOf(dsapi.Kind_Uint32)} { + field.Kind = kind + if _, err := ReadFullVerdict(ds, data); err == nil { + t.Fatalf("accepted bitmap kind %v", kind) + } + } + field.Kind = dsapi.ArrayOf(dsapi.Kind_Uint64) + } + for _, length := range []int{0, 8, 24, 31, 33, 40} { + if err := ds.GetField("matched_rules").Set(data, make([]byte, length)); err != nil { + t.Fatal(err) + } + if _, e = ReadFullVerdict(ds, data); e == nil { + t.Fatalf("accepted bitmap length %d", length) + } + } + } + names := []string{"generation", "cgroup", "deny", "mode", "host_pid", "path_result", "path", "matched_rules", "error"} + for _, missing := range names { + ds, _ := datasource.New(datasource.TypeSingle, "missing") + for _, name := range names { + if name != missing { + if _, err := ds.AddField(name, dsapi.Kind_Uint32); err != nil { + t.Fatal(err) + } + } + } + if _, err := ReadFullVerdict(ds, nil); err == nil { + t.Fatalf("accepted missing %s", missing) + } + } + if _, e := ReadFullVerdict(nil, nil); e == nil { + t.Fatal("nil datasource") + } +} + +func TestFullVerdictRetainedEpochSortedSnapshot(t *testing.T) { + p := verdictPlan() + p.Policies[0], p.Policies[1] = p.Policies[1], p.Policies[0] + b := sandboxpolicy.Binding{CgroupID: 19, ContainerName: "app"} + k, err := CompileFull(b, p, 7, map[string]uint64{"uid-one": 2}) + if err != nil { + t.Fatal(err) + } + d, err := NewFullVerdictDecoder(b, p, k) + if err != nil { + t.Fatal(err) + } + k.Rules[0].Name = "mutated" + k.Entries[0][0] = 0 + v, err := d.Decode(RawFullVerdict{RawVerdict: RawVerdict{Generation: 7, Cgroup: 19, Mode: 2}, MatchedRules: [4]uint64{1}}) + if err != nil || v.Rule != "allow" || v.Policy.UID != "uid-one" || v.Revision != p.Revision || v.PolicySet[0].Policy.UID != "uid-one" { + t.Fatalf("wrong sorted snapshot %+v %v", v, err) + } + p = verdictPlan() + p.Policies = p.Policies[:1] + d = verdictDecoder(t, p) + v, err = d.Decode(RawFullVerdict{RawVerdict: RawVerdict{Generation: 7, Cgroup: 19, Mode: 2, Deny: 1}, Error: 1}) + if err != nil || v.Policy.UID != "uid-one" || v.Rule != "" || v.MatchesComplete { + t.Fatalf("single policy context %+v %v", v, err) + } +} + +func TestFullVerdictAlwaysRetainsAggregateRevision(t *testing.T) { + for _, name := range []string{"unique rule", "ambiguous rules", "single policy", "unique default", "ambiguous defaults", "single fault"} { + t.Run(name, func(t *testing.T) { + p := verdictPlan() + r := RawFullVerdict{RawVerdict: RawVerdict{Generation: 7, Cgroup: 19, Deny: 1, Mode: 2}, MatchedRules: [4]uint64{4}} + switch name { + case "ambiguous rules": + p.Policies[1].Exec.Rules = []api.ExecRule{p.Policies[0].Exec.Rules[2]} + r.MatchedRules[0] = 12 + case "single policy": + p.Policies = p.Policies[:1] + case "unique default": + r.MatchedRules = [4]uint64{} + r.Mode = 1 + case "ambiguous defaults": + r.MatchedRules = [4]uint64{} + r.Mode = 1 + p.Policies[0].Exec.Default = "deny" + p.Policies[0].Mode = "audit" + case "single fault": + p.Policies = p.Policies[:1] + r.Error = 2 + } + v, err := verdictDecoder(t, p).Decode(r) + if err != nil { + t.Fatal(err) + } + if v.Revision != p.Revision { + t.Fatalf("aggregate revision changed: %q", v.Revision) + } + for _, policy := range v.PolicySet { + if policy.Revision == "" || policy.Revision == p.Revision { + t.Fatal("lost policy revision") + } + } + for _, match := range v.Matches { + if match.Revision == "" || match.Revision == p.Revision { + t.Fatal("lost rule policy revision") + } + } + }) + } +} diff --git a/pkg/sandboxpolicy/igbackend/identity_linux.go b/pkg/sandboxpolicy/igbackend/identity_linux.go new file mode 100644 index 0000000000..b6740883a5 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/identity_linux.go @@ -0,0 +1,178 @@ +package igbackend + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strconv" + "strings" + + "github.com/kubescape/node-agent/pkg/sandboxpolicy" + "golang.org/x/sys/unix" +) + +// LinuxValidator uses host-visible procfs and the unified cgroup mount. Runtime +// and container ID must originate from the authoritative runtime cache; neither +// is inferred from cgroup names. Nonempty roots support node-agent host mounts. +type LinuxValidator struct{ ProcRoot, CgroupRoot string } +type linuxLease struct { + proc, cgroup, namespace *os.File + identity sandboxpolicy.Binding + start string +} + +func (v LinuxValidator) Acquire(ctx context.Context, b sandboxpolicy.Binding) (Lease, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if b.Runtime == "" || b.ContainerID == "" || b.PID == 0 || b.CgroupID == 0 || b.MountNamespace == 0 || !path.IsAbs(b.CgroupPath) || path.Clean(b.CgroupPath) != b.CgroupPath || b.CgroupPath == "/" { + return nil, fmt.Errorf("complete non-root live container identity required") + } + procRoot, cgRoot := v.ProcRoot, v.CgroupRoot + if procRoot == "" { + procRoot = "/proc" + } + if cgRoot == "" { + cgRoot = "/sys/fs/cgroup" + } + proc, err := os.Open(filepath.Join(procRoot, strconv.FormatUint(uint64(b.PID), 10))) + if err != nil { + return nil, err + } + lease := &linuxLease{proc: proc, identity: b} + fail := func(err error) (Lease, error) { return nil, errors.Join(err, lease.Close()) } + var fs unix.Statfs_t + if err := unix.Fstatfs(int(proc.Fd()), &fs); err != nil { + return fail(err) + } + if fs.Type != unix.PROC_SUPER_MAGIC { + return fail(fmt.Errorf("process root is not procfs")) + } + root, err := os.Open(cgRoot) + if err != nil { + return fail(err) + } + fd, err := unix.Openat2(int(root.Fd()), strings.TrimPrefix(b.CgroupPath, "/"), &unix.OpenHow{Flags: unix.O_RDONLY | unix.O_DIRECTORY | unix.O_CLOEXEC, Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS}) + root.Close() + if err != nil { + return fail(err) + } + lease.cgroup = os.NewFile(uintptr(fd), b.CgroupPath) + if err := unix.Fstatfs(fd, &fs); err != nil { + return fail(err) + } + if fs.Type != unix.CGROUP2_SUPER_MAGIC { + return fail(fmt.Errorf("binding is not on cgroup v2")) + } + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + return fail(err) + } + if stat.Ino != b.CgroupID { + return fail(fmt.Errorf("cgroup inode changed")) + } + ns, err := unix.Openat(int(proc.Fd()), "ns/mnt", unix.O_RDONLY|unix.O_CLOEXEC, 0) + if err != nil { + return fail(err) + } + lease.namespace = os.NewFile(uintptr(ns), "mount namespace") + if err := unix.Fstat(ns, &stat); err != nil { + return fail(err) + } + if stat.Ino != b.MountNamespace { + return fail(fmt.Errorf("mount namespace changed")) + } + lease.start, err = lease.readStart() + if err != nil { + return fail(err) + } + if err := lease.Check(); err != nil { + return fail(err) + } + return lease, nil +} +func (l *linuxLease) read(name string) ([]byte, error) { + fd, err := unix.Openat(int(l.proc.Fd()), name, unix.O_RDONLY|unix.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + f := os.NewFile(uintptr(fd), name) + defer f.Close() + // proc entries are small; constrain unexpected/malformed input. + b, err := io.ReadAll(io.LimitReader(f, 64*1024+1)) + if err != nil { + return nil, err + } + if len(b) > 64*1024 { + return nil, fmt.Errorf("oversized proc entry") + } + return b, nil +} +func (l *linuxLease) readStart() (string, error) { + data, err := l.read("stat") + if err != nil { + return "", err + } + end := strings.LastIndexByte(string(data), ')') + if end < 0 { + return "", fmt.Errorf("malformed process stat") + } + fields := strings.Fields(string(data[end+1:])) + // Fields after comm begin at3; starttime is22. + if len(fields) <= 19 { + return "", fmt.Errorf("incomplete process stat") + } + return fields[19], nil +} +func (l *linuxLease) Check() error { + start, err := l.readStart() + if err != nil { + return err + } + if start != l.start { + return fmt.Errorf("process identity changed") + } + data, err := l.read("cgroup") + if err != nil { + return err + } + found := false + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + if strings.HasPrefix(line, "0::") { + if found || strings.TrimPrefix(line, "0::") != l.identity.CgroupPath { + return fmt.Errorf("process cgroup changed") + } + found = true + } + } + if !found { + return fmt.Errorf("missing unified process cgroup") + } + fd, err := unix.Openat(int(l.proc.Fd()), "ns/mnt", unix.O_RDONLY|unix.O_CLOEXEC, 0) + if err != nil { + return err + } + defer unix.Close(fd) + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + return err + } + if stat.Ino != l.identity.MountNamespace { + return fmt.Errorf("process mount namespace changed") + } + return nil +} +func (l *linuxLease) Close() error { + var errs []error + for _, f := range []*os.File{l.namespace, l.cgroup, l.proc} { + if f != nil { + errs = append(errs, f.Close()) + } + } + l.namespace, l.cgroup, l.proc = nil, nil, nil + return errors.Join(errs...) +} diff --git a/pkg/sandboxpolicy/igbackend/identity_linux_test.go b/pkg/sandboxpolicy/igbackend/identity_linux_test.go new file mode 100644 index 0000000000..1a22dc0d01 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/identity_linux_test.go @@ -0,0 +1,58 @@ +package igbackend + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLinuxValidatorRejectsIncompleteAndNonProcIdentity(t *testing.T) { + _, _, _, b, _ := fixture(t) + incomplete := b + incomplete.PID = 0 + if _, err := (LinuxValidator{}).Acquire(context.Background(), incomplete); err == nil { + t.Fatal("missing PID accepted") + } + fakeProc := t.TempDir() + if err := os.Mkdir(filepath.Join(fakeProc, "123"), 0700); err != nil { + t.Fatal(err) + } + if _, err := (LinuxValidator{ProcRoot: fakeProc}).Acquire(context.Background(), b); err == nil || !strings.Contains(err.Error(), "procfs") { + t.Fatalf("non-proc process identity accepted: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := (LinuxValidator{}).Acquire(ctx, b); err == nil { + t.Fatal("canceled acquisition accepted") + } +} +func TestPinnedProcessStatParsing(t *testing.T) { + root := t.TempDir() + dir, err := os.Open(root) + if err != nil { + t.Fatal(err) + } + l := &linuxLease{proc: dir} + defer l.Close() + // comm can contain spaces and parentheses; starttime must still be field22. + fields := append([]string{"S"}, make([]string, 19)...) + for i := 1; i < len(fields); i++ { + fields[i] = "0" + } + fields[19] = "123456" + if err := os.WriteFile(filepath.Join(root, "stat"), []byte("10 (strange ) process) "+strings.Join(fields, " ")), 0600); err != nil { + t.Fatal(err) + } + start, err := l.readStart() + if err != nil || start != "123456" { + t.Fatal(start, err) + } + if err := os.WriteFile(filepath.Join(root, "stat"), []byte("malformed"), 0600); err != nil { + t.Fatal(err) + } + if _, err := l.readStart(); err == nil { + t.Fatal("malformed stat accepted") + } +} diff --git a/pkg/sandboxpolicy/igbackend/imageevidence.go b/pkg/sandboxpolicy/igbackend/imageevidence.go new file mode 100644 index 0000000000..fb017399e2 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/imageevidence.go @@ -0,0 +1,1423 @@ +package igbackend + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "hash" + "io" + "net" + "os" + "path" + goruntime "runtime" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/containerd/containerd" + "github.com/containerd/containerd/mount" + "github.com/containerd/containerd/namespaces" + "github.com/containerd/containerd/snapshots" + "github.com/containerd/errdefs" + "github.com/opencontainers/go-digest" + imageidentity "github.com/opencontainers/image-spec/identity" + specs "github.com/opencontainers/runtime-spec/specs-go" + "golang.org/x/sys/unix" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// This evidence comes from an admin-configured trusted runtime, never policy +// input or annotations. A fake source exists only for bounded validation tests. +type imageEvidenceSource interface { + Evidence(context.Context, string) (containerdImageEvidence, error) +} +type containerdImageEvidence struct { + Namespace, ID, Image, Snapshotter, SnapshotKey string + RuntimeName string + SandboxID, PodUID, PodNamespace string + PID uint32 + Running bool + Spec *specs.Spec + SandboxSpec *specs.Spec // authenticated CRI sandbox OCI spec, not annotations + DiffIDs []digest.Digest + Active snapshots.Info + Parents []snapshots.Info // nearest committed parent first + Mounts []mount.Mount +} +type containerdImageSource struct{ client *containerd.Client } + +// The admin must authenticate and protect the socket and its parent directory. +// This does not discover sockets from workloads. Close belongs to the creator; +// image leases borrow the source and never close a shared client. +func newContainerdImageSource(socket string) (*containerdImageSource, error) { + if !simpleEvidencePath(socket) { + return nil, fmt.Errorf("containerd requires a canonical admin-configured Unix socket") + } + fd, err := unix.Openat2(unix.AT_FDCWD, socket, &unix.OpenHow{Flags: unix.O_PATH | unix.O_CLOEXEC, Resolve: unix.RESOLVE_NO_SYMLINKS}) + if err != nil { + return nil, err + } + var stat unix.Stat_t + e := unix.Fstat(fd, &stat) + ce := unix.Close(fd) + if e != nil || ce != nil { + return nil, errors.Join(e, ce) + } + if stat.Mode&unix.S_IFMT != unix.S_IFSOCK { + return nil, fmt.Errorf("containerd endpoint is not a Unix socket") + } + client, err := containerd.New(socket, containerd.WithDefaultNamespace("k8s.io"), containerd.WithTimeout(5*time.Second)) + if err != nil { + return nil, err + } + return &containerdImageSource{client: client}, nil +} +func (s *containerdImageSource) Close() error { return s.client.Close() } +func (s *containerdImageSource) Evidence(ctx context.Context, id string) (containerdImageEvidence, error) { + var result containerdImageEvidence + if !fullContainerID(id) { + return result, fmt.Errorf("exact 64-character containerd container ID required") + } + ctx = namespaces.WithNamespace(ctx, "k8s.io") + container, err := s.client.LoadContainer(ctx, id) + if err != nil { + return result, err + } + info, err := container.Info(ctx) + if err != nil { + return result, err + } + task, err := container.Task(ctx, nil) + if err != nil { + return result, err + } + status, err := task.Status(ctx) + if err != nil { + return result, err + } + spec, err := container.Spec(ctx) + if err != nil { + return result, err + } + image, err := container.Image(ctx) + if err != nil { + return result, err + } + diffs, err := image.RootFS(ctx) + if err != nil { + return result, err + } + if len(diffs) == 0 || len(diffs) > 128 { + return result, fmt.Errorf("unsupported image layer count") + } + if info.Snapshotter != "overlayfs" { + return result, fmt.Errorf("only containerd overlayfs snapshotter is supported") + } + service := s.client.SnapshotService(info.Snapshotter) + active, err := service.Stat(ctx, info.SnapshotKey) + if err != nil { + return result, err + } + mounts, err := service.Mounts(ctx, info.SnapshotKey) + if err != nil { + return result, err + } + result = containerdImageEvidence{Namespace: "k8s.io", ID: info.ID, Image: info.Image, RuntimeName: info.Runtime.Name, Snapshotter: info.Snapshotter, SnapshotKey: info.SnapshotKey, PID: task.Pid(), Running: status.Status == containerd.Running, Spec: spec, DiffIDs: diffs, Active: active, Mounts: mounts} + if info.Labels["io.cri-containerd.kind"] == "container" { + extension := info.Extensions["io.cri-containerd.container.metadata"] + if extension == nil { + return containerdImageEvidence{}, fmt.Errorf("missing authenticated CRI container metadata") + } + sandboxID, e := criContainerSandbox(extension.GetValue(), info.ID) + if e != nil { + return containerdImageEvidence{}, e + } + sandbox, e := s.client.LoadContainer(ctx, sandboxID) + if e != nil { + return containerdImageEvidence{}, e + } + sandboxInfo, e := sandbox.Info(ctx) + if e != nil { + return containerdImageEvidence{}, e + } + sandboxExtension := sandboxInfo.Extensions["io.cri-containerd.sandbox.metadata"] + if sandboxInfo.ID != sandboxID || sandboxInfo.Labels["io.cri-containerd.kind"] != "sandbox" || sandboxExtension == nil { + return containerdImageEvidence{}, fmt.Errorf("CRI sandbox identity is not authenticated") + } + uid, namespace, e := criSandboxIdentity(sandboxExtension.GetValue(), sandboxID) + if e != nil { + return containerdImageEvidence{}, e + } + sandboxSpec, e := sandbox.Spec(ctx) + if e != nil { + return containerdImageEvidence{}, e + } + // Bound runtime metadata before retaining it in the evidence snapshot. + rawSandboxSpec, e := json.Marshal(sandboxSpec) + if e != nil { + return containerdImageEvidence{}, e + } + if len(rawSandboxSpec) > 1<<20 { + return containerdImageEvidence{}, fmt.Errorf("CRI sandbox spec budget exceeded") + } + result.SandboxSpec = sandboxSpec + result.SandboxID = sandboxID + result.PodUID = uid + result.PodNamespace = namespace + } else if spec.Annotations["io.kubernetes.cri.sandbox-id"] != "" { + return containerdImageEvidence{}, fmt.Errorf("CRI annotation without authenticated runtime metadata") + } + parent := active.Parent + for parent != "" { + if err := ctx.Err(); err != nil { + return containerdImageEvidence{}, err + } + if len(result.Parents) >= 128 { + return containerdImageEvidence{}, fmt.Errorf("snapshot ancestry budget exceeded") + } + item, err := service.Stat(ctx, parent) + if err != nil { + return containerdImageEvidence{}, err + } + result.Parents = append(result.Parents, item) + parent = item.Parent + } + return result, nil +} + +// Decode the runtime-owned versioned extensions, not workload annotations. +func criContainerSandbox(raw []byte, id string) (string, error) { + var value struct { + Version string + Metadata struct{ ID, SandboxID string } + } + if len(raw) > 1<<20 { + return "", fmt.Errorf("CRI metadata budget exceeded") + } + if err := json.Unmarshal(raw, &value); err != nil { + return "", err + } + if value.Version != "v1" || value.Metadata.ID != id || !fullContainerID(value.Metadata.SandboxID) { + return "", fmt.Errorf("CRI container/sandbox metadata mismatch") + } + return value.Metadata.SandboxID, nil +} +func criSandboxIdentity(raw []byte, id string) (string, string, error) { + var value struct { + Version string + Metadata struct { + ID string + Config struct { + Metadata struct{ UID, Namespace string } + } + } + } + if len(raw) > 1<<20 { + return "", "", fmt.Errorf("CRI metadata budget exceeded") + } + if err := json.Unmarshal(raw, &value); err != nil { + return "", "", err + } + metadata := value.Metadata.Config.Metadata + if value.Version != "v1" || value.Metadata.ID != id || metadata.UID == "" || metadata.Namespace == "" { + return "", "", fmt.Errorf("CRI sandbox pod identity mismatch") + } + return metadata.UID, metadata.Namespace, nil +} +func validatedSandboxFile(e containerdImageEvidence, m specs.Mount, lower []lowerLayer) bool { + if !fullContainerID(e.SandboxID) || len(lower) == 0 { + return false + } + filename := path.Base(m.Source) + if filename != "hosts" && filename != "hostname" && filename != "resolv.conf" { + return false + } + if m.Destination != "/etc/"+filename { + return false + } + const marker = "/io.containerd.snapshotter.v1.overlayfs/snapshots/" + runtimeRoot := "" + for _, layer := range lower { + root, suffix, ok := strings.Cut(layer.name, marker) + parts := strings.Split(suffix, "/") + if !ok || !simpleEvidencePath(root) || len(parts) != 2 || parts[1] != "fs" { + return false + } + if _, err := strconv.ParseUint(parts[0], 10, 64); err != nil { + return false + } + if runtimeRoot != "" && runtimeRoot != root { + return false + } + runtimeRoot = root + } + return m.Source == path.Join(runtimeRoot, "io.containerd.grpc.v1.cri", "sandboxes", e.SandboxID, filename) +} + +// validatedSandboxShm authenticates only the CRI sandbox's ordinary shared-memory +// mount. It is not a helper exemption and does not establish physical safety: +// validateSandboxShmPins must additionally prove a restricted tmpfs root, and the +// normal bounded volume scan still applies. The runtime's StateDir may differ +// from its persistent RootDir; never infer one by rewriting a pathname. +func validatedSandboxShm(e containerdImageEvidence, m specs.Mount) bool { + if !fullContainerID(e.SandboxID) || e.PodUID == "" || e.PodNamespace == "" || e.SandboxSpec == nil || e.SandboxSpec.Linux == nil || len(e.SandboxSpec.Mounts) > 128 { + return false + } + if m.Type != "bind" || m.Destination != "/dev/shm" || !simpleEvidencePath(m.Source) || !strings.HasSuffix(m.Source, "/sandboxes/"+e.SandboxID+"/shm") || !validBindOptions(m.Options) || (!containsEvidence(m.Options, "bind") && !containsEvidence(m.Options, "rbind")) { + return false + } + // A host IPC namespace is not an authenticated sandbox-owned tmpfs. + isolatedIPC, isolatedMount := false, false + for _, ns := range e.SandboxSpec.Linux.Namespaces { + switch ns.Type { + case specs.IPCNamespace: + if isolatedIPC || ns.Path != "" { + return false + } + isolatedIPC = true + case specs.MountNamespace: + if isolatedMount || ns.Path != "" { + return false + } + isolatedMount = true + } + } + if !isolatedIPC || !isolatedMount { + return false + } + found := false + for _, sandboxMount := range e.SandboxSpec.Mounts { + if sandboxMount.Destination != "/dev/shm" { + continue + } + if found || sandboxMount.Type != "bind" || sandboxMount.Source != m.Source || !validBindOptions(sandboxMount.Options) || (!containsEvidence(sandboxMount.Options, "bind") && !containsEvidence(sandboxMount.Options, "rbind")) { + return false + } + for _, option := range []string{"ro", "nosuid", "nodev", "noexec"} { + if !containsEvidence(sandboxMount.Options, option) { + return false + } + } + found = true + } + return found +} + +func readSelfMountInfo() ([]byte, error) { + f, err := os.Open("/proc/self/mountinfo") + if err != nil { + return nil, err + } + data, readErr := io.ReadAll(io.LimitReader(f, (1<<20)+1)) + closeErr := f.Close() + if readErr != nil || closeErr != nil { + return nil, errors.Join(readErr, closeErr) + } + if len(data) > 1<<20 { + return nil, fmt.Errorf("self mountinfo budget exceeded") + } + return data, nil +} +func validateSandboxShmPins(source, destination *os.File, sourceMounts, destinationMounts []byte, evidence containerdImageEvidence, process *linuxLease) error { + for position, pin := range []struct { + file *os.File + mounts []byte + }{{source, sourceMounts}, {destination, destinationMounts}} { + var fs unix.Statfs_t + if err := unix.Fstatfs(int(pin.file.Fd()), &fs); err != nil { + return err + } + var stat unix.Stat_t + if err := unix.Fstat(int(pin.file.Fd()), &stat); err != nil { + return err + } + var sx unix.Statx_t + if err := unix.Statx(int(pin.file.Fd()), "", unix.AT_EMPTY_PATH, unix.STATX_MNT_ID, &sx); err != nil { + return err + } + if sx.Mask&unix.STATX_MNT_ID == 0 { + return fmt.Errorf("sandbox shm mount identity unavailable") + } + if err := validateSandboxShmFacts(fs, stat.Mode, pin.mounts, sx.Mnt_id); err != nil { + // Containerd/runc may clear inherited destination bind flags. Do not + // weaken source tmpfs proof, and never waive destination flags from + // caller-supplied booleans or annotations: re-read the pinned process. + if position == 0 || process == nil || evidence.RuntimeName != "io.containerd.runc.v2" { + return err + } + status, readErr := process.read("status") + if readErr != nil { + return readErr + } + if fallbackErr := validateSandboxShmDestinationFacts(fs, stat.Mode, pin.mounts, sx.Mnt_id, evidence.Spec, status); fallbackErr != nil { + return fallbackErr + } + } + } + return nil +} +func validateSandboxShmFacts(fs unix.Statfs_t, mode uint32, mountinfo []byte, id uint64) error { + requiredFlags := int64(unix.ST_NOSUID | unix.ST_NODEV | unix.ST_NOEXEC) + if mode&unix.S_IFMT != unix.S_IFDIR || fs.Type != unix.TMPFS_MAGIC || fs.Flags&requiredFlags != requiredFlags { + return fmt.Errorf("sandbox shm must be a nosuid/nodev/noexec tmpfs directory") + } + return validateRestrictedMountInfo(mountinfo, id, "tmpfs", true, []string{"nosuid", "nodev", "noexec"}) +} + +// This compatibility matrix is deliberately narrower than ordinary root +// admission. No-new-privileges plus empty capabilities prevent set-ID/file-cap +// privilege acquisition on a destination bind that lost nosuid. The exact OCI +// baseline device rule admits only the trusted runc default safe-device rules; +// it is NOT a claim that effective device access is literally deny-all. Runtime +// and current cgroup authority are the same trusted prerequisites as seccomp. +func validateSandboxShmUnprivileged(spec *specs.Spec, status []byte) error { + if spec == nil || spec.Process == nil || !spec.Process.NoNewPrivileges || spec.Process.Capabilities == nil || spec.Linux == nil || spec.Linux.Resources == nil { + return fmt.Errorf("sandbox shm destination requires explicit unprivileged runtime evidence") + } + caps := spec.Process.Capabilities + for _, set := range [][]string{caps.Bounding, caps.Effective, caps.Permitted, caps.Inheritable, caps.Ambient} { + if len(set) != 0 { + return fmt.Errorf("sandbox shm destination requires empty OCI capability sets") + } + } + devices := spec.Linux.Resources.Devices + if len(devices) != 1 { + return fmt.Errorf("sandbox shm destination requires the exact baseline OCI device policy") + } + rule := devices[0] + if rule.Allow || (rule.Type != "" && rule.Type != "a") || rule.Major != nil || rule.Minor != nil || rule.Access != "rwm" { + return fmt.Errorf("sandbox shm destination has an unsupported OCI device policy") + } + if len(status) > 65536 { + return fmt.Errorf("sandbox shm process status budget exceeded") + } + required := map[string]bool{"CapBnd:": false, "CapEff:": false, "CapPrm:": false, "CapInh:": false, "CapAmb:": false, "NoNewPrivs:": false, "Seccomp:": false} + for _, line := range strings.Split(string(status), "\n") { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + seen, wanted := required[fields[0]] + if !wanted { + continue + } + if seen || len(fields) != 2 { + return fmt.Errorf("ambiguous sandbox shm process status") + } + required[fields[0]] = true + switch fields[0] { + case "NoNewPrivs:": + if fields[1] != "1" { + return fmt.Errorf("sandbox shm destination requires live no-new-privileges") + } + case "Seccomp:": + if fields[1] != "2" { + return fmt.Errorf("sandbox shm destination requires live seccomp filter") + } + default: + value, err := strconv.ParseUint(fields[1], 16, 64) + if err != nil { + return err + } + if value != 0 { + return fmt.Errorf("sandbox shm destination requires empty live capability sets") + } + } + } + for _, present := range required { + if !present { + return fmt.Errorf("incomplete sandbox shm process status") + } + } + return nil +} +func validateSandboxShmDestinationFacts(fs unix.Statfs_t, mode uint32, mountinfo []byte, id uint64, spec *specs.Spec, status []byte) error { + if err := validateSandboxShmUnprivileged(spec, status); err != nil { + return err + } + if mode&unix.S_IFMT != unix.S_IFDIR || fs.Type != unix.TMPFS_MAGIC { + return fmt.Errorf("sandbox shm destination must remain a tmpfs directory") + } + return validateRestrictedMountInfo(mountinfo, id, "tmpfs", true, nil) +} + +// Mountinfo binds the fstatfs facts to the actual pinned mount. A read-only +// cgroup2 subtree is supported; sandbox shm must be the whole tmpfs root. +func validateRestrictedMountInfo(mountinfo []byte, id uint64, filesystem string, requireRoot bool, requiredOptions []string) error { + found := false + for _, line := range strings.Split(strings.TrimSpace(string(mountinfo)), "\n") { + fields := strings.Fields(line) + if len(fields) < 6 { + return fmt.Errorf("malformed restricted mountinfo") + } + mountID, err := strconv.ParseUint(fields[0], 10, 64) + if err != nil { + return err + } + if mountID != id { + continue + } + if found || (requireRoot && fields[3] != "/") { + return fmt.Errorf("%s mount must have an unambiguous supported root", filesystem) + } + found = true + for _, option := range requiredOptions { + if !containsEvidence(strings.Split(fields[5], ","), option) { + return fmt.Errorf("%s mount lacks %s", filesystem, option) + } + } + separator := 6 + for separator < len(fields) && fields[separator] != "-" { + separator++ + } + if separator+3 >= len(fields) || fields[separator+1] != filesystem { + return fmt.Errorf("mount is not %s", filesystem) + } + } + if !found { + return fmt.Errorf("%s mount not found", filesystem) + } + return nil +} + +func validCgroupMount(m specs.Mount) bool { + if m.Type != "cgroup" || m.Source != "cgroup" || m.Destination != "/sys/fs/cgroup" || len(m.Options) > 5 { + return false + } + seen := map[string]bool{} + for _, option := range m.Options { + if seen[option] { + return false + } + seen[option] = true + switch option { + case "ro", "nosuid", "nodev", "noexec", "relatime": + default: + return false + } + } + return seen["ro"] && seen["nosuid"] && seen["nodev"] && seen["noexec"] +} +func validateCgroupMountFacts(fs unix.Statfs_t, mode uint32, mountinfo []byte, id uint64) error { + required := int64(unix.ST_RDONLY | unix.ST_NOSUID | unix.ST_NODEV | unix.ST_NOEXEC) + if mode&unix.S_IFMT != unix.S_IFDIR || fs.Type != unix.CGROUP2_SUPER_MAGIC || fs.Flags&required != required { + return fmt.Errorf("cgroup mount must be a read-only nosuid/nodev/noexec cgroup2 directory") + } + return validateRestrictedMountInfo(mountinfo, id, "cgroup2", false, []string{"ro", "nosuid", "nodev", "noexec"}) +} +func validateCgroupMounts(spec *specs.Spec, root *os.File, mountinfo []byte) error { + for _, m := range spec.Mounts { + if m.Type != "cgroup" { + continue + } + if !validCgroupMount(m) { + return fmt.Errorf("unsupported OCI cgroup mount") + } + fd, err := unix.Openat2(int(root.Fd()), m.Destination, &unix.OpenHow{Flags: unix.O_PATH | unix.O_CLOEXEC, Resolve: unix.RESOLVE_IN_ROOT | unix.RESOLVE_NO_MAGICLINKS}) + if err != nil { + return err + } + file := os.NewFile(uintptr(fd), m.Destination) + check := func() error { + var fs unix.Statfs_t + if err := unix.Fstatfs(fd, &fs); err != nil { + return err + } + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + return err + } + var sx unix.Statx_t + if err := unix.Statx(fd, "", unix.AT_EMPTY_PATH, unix.STATX_MNT_ID, &sx); err != nil { + return err + } + if sx.Mask&unix.STATX_MNT_ID == 0 { + return fmt.Errorf("cgroup mount identity unavailable") + } + return validateCgroupMountFacts(fs, stat.Mode, mountinfo, sx.Mnt_id) + } + if err := errors.Join(check(), file.Close()); err != nil { + return err + } + } + return nil +} + +func validateEvidencePodIdentity(e containerdImageEvidence, process *linuxLease) error { + if process.identity.Namespace != "" && e.PodNamespace != process.identity.Namespace { + return fmt.Errorf("runtime pod namespace does not match binding") + } + if process.identity.PodUID != "" && e.PodUID != string(process.identity.PodUID) { + return fmt.Errorf("runtime pod UID does not match binding") + } + return nil +} + +func fullContainerID(id string) bool { + if len(id) != 64 { + return false + } + for _, c := range id { + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { + return false + } + } + return true +} +func simpleEvidencePath(p string) bool { + return p != "/" && path.IsAbs(p) && path.Clean(p) == p && len(p) <= 4096 && !strings.ContainsAny(p, "\\,:%\x00\t\r\n ") +} + +type overlayEvidence struct { + Lower []string + Upper, Work string +} + +func parseOverlayEvidence(options []string) (overlayEvidence, error) { + var result overlayEvidence + if len(options) > 32 { + return result, fmt.Errorf("overlay option budget exceeded") + } + seen := map[string]bool{} + for _, option := range options { + key, value, hasValue := strings.Cut(option, "=") + if seen[key] { + return result, fmt.Errorf("duplicate overlay option %q", key) + } + seen[key] = true + switch key { + case "lowerdir": + if !hasValue { + return result, fmt.Errorf("missing lowerdir") + } + result.Lower = strings.Split(value, ":") + if len(result.Lower) == 0 || len(result.Lower) > 128 { + return result, fmt.Errorf("lowerdir budget exceeded") + } + for _, p := range result.Lower { + if !simpleEvidencePath(p) { + return result, fmt.Errorf("unsupported encoded/relative/data-only lowerdir") + } + } + case "upperdir", "workdir": + if !hasValue || !simpleEvidencePath(value) { + return result, fmt.Errorf("invalid %s", key) + } + if key == "upperdir" { + result.Upper = value + } else { + result.Work = value + } + case "rw", "relatime", "nosuid", "nodev", "noexec": + if hasValue { + return result, fmt.Errorf("invalid overlay flag") + } + case "index", "redirect_dir", "xino": + if value != "off" { + return result, fmt.Errorf("unsupported overlay %s", option) + } + case "uuid": + if value != "on" { + return result, fmt.Errorf("unsupported overlay uuid option") + } + case "metacopy": + if value != "on" && value != "off" { + return result, fmt.Errorf("unsupported overlay metacopy") + } + default: + return result, fmt.Errorf("unsupported overlay option %q", option) + } + } + if len(result.Lower) == 0 || result.Upper == "" || result.Work == "" { + return result, fmt.Errorf("active overlay requires lower/upper/work directories") + } + all := append(append([]string{}, result.Lower...), result.Upper, result.Work) + for i, a := range all { + for _, b := range all[:i] { + if a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/") { + return result, fmt.Errorf("overlapping overlay directories") + } + } + } + return result, nil +} +func validateImageSpec(spec *specs.Spec) error { + if spec == nil || spec.Process == nil || spec.Process.Capabilities == nil || spec.Linux == nil || spec.Root == nil || spec.Root.Path == "" { + return fmt.Errorf("incomplete OCI isolation evidence") + } + if spec.Root.Path != "rootfs" && !simpleEvidencePath(spec.Root.Path) { + return fmt.Errorf("unsupported OCI root path") + } + encoded, err := json.Marshal(spec) + if err != nil { + return err + } + if len(encoded) > 1<<20 { + return fmt.Errorf("OCI spec evidence budget exceeded") + } + allowedCaps := map[string]bool{} + for _, name := range []string{"CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_FSETID", "CAP_KILL", "CAP_SETGID", "CAP_SETUID", "CAP_SETPCAP", "CAP_NET_BIND_SERVICE", "CAP_NET_RAW", "CAP_MKNOD", "CAP_AUDIT_WRITE", "CAP_SETFCAP"} { + allowedCaps[name] = true + } + if err := validateRootSeccomp(spec.Linux.Seccomp); err != nil { + return err + } + caps := spec.Process.Capabilities + for _, set := range [][]string{caps.Bounding, caps.Effective, caps.Permitted, caps.Inheritable, caps.Ambient} { + if len(set) > 64 { + return fmt.Errorf("OCI capability budget exceeded") + } + for _, cap := range set { + if !allowedCaps[cap] { + return fmt.Errorf("unsupported OCI capability %s", cap) + } + } + } + ns := map[specs.LinuxNamespaceType]bool{} + for _, item := range spec.Linux.Namespaces { + if ns[item.Type] { + return fmt.Errorf("duplicate OCI namespace") + } + ns[item.Type] = true + if (item.Type == specs.MountNamespace || item.Type == specs.PIDNamespace) && item.Path != "" { + return fmt.Errorf("shared/host mount or PID namespace is unsupported") + } + } + if !ns[specs.MountNamespace] || !ns[specs.PIDNamespace] { + return fmt.Errorf("isolated mount and PID namespaces required") + } + if len(spec.Mounts) > 128 || len(spec.Linux.Devices) > 32 { + return fmt.Errorf("OCI mount/device budget exceeded") + } + destinations := map[string]bool{} + for _, m := range spec.Mounts { + if !simpleEvidencePath(m.Destination) || destinations[m.Destination] { + return fmt.Errorf("invalid OCI mount destination") + } + destinations[m.Destination] = true + // Bind volume provenance is validated against pinned host/process roots below. + allowed := false + switch m.Type { + case "bind": + allowed = simpleEvidencePath(m.Source) && (containsEvidence(m.Options, "bind") || containsEvidence(m.Options, "rbind")) + if !validBindOptions(m.Options) { + return fmt.Errorf("unsupported bind options") + } + case "cgroup": + allowed = validCgroupMount(m) + case "proc": + allowed = m.Source == "proc" && m.Destination == "/proc" + case "sysfs": + allowed = m.Source == "sysfs" && m.Destination == "/sys" && containsEvidence(m.Options, "ro") + case "devpts": + allowed = m.Source == "devpts" && m.Destination == "/dev/pts" + case "mqueue": + allowed = m.Source == "mqueue" && m.Destination == "/dev/mqueue" + case "tmpfs": + allowed = (m.Source == "tmpfs" && (m.Destination == "/dev" || m.Destination == "/dev/shm" || m.Destination == "/run")) || (m.Source == "shm" && m.Destination == "/dev/shm") + } + if !allowed || (m.Type != "bind" && (containsEvidence(m.Options, "bind") || containsEvidence(m.Options, "rbind"))) { + return fmt.Errorf("unsupported OCI mount %s at %s: mount provenance is unsupported", m.Type, m.Destination) + } + } + for _, device := range spec.Linux.Devices { + allowed := device.Type == "c" && ((device.Major == 1 && (device.Minor == 3 || device.Minor == 5 || device.Minor == 7 || device.Minor == 8 || device.Minor == 9)) || (device.Major == 5 && (device.Minor == 0 || device.Minor == 1 || device.Minor == 2))) + if !allowed { + return fmt.Errorf("host/block device exposure is unsupported") + } + } + if spec.Linux.Resources != nil { + for _, device := range spec.Linux.Resources.Devices { + if device.Allow && (device.Type == "b" || device.Type == "" || device.Type == "a") { + return fmt.Errorf("privileged/block device cgroup rule") + } + } + } + return nil +} + +// This is deliberately a canonical-template checker, not a general seccomp +// implication solver. The syscall barrier is needed because periodic root +// checks cannot prevent chroot/user-namespace changes between observations. +func validateRootSeccomp(profile *specs.LinuxSeccomp) error { + if goruntime.GOARCH != "amd64" || profile == nil || profile.DefaultAction != specs.ActAllow || len(profile.Architectures) != 1 || profile.Architectures[0] != specs.ArchX86_64 || len(profile.Syscalls) > 512 { + return fmt.Errorf("stable-root evidence requires the canonical native-x86_64 seccomp barrier") + } + required := map[string]bool{"chroot": false, "pivot_root": false, "unshare": false, "setns": false, "clone3": false} + flags := map[uint64]bool{uint64(unix.CLONE_NEWUSER): false, uint64(unix.CLONE_NEWNS): false} + for _, rule := range profile.Syscalls { + for _, name := range rule.Names { + if _, ok := required[name]; ok { + if rule.Action != specs.ActErrno || rule.ErrnoRet == nil || *rule.ErrnoRet != uint(unix.EPERM) || len(rule.Args) != 0 { + return fmt.Errorf("ambiguous seccomp rule for root-changing syscall %s", name) + } + required[name] = true + } + if name == "clone" { + if len(rule.Names) != 1 || rule.Action != specs.ActErrno || rule.ErrnoRet == nil || *rule.ErrnoRet != uint(unix.EPERM) || len(rule.Args) != 1 { + return fmt.Errorf("ambiguous clone namespace seccomp rule") + } + arg := rule.Args[0] + if _, ok := flags[arg.Value]; !ok || arg.Index != 0 || arg.Op != specs.OpMaskedEqual || arg.ValueTwo != arg.Value { + return fmt.Errorf("clone rule is not a canonical namespace-bit deny") + } + flags[arg.Value] = true + } + } + } + for name, found := range required { + if !found { + return fmt.Errorf("missing unconditional seccomp errno deny for %s", name) + } + } + for flag, found := range flags { + if !found { + return fmt.Errorf("missing clone namespace-bit deny %#x", flag) + } + } + return nil +} + +func containsEvidence(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} +func validateContainerdEvidence(e containerdImageEvidence, id string, pid uint32) (overlayEvidence, error) { + var empty overlayEvidence + if !fullContainerID(id) || e.ID != id || e.Namespace != "k8s.io" || e.PID != pid || pid == 0 || !e.Running || e.Image == "" { + return empty, fmt.Errorf("runtime container/task identity mismatch") + } + if e.SandboxID != "" && (!fullContainerID(e.SandboxID) || e.PodUID == "" || e.PodNamespace == "" || e.Spec == nil || e.Spec.Annotations["io.kubernetes.cri.sandbox-id"] != e.SandboxID || e.Spec.Annotations["io.kubernetes.cri.container-type"] != "container") { + return empty, fmt.Errorf("OCI identity does not match authenticated CRI sandbox") + } + if e.RuntimeName != "io.containerd.runc.v2" { + return empty, fmt.Errorf("only the trusted runc v2 runtime matrix is supported") + } + if e.Snapshotter != "overlayfs" || e.SnapshotKey == "" || len(e.SnapshotKey) > 512 || e.Active.Name != e.SnapshotKey || e.Active.Kind != snapshots.KindActive { + return empty, fmt.Errorf("unsupported active snapshot evidence") + } + if len(e.DiffIDs) == 0 || len(e.DiffIDs) > 128 || len(e.Parents) != len(e.DiffIDs) { + return empty, fmt.Errorf("incomplete or excessive committed snapshot chain") + } + for _, diff := range e.DiffIDs { + if err := diff.Validate(); err != nil { + return empty, err + } + } + expected := imageidentity.ChainID(e.DiffIDs).String() + if e.Active.Parent != expected { + return empty, fmt.Errorf("active parent does not match image ChainID") + } + for i, item := range e.Parents { + n := len(e.DiffIDs) - i + parent := "" + if n > 1 { + parent = imageidentity.ChainID(e.DiffIDs[:n-1]).String() + } + if item.Kind != snapshots.KindCommitted || item.Name != imageidentity.ChainID(e.DiffIDs[:n]).String() || item.Parent != parent { + return empty, fmt.Errorf("committed snapshot chain mismatch") + } + } + if err := validateImageSpec(e.Spec); err != nil { + return empty, err + } + if len(e.Mounts) != 1 || e.Mounts[0].Type != "overlay" || e.Mounts[0].Source != "overlay" || e.Mounts[0].Target != "" { + return empty, fmt.Errorf("unsupported snapshot mount specification") + } + overlay, err := parseOverlayEvidence(e.Mounts[0].Options) + if err != nil { + return empty, err + } + if len(overlay.Lower) != len(e.Parents) { + return empty, fmt.Errorf("snapshot lowerdir/parent count mismatch") + } + return overlay, nil +} + +type processRootEvidence struct { + MountID uint64 + Device, Inode uint64 + Overlay overlayEvidence +} + +func parseProcessRootEvidence(data []byte, mountID uint64) (overlayEvidence, error) { + var result overlayEvidence + found := false + if len(data) > 65536 { + return result, fmt.Errorf("mountinfo budget exceeded") + } + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + fields := strings.Fields(line) + if len(fields) < 10 { + return result, fmt.Errorf("malformed mountinfo") + } + id, err := strconv.ParseUint(fields[0], 10, 64) + if err != nil { + return result, err + } + if id != mountID { + continue + } + if found || fields[3] != "/" || fields[4] != "/" { + return result, fmt.Errorf("pivoted/ambiguous process root") + } + found = true + separator := -1 + for i, f := range fields { + if f == "-" { + separator = i + break + } + } + if separator < 6 || separator+4 != len(fields) || fields[separator+1] != "overlay" || fields[separator+2] != "overlay" { + return result, fmt.Errorf("process root is not a simple overlay") + } + result, err = parseOverlayEvidence(strings.Split(fields[separator+3], ",")) + if err != nil { + return result, err + } + } + if !found { + return result, fmt.Errorf("pinned process root mount missing") + } + return result, nil +} +func validateLiveRootBarrier(status []byte) error { + seccomp := false + sets := map[string]bool{"CapBnd:": false, "CapEff:": false, "CapPrm:": false, "CapInh:": false, "CapAmb:": false} + for _, line := range strings.Split(string(status), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + if fields[0] == "Seccomp:" { + if fields[1] != "2" { + return fmt.Errorf("process seccomp filter is not active") + } + seccomp = true + } + if _, ok := sets[fields[0]]; ok { + value, err := strconv.ParseUint(fields[1], 16, 64) + if err != nil { + return err + } + if value&(uint64(1)< 16 { + return false + } + seen := map[string]bool{} + for _, option := range options { + if seen[option] { + return false + } + seen[option] = true + switch option { + case "bind", "rbind", "ro", "rw", "rprivate", "private", "rslave", "slave", "nosuid", "nodev", "noexec", "relatime": + default: + return false + } + } + return !(seen["ro"] && seen["rw"]) && !(seen["bind"] && seen["rbind"]) +} +func sensitiveVolumeSource(source string, lower []lowerLayer) bool { + for _, root := range []string{"/proc", "/sys", "/dev", "/etc", "/bin", "/sbin", "/usr", "/root", "/run", "/var/run", "/var/lib/containerd", "/var/lib/docker"} { + if source == root || strings.HasPrefix(source, root+"/") || strings.HasPrefix(root, source+"/") { + return true + } + } + for _, root := range lower { + if runtimeRoot, _, ok := strings.Cut(root.name, "/io.containerd.snapshotter.v1.overlayfs/snapshots/"); ok && simpleEvidencePath(runtimeRoot) { + if source == runtimeRoot || strings.HasPrefix(source, runtimeRoot+"/") || strings.HasPrefix(runtimeRoot, source+"/") { + return true + } + } + if source == root.name || strings.HasPrefix(source, root.name+"/") || strings.HasPrefix(root.name, source+"/") { + return true + } + } + return false +} +func mountReadOnly(data []byte, id uint64) (bool, error) { + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + fields := strings.Fields(line) + if len(fields) < 6 { + return false, fmt.Errorf("malformed volume mountinfo") + } + n, err := strconv.ParseUint(fields[0], 10, 64) + if err != nil { + return false, err + } + if n == id { + return containsEvidence(strings.Split(fields[5], ","), "ro"), nil + } + } + return false, fmt.Errorf("volume mount not found") +} + +type volumeWalker struct { + ctx context.Context + entries int + lower map[ImageInode]bool + writable bool + rejectRedirect bool + allowSpecial bool +} + +func (w *volumeWalker) walk(file *os.File, name string, depth int, h hash.Hash) (retErr error) { + if err := w.ctx.Err(); err != nil { + return err + } + if depth > 128 || len(name) > 4096 { + return fmt.Errorf("volume traversal depth/path exceeded") + } + stat, err := indexStat(file) + if err != nil { + return err + } + fmt.Fprintf(h, "%q:%+v\n", name, stat) + if w.rejectRedirect && (stat.Mode&unix.S_IFMT == unix.S_IFREG || stat.Mode&unix.S_IFMT == unix.S_IFDIR) { + // The self-fd path targets an already pinned inode; it is not a workload + // pathname or an untrusted process magic link. O_PATH cannot use fgetxattr. + for _, attribute := range []string{"trusted.overlay.redirect", "user.overlay.redirect"} { + _, e := unix.Getxattr(fmt.Sprintf("/proc/self/fd/%d", file.Fd()), attribute, nil) + if e == nil { + return fmt.Errorf("redirected lower layer is unsupported") + } + if !errors.Is(e, unix.ENODATA) { + return fmt.Errorf("cannot prove absence of lower redirects: %w", e) + } + } + } + + switch stat.Mode & unix.S_IFMT { + case unix.S_IFREG: + if w.writable && w.lower[ImageInode{stat.Ino, unix.Major(stat.Dev), unix.Minor(stat.Dev)}] { + return fmt.Errorf("writable volume aliases immutable image inode") + } + return nil + case unix.S_IFLNK: + return nil // never follow projected-directory symlinks while enumerating + case unix.S_IFDIR: + default: + if w.allowSpecial { + return nil + } + return fmt.Errorf("volume exposes socket/device/special file") + } + reader, err := indexOpen(int(file.Fd()), ".", unix.O_RDONLY|unix.O_DIRECTORY, true) + if err != nil { + return err + } + defer func() { retErr = errors.Join(retErr, reader.Close()) }() + var names []string + for { + batch, e := reader.Readdirnames(128) + for _, name := range batch { + w.entries++ + if w.entries > 262144 { + return fmt.Errorf("volume entry budget exceeded") + } + names = append(names, name) + } + if e == io.EOF { + break + } + if e != nil { + return e + } + if e = w.ctx.Err(); e != nil { + return e + } + } + sort.Strings(names) + for _, childName := range names { + child, e := indexOpen(int(file.Fd()), childName, unix.O_PATH|unix.O_NOFOLLOW, true) + if e != nil { + return e + } + e = w.walk(child, path.Join(name, childName), depth+1, h) + ce := child.Close() + if e != nil || ce != nil { + return errors.Join(e, ce) + } + } + after, err := indexStat(file) + if err != nil { + return err + } + if after != stat { + return fmt.Errorf("volume changed during provenance scan") + } + return nil +} +func validateBindVolumes(ctx context.Context, evidence containerdImageEvidence, index *lowerIndexLease, root *os.File, process *linuxLease) error { + lower := make(map[ImageInode]bool, len(index.Inodes)) + for _, key := range index.Inodes { + lower[key] = true + } + mountinfo, err := process.read("mountinfo") + if err != nil { + return err + } + if err := validateCgroupMounts(evidence.Spec, root, mountinfo); err != nil { + return err + } + totalEntries := 0 + var selfMountInfo []byte + for _, m := range evidence.Spec.Mounts { + if m.Type != "bind" { + continue + } + if err = ctx.Err(); err != nil { + return err + } + generated := validatedSandboxFile(evidence, m, index.layers) + sharedMemory := validatedSandboxShm(evidence, m) + if sharedMemory && selfMountInfo == nil { + selfMountInfo, err = readSelfMountInfo() + if err != nil { + return err + } + } + if sensitiveVolumeSource(m.Source, index.layers) && !generated && !sharedMemory { + return fmt.Errorf("bind source exposes sensitive host/runtime/lower roots; no authenticated sandbox-mount provenance") + } + + source, e := indexOpen(int(index.host.Fd()), strings.TrimPrefix(m.Source, "/"), unix.O_PATH, false) + if e != nil { + return e + } + // Absolute paths and /var/run symlinks resolve within the pinned container + // root; proc magic links cannot escape it. Ordinary symlinks are permitted. + fd, e := unix.Openat2(int(root.Fd()), m.Destination, &unix.OpenHow{Flags: unix.O_PATH | unix.O_CLOEXEC, Resolve: unix.RESOLVE_IN_ROOT | unix.RESOLVE_NO_MAGICLINKS}) + if e != nil { + return errors.Join(e, source.Close()) + } + destination := os.NewFile(uintptr(fd), m.Destination) + check := func() error { + src, e := indexStat(source) + if e != nil { + return e + } + dst, e := indexStat(destination) + if e != nil { + return e + } + if src.Dev != dst.Dev || src.Ino != dst.Ino || src.Mode&unix.S_IFMT != dst.Mode&unix.S_IFMT { + return fmt.Errorf("bind source/destination inode mismatch") + } + if sharedMemory { + if e = validateSandboxShmPins(source, destination, selfMountInfo, mountinfo, evidence, process); e != nil { + return e + } + } + if generated && src.Mode&unix.S_IFMT != unix.S_IFREG { + return fmt.Errorf("authenticated sandbox file is not regular") + } + if src.Mode&unix.S_IFMT != unix.S_IFREG && src.Mode&unix.S_IFMT != unix.S_IFDIR { + return fmt.Errorf("bind source is not an ordinary file/directory") + } + if e = indexFS(source); e != nil { + return e + } + var sx unix.Statx_t + if e = unix.Statx(fd, "", unix.AT_EMPTY_PATH, unix.STATX_MNT_ID, &sx); e != nil { + return e + } + if sx.Mask&unix.STATX_MNT_ID == 0 { + return fmt.Errorf("bind mount identity unavailable") + } + ro, e := mountReadOnly(mountinfo, sx.Mnt_id) + if e != nil { + return e + } + if containsEvidence(m.Options, "ro") && !ro { + return fmt.Errorf("read-only OCI bind is writable in process mount namespace") + } + walker := volumeWalker{ctx: ctx, entries: totalEntries, lower: lower, writable: !ro} + a, b := sha256.New(), sha256.New() + if e = walker.walk(source, "/", 0, a); e != nil { + return e + } + if e = walker.walk(destination, "/", 0, b); e != nil { + return e + } + totalEntries = walker.entries + if string(a.Sum(nil)) != string(b.Sum(nil)) { + return fmt.Errorf("bind tree changed or has different nested mounts") + } + return nil + } + e = check() + ce := errors.Join(source.Close(), destination.Close()) + if e != nil || ce != nil { + return errors.Join(e, ce) + } + } + return nil +} + +type trustedImageLease struct { + mu sync.Mutex + Inodes []ImageInode + Complete bool + source imageEvidenceSource + process *linuxLease // borrowed: caller must retain it until this lease closes + root *os.File + index *lowerIndexLease + rootEvidence processRootEvidence + evidenceHash [32]byte + failure error + closed bool +} + +func evidenceHash(e containerdImageEvidence) ([32]byte, error) { + b, err := json.Marshal(e) + return sha256.Sum256(b), err +} + +// Host/runtime privilege and immutable runtime layers are trusted. This rejects +// unproved mounts rather than pretending annotations establish immunity. No +// support for Docker, unvalidated bind aliases, redirected/data-only/nested overlay lowers, +// pivoted roots, shared PID/mount namespaces or arbitrary runtime layouts. +func acquireImageEvidence(ctx context.Context, source imageEvidenceSource, process *linuxLease, hostRoot string, limits ImageIndexLimits) (*trustedImageLease, error) { + if source == nil || process == nil || process.identity.Runtime != "containerd" { + return nil, fmt.Errorf("trusted containerd process lease required") + } + if err := ctx.Err(); err != nil { + return nil, err + } + evidence, err := source.Evidence(ctx, process.identity.ContainerID) + if err != nil { + return nil, err + } + if err := validateEvidencePodIdentity(evidence, process); err != nil { + return nil, err + } + overlay, err := validateContainerdEvidence(evidence, process.identity.ContainerID, process.identity.PID) + if err != nil { + return nil, err + } + root, observed, err := observeProcessRoot(process) + if err != nil { + return nil, err + } + lease := &trustedImageLease{source: source, process: process, root: root, rootEvidence: observed} + fail := func(err error) (*trustedImageLease, error) { return nil, errors.Join(err, lease.Close()) } + if !sameOverlay(overlay, observed.Overlay) { + return fail(fmt.Errorf("runtime snapshot does not match pinned process root")) + } + lease.evidenceHash, err = evidenceHash(evidence) + if err != nil { + return fail(err) + } + lease.index, err = buildLowerIndex(ctx, hostRoot, overlay.Lower, limits) + if err != nil { + return fail(err) + } + if err = lease.check(ctx); err != nil { + return fail(err) + } + lease.Inodes = append([]ImageInode(nil), lease.index.Inodes...) + lease.Complete = true + return lease, nil +} +func sameOverlay(a, b overlayEvidence) bool { + if a.Upper != b.Upper || a.Work != b.Work || len(a.Lower) != len(b.Lower) { + return false + } + for i := range a.Lower { + if a.Lower[i] != b.Lower[i] { + return false + } + } + return true +} + +// imageEvidenceUnavailable marks only retrieval transport failures. Validation, +// root/index checks and unsafe-volume errors must not inherit this exemption. +type imageEvidenceUnavailable struct{ err error } + +func (e *imageEvidenceUnavailable) Error() string { return e.err.Error() } +func (e *imageEvidenceUnavailable) Unwrap() error { return e.err } + +func transientImageEvidenceRetrieval(err error) bool { + if errdefs.IsUnavailable(err) || status.Code(err) == codes.Unavailable { + return true + } + // Require network provenance: a bare EOF may instead be malformed runtime + // metadata, and filesystem failures are not evidence-source unavailability. + var op *net.OpError + return errors.As(err, &op) && (errors.Is(op, unix.ECONNREFUSED) || + errors.Is(op, unix.ECONNRESET) || errors.Is(op, unix.EPIPE) || errors.Is(op, io.EOF)) +} + +func (l *trustedImageLease) check(ctx context.Context) error { + if l.closed { + return fmt.Errorf("image evidence lease closed") + } + if l.failure != nil { + return l.failure + } + if err := ctx.Err(); err != nil { + return err + } + evidence, err := l.source.Evidence(ctx, l.process.identity.ContainerID) + if err != nil { + if transientImageEvidenceRetrieval(err) { + return &imageEvidenceUnavailable{err: err} + } + return err + } + if err = validateEvidencePodIdentity(evidence, l.process); err != nil { + return err + } + if _, err = validateContainerdEvidence(evidence, l.process.identity.ContainerID, l.process.identity.PID); err != nil { + return err + } + sum, err := evidenceHash(evidence) + if err != nil { + return err + } + if sum != l.evidenceHash { + return fmt.Errorf("runtime image evidence changed") + } + root, current, err := observeProcessRoot(l.process) + if err != nil { + return err + } + if err = root.Close(); err != nil { + return err + } + before := l.rootEvidence + if current.MountID != before.MountID || current.Device != before.Device || current.Inode != before.Inode || !sameOverlay(current.Overlay, before.Overlay) { + return fmt.Errorf("process root evidence changed") + } + if err := l.index.CheckContext(ctx); err != nil { + return err + } + walker := volumeWalker{ctx: ctx, rejectRedirect: true, allowSpecial: true} + for _, layer := range l.index.layers { + if err = walker.walk(layer.file, "/", 0, sha256.New()); err != nil { + return err + } + } + if err = validateBindVolumes(ctx, evidence, l.index, l.root, l.process); err != nil { + return err + } + return l.process.Check() +} +func (l *trustedImageLease) Check() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return l.CheckContext(ctx) +} + +// CheckContext preserves the sticky evidence-failure boundary while allowing the +// persistent owner to bound runtime re-attestation with its operation context. +func (l *trustedImageLease) CheckContext(ctx context.Context) error { + l.mu.Lock() + defer l.mu.Unlock() + err := l.check(ctx) + // An interrupted observation is not evidence that the pinned root changed. + // Likewise, transport unavailability cannot attest a change. Fail this + // operation, but permit a later full re-attestation of the same pinned lease. + var unavailable *imageEvidenceUnavailable + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) && !errors.As(err, &unavailable) { + l.Complete = false + l.failure = err + } + return err +} +func (l *trustedImageLease) Close() error { + l.mu.Lock() + defer l.mu.Unlock() + if l.closed { + return nil + } + l.closed = true + l.Complete = false + var errs []error + if l.index != nil { + errs = append(errs, l.index.Close()) + } + if l.root != nil { + errs = append(errs, l.root.Close()) + } + return errors.Join(errs...) +} diff --git a/pkg/sandboxpolicy/igbackend/imageevidence_kernel_test.go b/pkg/sandboxpolicy/igbackend/imageevidence_kernel_test.go new file mode 100644 index 0000000000..96fc455f98 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/imageevidence_kernel_test.go @@ -0,0 +1,126 @@ +package igbackend + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/kubescape/node-agent/pkg/sandboxpolicy" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +// This is an optional real containerd root-discovery proof, not a host test. +// Provision a disposable k8s.io namespace task first, with an exact 64-hex ID. +// The task must use io.containerd.runc.v2, drop CAP_SYS_CHROOT from every +// capability set, and use canonicalRootSeccomp's exact native-x86_64 errno +// barrier. Default OCI capabilities/seccomp do NOT satisfy this matrix. +// The fixture never creates, deletes or mutates containers or mount namespaces. +func TestContainerdImageEvidenceKernel(t *testing.T) { + if os.Getenv("IG_IMAGE_EVIDENCE_KERNEL_TEST") != "1" { + t.Skip("explicit disposable containerd guest opt-in required") + } + socket := os.Getenv("IG_IMAGE_EVIDENCE_SOCKET") + if socket == "" { + socket = "/run/containerd/containerd.sock" + } + id := os.Getenv("IG_IMAGE_EVIDENCE_CONTAINER_ID") + require.True(t, fullContainerID(id)) + source, err := newContainerdImageSource(socket) + require.NoError(t, err) + defer source.Close() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + evidence, err := source.Evidence(ctx, id) + require.NoError(t, err) + bytes, err := os.ReadFile(fmt.Sprintf("/proc/%d/cgroup", evidence.PID)) + require.NoError(t, err) + var cg string + for _, line := range strings.Split(string(bytes), "\n") { + if strings.HasPrefix(line, "0::") { + cg = strings.TrimPrefix(line, "0::") + } + } + require.NotEmpty(t, cg) + require.NotEqual(t, "/", cg) + var cgroup, mountns unix.Stat_t + require.NoError(t, unix.Stat("/sys/fs/cgroup"+cg, &cgroup)) + require.NoError(t, unix.Stat(fmt.Sprintf("/proc/%d/ns/mnt", evidence.PID), &mountns)) + binding := sandboxpolicy.Binding{Runtime: "containerd", ContainerID: id, PID: evidence.PID, CgroupID: cgroup.Ino, CgroupPath: cg, MountNamespace: mountns.Ino} + process, err := (LinuxValidator{}).Acquire(ctx, binding) + require.NoError(t, err) + defer process.Close() + lease, err := acquireImageEvidence(ctx, source, process.(*linuxLease), "/", ImageIndexLimits{}) + require.NoError(t, err) + defer lease.Close() + require.True(t, lease.Complete) + require.NotEmpty(t, lease.Inodes) + require.NoError(t, lease.Check()) + t.Logf("trusted containerd root/index validated: id=%s pid=%d inodes=%d rootMount=%d", id, binding.PID, len(lease.Inodes), lease.rootEvidence.MountID) + require.NoError(t, lease.Close()) + require.NoError(t, lease.Close()) + require.Error(t, lease.Check()) +} + +// This optional proof exercises the production combined adapter, including host +// namespace authentication and client/lease ownership. It does not attach the +// enforcer: the full native integration requires a separate proof. +func TestContainerdFullLeaseKernel(t *testing.T) { + if os.Getenv("IG_IMAGE_EVIDENCE_KERNEL_TEST") != "1" { + t.Skip("explicit disposable containerd guest opt-in required") + } + socket := os.Getenv("IG_IMAGE_EVIDENCE_SOCKET") + if socket == "" { + socket = "/run/containerd/containerd.sock" + } + id := os.Getenv("IG_IMAGE_EVIDENCE_CONTAINER_ID") + require.True(t, fullContainerID(id)) + source, err := NewContainerdFullSource(socket, "/", "/proc", "/sys/fs/cgroup", ImageIndexLimits{}) + require.NoError(t, err) + defer source.Close() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + evidence, err := source.source.Evidence(ctx, id) + require.NoError(t, err) + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cgroup", evidence.PID)) + require.NoError(t, err) + var cg string + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "0::") { + cg = strings.TrimPrefix(line, "0::") + } + } + require.NotEmpty(t, cg) + require.NotEqual(t, "/", cg) + var cgroup, mountns unix.Stat_t + require.NoError(t, unix.Stat("/sys/fs/cgroup"+cg, &cgroup)) + require.NoError(t, unix.Stat(fmt.Sprintf("/proc/%d/ns/mnt", evidence.PID), &mountns)) + binding := sandboxpolicy.Binding{Runtime: "containerd", ContainerID: id, PID: evidence.PID, CgroupID: cgroup.Ino, CgroupPath: cg, MountNamespace: mountns.Ino} + lease, err := source.Prepare(ctx, binding) + require.NoError(t, err) + defer lease.Close() + require.NoError(t, lease.Check(ctx)) + require.NotNil(t, lease.InitProc()) + require.NotNil(t, lease.Cgroup()) + require.NotNil(t, lease.MountNamespace()) + require.NotNil(t, lease.HostMountNamespace()) + origins := lease.ImageOrigins() + require.NotNil(t, origins) + require.True(t, origins.Complete) + require.NotEmpty(t, origins.Keys) + // Client shutdown cannot race lease ownership; a refused close seals new + // admissions but must not invalidate already acquired evidence. + require.Error(t, source.Close()) + require.NoError(t, lease.Check(ctx)) + proc := lease.InitProc() + require.NoError(t, lease.Close()) + require.NoError(t, lease.Close()) + require.Error(t, lease.Check(ctx)) + require.Equal(t, ^uintptr(0), proc.Fd()) + require.NoError(t, source.Close()) + require.NoError(t, source.Close()) + t.Logf("combined trusted lease validated: id=%s pid=%d origins=%d", id, binding.PID, len(origins.Keys)) +} diff --git a/pkg/sandboxpolicy/igbackend/imageevidence_test.go b/pkg/sandboxpolicy/igbackend/imageevidence_test.go new file mode 100644 index 0000000000..42e48bbe09 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/imageevidence_test.go @@ -0,0 +1,620 @@ +package igbackend + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/containerd/containerd/mount" + "github.com/containerd/containerd/snapshots" + "github.com/containerd/errdefs" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" + "github.com/opencontainers/go-digest" + imageidentity "github.com/opencontainers/image-spec/identity" + specs "github.com/opencontainers/runtime-spec/specs-go" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func goodImageEvidence() containerdImageEvidence { + diff := digest.FromString("layer") + chain := imageidentity.ChainID([]digest.Digest{diff}).String() + return containerdImageEvidence{Namespace: "k8s.io", RuntimeName: "io.containerd.runc.v2", ID: strings.Repeat("a", 64), Image: "registry/image@sha256:" + strings.Repeat("b", 64), Snapshotter: "overlayfs", SnapshotKey: "active", PID: 42, Running: true, + Spec: &specs.Spec{Root: &specs.Root{Path: "rootfs"}, Process: &specs.Process{Capabilities: &specs.LinuxCapabilities{Bounding: []string{"CAP_CHOWN"}}}, Linux: &specs.Linux{Seccomp: canonicalRootSeccomp(), Namespaces: []specs.LinuxNamespace{{Type: specs.MountNamespace}, {Type: specs.PIDNamespace}}}}, + DiffIDs: []digest.Digest{diff}, Active: snapshots.Info{Name: "active", Kind: snapshots.KindActive, Parent: chain}, Parents: []snapshots.Info{{Name: chain, Kind: snapshots.KindCommitted}}, + Mounts: []mount.Mount{{Type: "overlay", Source: "overlay", Options: []string{"lowerdir=/snapshots/1/fs", "upperdir=/snapshots/2/fs", "workdir=/snapshots/2/work"}}}} +} + +type fakeImageEvidenceSource struct { + evidence containerdImageEvidence + err error +} + +func (f fakeImageEvidenceSource) Evidence(ctx context.Context, _ string) (containerdImageEvidence, error) { + if err := ctx.Err(); err != nil { + return containerdImageEvidence{}, err + } + return f.evidence, f.err +} +func TestContainerdImageEvidenceRejectsForgedInputs(t *testing.T) { + tests := map[string]func(*containerdImageEvidence){ + "namespace": func(e *containerdImageEvidence) { e.Namespace = "default" }, "id": func(e *containerdImageEvidence) { e.ID = strings.Repeat("b", 64) }, "pid": func(e *containerdImageEvidence) { e.PID++ }, "stopped": func(e *containerdImageEvidence) { e.Running = false }, + "spec": func(e *containerdImageEvidence) { e.Spec = nil }, "snapshotter": func(e *containerdImageEvidence) { e.Snapshotter = "native" }, "active-kind": func(e *containerdImageEvidence) { e.Active.Kind = snapshots.KindCommitted }, "active-name": func(e *containerdImageEvidence) { e.Active.Name = "other" }, "active-parent": func(e *containerdImageEvidence) { e.Active.Parent = "other" }, + "parent-kind": func(e *containerdImageEvidence) { e.Parents[0].Kind = snapshots.KindActive }, "parent-chain": func(e *containerdImageEvidence) { e.Parents[0].Parent = "cycle" }, "parent-name": func(e *containerdImageEvidence) { e.Parents[0].Name = "wrong" }, "diff-id": func(e *containerdImageEvidence) { e.DiffIDs[0] = "invalid" }, "chain-budget": func(e *containerdImageEvidence) { e.DiffIDs = make([]digest.Digest, 129) }, + "root": func(e *containerdImageEvidence) { e.Spec.Root.Path = "../outside" }, "mount-spec": func(e *containerdImageEvidence) { e.Mounts[0].Type = "bind" }, "mount-options": func(e *containerdImageEvidence) { e.Mounts[0].Options = append(e.Mounts[0].Options, "redirect_dir=on") }, "mount-layers": func(e *containerdImageEvidence) { e.Mounts[0].Options[0] = "lowerdir=/a:/b" }, + "host-pid": func(e *containerdImageEvidence) { e.Spec.Linux.Namespaces[1].Path = "/proc/1/ns/pid" }, "host-mount": func(e *containerdImageEvidence) { e.Spec.Linux.Namespaces[0].Path = "/proc/1/ns/mnt" }, "missing-ns": func(e *containerdImageEvidence) { e.Spec.Linux.Namespaces = nil }, + "sysadmin": func(e *containerdImageEvidence) { e.Spec.Process.Capabilities.Bounding = []string{"CAP_SYS_ADMIN"} }, "checkpoint": func(e *containerdImageEvidence) { + e.Spec.Process.Capabilities.Ambient = []string{"CAP_CHECKPOINT_RESTORE"} + }, "block": func(e *containerdImageEvidence) { e.Spec.Linux.Devices = []specs.LinuxDevice{{Type: "b", Major: 8}} }, "privileged": func(e *containerdImageEvidence) { + e.Spec.Linux.Resources = &specs.LinuxResources{Devices: []specs.LinuxDeviceCgroup{{Allow: true}}} + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + e := goodImageEvidence() + mutate(&e) + _, err := validateContainerdEvidence(e, strings.Repeat("a", 64), 42) + require.Error(t, err) + // Rejection occurs before any process root/index can be marked Complete. + process := &linuxLease{identity: sandboxpolicy.Binding{Runtime: "containerd", ContainerID: strings.Repeat("a", 64), PID: 42}} + lease, err := acquireImageEvidence(context.Background(), fakeImageEvidenceSource{evidence: e}, process, "/", ImageIndexLimits{}) + require.Error(t, err) + require.Nil(t, lease) + }) + } + e := goodImageEvidence() + overlay, err := validateContainerdEvidence(e, e.ID, e.PID) + require.NoError(t, err) + require.Equal(t, []string{"/snapshots/1/fs"}, overlay.Lower) +} +func TestOverlayEvidenceStrictSyntax(t *testing.T) { + for _, option := range []string{"lowerdir=relative", "lowerdir=/a::/b", "lowerdir=/a\\040b", "lowerdir=/a/../b", "lowerdir=/a%2fb", "lowerdir=/a:/a", "lowerdir=/snapshots/2/fs", "upperdir=/a,b", "redirect_dir=on", "data-only=/a", "metacopy=maybe", "unknown=1"} { + options := []string{"lowerdir=/snapshots/1/fs", "upperdir=/snapshots/2/fs", "workdir=/snapshots/2/work"} + key, _, _ := strings.Cut(option, "=") + replaced := false + for i, old := range options { + if strings.HasPrefix(old, key+"=") { + options[i] = option + replaced = true + } + } + if !replaced { + options = append(options, option) + } + _, err := parseOverlayEvidence(options) + require.Error(t, err, option) + } +} +func TestPinnedRootMountEvidence(t *testing.T) { + good := "123 2 0:7 / / rw,relatime - overlay overlay rw,lowerdir=/a,upperdir=/b,workdir=/c\n" + root, err := parseProcessRootEvidence([]byte(good), 123) + require.NoError(t, err) + require.Equal(t, "/b", root.Upper) + for _, data := range []string{strings.Replace(good, " / / ", " /subtree / ", 1), strings.Replace(good, " / / ", " / /elsewhere ", 1), strings.Replace(good, "- overlay overlay", "- ext4 disk", 1), good + good, "broken", strings.Repeat("x", 65537)} { + _, err := parseProcessRootEvidence([]byte(data), 123) + require.Error(t, err) + } + _, err = parseProcessRootEvidence([]byte(good), 124) + require.Error(t, err) + require.False(t, sameOverlay(root, overlayEvidence{Lower: []string{"/forged"}, Upper: "/b", Work: "/c"})) +} +func TestImageEvidenceCancellationAndSourceFailure(t *testing.T) { + process := &linuxLease{identity: sandboxpolicy.Binding{Runtime: "containerd", ContainerID: strings.Repeat("a", 64), PID: 42}} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + lease, err := acquireImageEvidence(ctx, fakeImageEvidenceSource{}, process, "/", ImageIndexLimits{}) + require.ErrorIs(t, err, context.Canceled) + require.Nil(t, lease) + fault := errors.New("runtime unavailable") + lease, err = acquireImageEvidence(context.Background(), fakeImageEvidenceSource{err: fault}, process, "/", ImageIndexLimits{}) + require.ErrorIs(t, err, fault) + require.Nil(t, lease) +} +func TestVolumePolicyAndAliasChecks(t *testing.T) { + e := goodImageEvidence() + e.Spec.Mounts = []specs.Mount{{Type: "bind", Source: "/var/lib/kubelet/pods/uid/hosts", Destination: "/etc/hosts", Options: []string{"rbind", "rw"}}, {Type: "bind", Source: "/var/lib/kubelet/pods/uid/projected", Destination: "/var/run/secrets/kubernetes.io/serviceaccount", Options: []string{"rbind", "ro"}}} + require.NoError(t, validateImageSpec(e.Spec)) + for _, source := range []string{"/etc", "/etc/passwd", "/run/containerd/containerd.sock", "/var/lib/containerd", "/snapshots/1/fs", "/snapshots"} { + require.True(t, sensitiveVolumeSource(source, []lowerLayer{{name: "/snapshots/1/fs"}}), source) + } + require.False(t, sensitiveVolumeSource("/var/lib/kubelet/pods/uid/volume", []lowerLayer{{name: "/snapshots/1/fs"}})) + root, lower := indexFixture(t) + indexFiles(t, lower) + file, err := os.Open(filepath.Join(lower, "plain")) + require.NoError(t, err) + defer file.Close() + stat, err := indexStat(file) + require.NoError(t, err) + key := ImageInode{stat.Ino, unix.Major(stat.Dev), unix.Minor(stat.Dev)} + walker := volumeWalker{ctx: context.Background(), lower: map[ImageInode]bool{key: true}, writable: true} + require.ErrorContains(t, walker.walk(file, "/", 0, sha256.New()), "aliases") + walker.writable = false + require.NoError(t, walker.walk(file, "/", 0, sha256.New())) + projected := filepath.Join(root, "projected") + require.NoError(t, os.MkdirAll(filepath.Join(projected, "..version"), 0700)) + require.NoError(t, os.WriteFile(filepath.Join(projected, "..version", "token"), []byte("token"), 0600)) + require.NoError(t, os.Symlink("..version", filepath.Join(projected, "..data"))) + require.NoError(t, os.Symlink("..data/token", filepath.Join(projected, "token"))) + directory, err := os.Open(projected) + require.NoError(t, err) + defer directory.Close() + require.NoError(t, walker.walk(directory, "/", 0, sha256.New())) + require.NoError(t, unix.Mkfifo(filepath.Join(projected, "unsafe"), 0600)) + require.ErrorContains(t, walker.walk(directory, "/", 0, sha256.New()), "socket/device/special") + walker.entries = 262144 + require.Error(t, walker.walk(directory, "/", 0, sha256.New())) +} +func TestContainerdSocketRequiresExplicitRealSocket(t *testing.T) { + for _, socket := range []string{"relative", "unix:///run/containerd/containerd.sock", "/tmp/../socket", "/proc/self/status"} { + source, err := newContainerdImageSource(socket) + require.Error(t, err, fmt.Sprint(socket)) + require.Nil(t, source) + } +} + +func TestLowerRedirectEvidenceRefusal(t *testing.T) { + _, lower := indexFixture(t) + indexFiles(t, lower) + file, err := os.Open(lower) + require.NoError(t, err) + defer file.Close() + walker := volumeWalker{ctx: context.Background(), rejectRedirect: true, allowSpecial: true} + require.NoError(t, walker.walk(file, "/", 0, sha256.New())) + require.NoError(t, unix.Setxattr(lower, "user.overlay.redirect", []byte("/elsewhere"), 0)) + require.ErrorContains(t, walker.walk(file, "/", 0, sha256.New()), "redirected") +} +func TestContainerSideSymlinkRootResolution(t *testing.T) { + root, _ := indexFixture(t) + require.NoError(t, os.MkdirAll(filepath.Join(root, "run", "secrets"), 0700)) + require.NoError(t, os.Mkdir(filepath.Join(root, "var"), 0700)) + require.NoError(t, os.Symlink("/run", filepath.Join(root, "var", "run"))) + require.NoError(t, os.WriteFile(filepath.Join(root, "run", "secrets", "token"), []byte("data"), 0600)) + file, err := os.Open(root) + require.NoError(t, err) + defer file.Close() + fd, err := unix.Openat2(int(file.Fd()), "/var/run/secrets/token", &unix.OpenHow{Flags: unix.O_PATH | unix.O_CLOEXEC, Resolve: unix.RESOLVE_IN_ROOT | unix.RESOLVE_NO_MAGICLINKS}) + require.NoError(t, err) + require.NoError(t, unix.Close(fd)) + require.NoError(t, os.Symlink("/proc/self/fd/0", filepath.Join(root, "escape"))) + fd, err = unix.Openat2(int(file.Fd()), "/escape", &unix.OpenHow{Flags: unix.O_PATH | unix.O_CLOEXEC, Resolve: unix.RESOLVE_IN_ROOT | unix.RESOLVE_NO_MAGICLINKS}) + require.Error(t, err) + require.Equal(t, -1, fd) +} + +func TestStandardSHMAndOverlayUUID(t *testing.T) { + e := goodImageEvidence() + e.Spec.Mounts = []specs.Mount{{Type: "tmpfs", Source: "shm", Destination: "/dev/shm", Options: []string{"nosuid", "noexec", "nodev", "mode=1777", "size=65536k"}}} + require.NoError(t, validateImageSpec(e.Spec)) + e.Spec.Mounts[0].Destination = "/run" + require.Error(t, validateImageSpec(e.Spec)) + e.Spec.Mounts[0].Destination = "/dev/shm" + e.Spec.Mounts[0].Source = "/host/shm" + require.Error(t, validateImageSpec(e.Spec)) + options := append(goodImageEvidence().Mounts[0].Options, "uuid=on") + _, err := parseOverlayEvidence(options) + require.NoError(t, err) + options[len(options)-1] = "uuid=anything" + _, err = parseOverlayEvidence(options) + require.Error(t, err) +} +func TestAuthenticatedSandboxGeneratedFiles(t *testing.T) { + id, sandbox := strings.Repeat("a", 64), strings.Repeat("b", 64) + raw := []byte(fmt.Sprintf(`{"Version":"v1","Metadata":{"ID":%q,"SandboxID":%q}}`, id, sandbox)) + actual, err := criContainerSandbox(raw, id) + require.NoError(t, err) + require.Equal(t, sandbox, actual) + _, err = criContainerSandbox(raw, sandbox) + require.Error(t, err) + pod := []byte(fmt.Sprintf(`{"Version":"v1","Metadata":{"ID":%q,"Config":{"Metadata":{"UID":"pod-uid","Namespace":"tenant"}}}}`, sandbox)) + uid, namespace, err := criSandboxIdentity(pod, sandbox) + require.NoError(t, err) + require.Equal(t, "pod-uid", uid) + require.Equal(t, "tenant", namespace) + _, _, err = criSandboxIdentity(pod, id) + require.Error(t, err) + e := goodImageEvidence() + e.SandboxID = sandbox + e.PodUID = uid + e.PodNamespace = namespace + e.Spec.Annotations = map[string]string{"io.kubernetes.cri.sandbox-id": sandbox, "io.kubernetes.cri.container-type": "container"} + lower := []lowerLayer{{name: "/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/123/fs"}} + for _, name := range []string{"hosts", "hostname", "resolv.conf"} { + m := specs.Mount{Type: "bind", Source: "/var/lib/containerd/io.containerd.grpc.v1.cri/sandboxes/" + sandbox + "/" + name, Destination: "/etc/" + name} + require.True(t, validatedSandboxFile(e, m, lower)) + m.Source = strings.Replace(m.Source, sandbox, id, 1) + require.False(t, validatedSandboxFile(e, m, lower)) + } + m := specs.Mount{Source: "/var/lib/containerd/io.containerd.metadata.v1.bolt/meta.db", Destination: "/etc/hosts"} + require.False(t, validatedSandboxFile(e, m, lower)) + m = specs.Mount{Source: "/run/containerd/containerd.sock", Destination: "/etc/hosts"} + require.False(t, validatedSandboxFile(e, m, lower)) + _, err = validateContainerdEvidence(e, id, 42) + require.NoError(t, err) + e.Spec.Annotations["io.kubernetes.cri.sandbox-id"] = id + _, err = validateContainerdEvidence(e, id, 42) + require.Error(t, err) + process := &linuxLease{identity: sandboxpolicy.Binding{Namespace: "other"}} + require.Error(t, validateEvidencePodIdentity(e, process)) +} + +func canonicalRootSeccomp() *specs.LinuxSeccomp { + errno := uint(unix.EPERM) + return &specs.LinuxSeccomp{DefaultAction: specs.ActAllow, Architectures: []specs.Arch{specs.ArchX86_64}, Syscalls: []specs.LinuxSyscall{ + {Names: []string{"chroot", "pivot_root", "unshare", "setns", "clone3"}, Action: specs.ActErrno, ErrnoRet: &errno}, + {Names: []string{"clone"}, Action: specs.ActErrno, ErrnoRet: &errno, Args: []specs.LinuxSeccompArg{{Index: 0, Value: uint64(unix.CLONE_NEWUSER), ValueTwo: uint64(unix.CLONE_NEWUSER), Op: specs.OpMaskedEqual}}}, + {Names: []string{"clone"}, Action: specs.ActErrno, ErrnoRet: &errno, Args: []specs.LinuxSeccompArg{{Index: 0, Value: uint64(unix.CLONE_NEWNS), ValueTwo: uint64(unix.CLONE_NEWNS), Op: specs.OpMaskedEqual}}}, + }} +} +func TestRootChangesRequireSynchronousSeccompBarrier(t *testing.T) { + e := goodImageEvidence() + require.NoError(t, validateImageSpec(e.Spec)) + e.Spec.Process.Capabilities.Bounding = append(e.Spec.Process.Capabilities.Bounding, "CAP_SYS_CHROOT") + require.ErrorContains(t, validateImageSpec(e.Spec), "CAP_SYS_CHROOT") + for _, mutate := range []func(*specs.LinuxSeccomp){ + func(p *specs.LinuxSeccomp) { p.DefaultAction = specs.ActErrno }, func(p *specs.LinuxSeccomp) { p.Architectures = nil }, func(p *specs.LinuxSeccomp) { p.Architectures = append(p.Architectures, specs.ArchX86) }, + func(p *specs.LinuxSeccomp) { + p.Syscalls[0].Args = []specs.LinuxSeccompArg{{Index: 0, Value: 0, Op: specs.OpEqualTo}} + }, func(p *specs.LinuxSeccomp) { p.Syscalls[0].Action = specs.ActAllow }, + func(p *specs.LinuxSeccomp) { p.Syscalls[0].Names = p.Syscalls[0].Names[1:] }, func(p *specs.LinuxSeccomp) { p.Syscalls[0].ErrnoRet = nil }, + func(p *specs.LinuxSeccomp) { p.Syscalls[1].Args[0].Index = 1 }, func(p *specs.LinuxSeccomp) { p.Syscalls[1].Args[0].ValueTwo = 0 }, func(p *specs.LinuxSeccomp) { p.Syscalls = p.Syscalls[:2] }, + } { + profile := canonicalRootSeccomp() + mutate(profile) + require.Error(t, validateRootSeccomp(profile)) + } + require.Error(t, validateRootSeccomp(nil)) +} + +func TestLiveRootBarrierStatus(t *testing.T) { + good := []byte("Seccomp:\t2\nCapBnd:\t0\nCapEff:\t0\nCapPrm:\t0\nCapInh:\t0\nCapAmb:\t0\n") + require.NoError(t, validateLiveRootBarrier(good)) + require.Error(t, validateLiveRootBarrier([]byte("Seccomp:\t0\n"))) + bad := strings.Replace(string(good), "CapBnd:\t0", fmt.Sprintf("CapBnd:\t%x", uint64(1)< max { + return l, fmt.Errorf("invalid image index budget") + } + if *values[i] == 0 { + *values[i] = max + } + } + return l, nil +} +func indexOpen(parent int, name string, flags uint64, noCross bool) (*os.File, error) { + resolve := uint64(unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS) + if noCross { + resolve |= unix.RESOLVE_NO_XDEV + } + fd, err := unix.Openat2(parent, name, &unix.OpenHow{Flags: flags | unix.O_CLOEXEC, Resolve: resolve}) + if err != nil { + return nil, err + } + return os.NewFile(uintptr(fd), name), nil +} +func indexStat(file *os.File) (inodeFingerprint, error) { + var s unix.Stat_t + err := unix.Fstat(int(file.Fd()), &s) + return fingerprint(s), err +} +func indexFS(file *os.File) error { + var fs unix.Statfs_t + if err := unix.Fstatfs(int(file.Fd()), &fs); err != nil { + return err + } + switch fs.Type { + case unix.EXT4_SUPER_MAGIC, unix.XFS_SUPER_MAGIC, unix.BTRFS_SUPER_MAGIC, unix.TMPFS_MAGIC: + return nil + } + return fmt.Errorf("unsupported lower filesystem %#x (overlay/nested roots are not accepted)", fs.Type) +} + +// buildLowerIndex does NOT establish that these paths are image layers. The +// caller must derive them from authenticated runtime snapshot metadata, match +// the live process root, and validate immutable lower ownership/mount semantics. +// Fingerprints detect ordinary churn, not a privileged host capable of mutating +// supposedly immutable layers or racing observation. A Complete result only +// means this bounded walk completed, not that those external guarantees hold. +func buildLowerIndex(ctx context.Context, hostRoot string, lowerDirs []string, limits ImageIndexLimits) (result *lowerIndexLease, err error) { + if err = ctx.Err(); err != nil { + return nil, err + } + limits, err = normalizeIndexLimits(limits) + if err != nil { + return nil, err + } + if !path.IsAbs(hostRoot) || path.Clean(hostRoot) != hostRoot || len(hostRoot) > 4096 { + return nil, fmt.Errorf("invalid host root") + } + if len(lowerDirs) == 0 || len(lowerDirs) > limits.Roots { + return nil, fmt.Errorf("invalid lower root count") + } + // The configured host root itself must not resolve through symlinks. + fd, e := unix.Openat2(unix.AT_FDCWD, hostRoot, &unix.OpenHow{Flags: unix.O_RDONLY | unix.O_DIRECTORY | unix.O_CLOEXEC, Resolve: unix.RESOLVE_NO_SYMLINKS}) + if e != nil { + return nil, e + } + lease := &lowerIndexLease{host: os.NewFile(uintptr(fd), hostRoot), limits: limits} + defer func() { + if err != nil { + err = errors.Join(err, lease.Close()) + } + }() + seen := map[string]bool{} + for _, name := range lowerDirs { + if e = ctx.Err(); e != nil { + return nil, e + } + if !path.IsAbs(name) || name == "/" || path.Clean(name) != name || len(name) > limits.PathBytes || seen[name] { + return nil, fmt.Errorf("invalid or duplicate lower directory %q", name) + } + seen[name] = true + file, e := indexOpen(int(lease.host.Fd()), strings.TrimPrefix(name, "/"), unix.O_RDONLY|unix.O_DIRECTORY, false) + if e != nil { + return nil, e + } + lease.layers = append(lease.layers, lowerLayer{name: name, file: file}) + if e = indexFS(file); e != nil { + return nil, e + } + identity, e := indexStat(file) + if e != nil { + return nil, e + } + lease.layers[len(lease.layers)-1].identity = identity + } + keys, digests, e := lease.scan(ctx) + if e != nil { + return nil, e + } + for i := range lease.layers { + lease.layers[i].digest = digests[i] + } + // A second bounded scan catches changes while another layer was being read. + if e = lease.check(ctx); e != nil { + return nil, e + } + lease.Inodes = keys + lease.Complete = true + return lease, nil +} + +type indexWalker struct { + ctx context.Context + limits ImageIndexLimits + entries int + keys map[ImageInode]struct{} +} + +func (w *indexWalker) walk(dir *os.File, name string, depth int, h hash.Hash) (retErr error) { + if err := w.ctx.Err(); err != nil { + return err + } + if depth > w.limits.Depth || len(name) > w.limits.PathBytes { + return fmt.Errorf("image index depth/path budget exceeded") + } + before, err := indexStat(dir) + if err != nil { + return err + } + // Reopen an independent directory description; never share readdir offsets. + reader, err := indexOpen(int(dir.Fd()), ".", unix.O_RDONLY|unix.O_DIRECTORY, true) + if err != nil { + return err + } + defer func() { retErr = errors.Join(retErr, reader.Close()) }() + var names []string + for { + batch, e := reader.Readdirnames(128) + for _, child := range batch { + w.entries++ + if w.entries > w.limits.Entries { + return fmt.Errorf("image index entry budget exceeded") + } + names = append(names, child) + } + if e == io.EOF { + break + } + if e != nil { + return e + } + if e = w.ctx.Err(); e != nil { + return e + } + } + sort.Strings(names) + fmt.Fprintf(h, "%q:%+v\n", name, before) + for _, child := range names { + if err = w.ctx.Err(); err != nil { + return err + } + childName := path.Join(name, child) + if len(childName) > w.limits.PathBytes { + return fmt.Errorf("image index path budget exceeded") + } + // O_PATH|O_NOFOLLOW lets us inspect (but never follow) final symlinks. + file, e := indexOpen(int(dir.Fd()), child, unix.O_PATH|unix.O_NOFOLLOW, true) + if e != nil { + return e + } + stat, e := indexStat(file) + closeErr := file.Close() + if e != nil || closeErr != nil { + return errors.Join(e, closeErr) + } + fmt.Fprintf(h, "%q:%+v\n", childName, stat) + switch stat.Mode & unix.S_IFMT { + case unix.S_IFREG: + key := ImageInode{stat.Ino, unix.Major(stat.Dev), unix.Minor(stat.Dev)} + w.keys[key] = struct{}{} + if len(w.keys) > w.limits.Files { + return fmt.Errorf("image index file budget exceeded") + } + case unix.S_IFDIR: + sub, e := indexOpen(int(dir.Fd()), child, unix.O_RDONLY|unix.O_DIRECTORY, true) + if e != nil { + return e + } + current, e := indexStat(sub) + if e == nil && current != stat { + e = fmt.Errorf("lower directory changed before traversal") + } + if e == nil { + e = w.walk(sub, childName, depth+1, h) + } + closeErr := sub.Close() + if e != nil || closeErr != nil { + return errors.Join(e, closeErr) + } + // Symlinks, devices, FIFOs and sockets are intentionally not image files. + } + } + after, err := indexStat(dir) + if err != nil { + return err + } + if before != after { + return fmt.Errorf("lower directory changed during traversal") + } + return nil +} +func (l *lowerIndexLease) scan(ctx context.Context) ([]ImageInode, [][32]byte, error) { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + // Revalidate the configured host-root path as well as the pinned layers. + fd, err := unix.Openat2(unix.AT_FDCWD, l.host.Name(), &unix.OpenHow{Flags: unix.O_RDONLY | unix.O_DIRECTORY | unix.O_CLOEXEC, Resolve: unix.RESOLVE_NO_SYMLINKS}) + if err != nil { + return nil, nil, err + } + reopened := os.NewFile(uintptr(fd), l.host.Name()) + current, e := indexStat(reopened) + pinned, pe := indexStat(l.host) + ce := reopened.Close() + if e != nil || pe != nil || ce != nil { + return nil, nil, errors.Join(e, pe, ce) + } + if current.Dev != pinned.Dev || current.Ino != pinned.Ino { + return nil, nil, fmt.Errorf("host root identity changed") + } + w := indexWalker{ctx: ctx, limits: l.limits, keys: map[ImageInode]struct{}{}} + digests := make([][32]byte, len(l.layers)) + for i, layer := range l.layers { + reopened, err := indexOpen(int(l.host.Fd()), strings.TrimPrefix(layer.name, "/"), unix.O_RDONLY|unix.O_DIRECTORY, false) + if err != nil { + return nil, nil, err + } + identity, e := indexStat(reopened) + fsErr := indexFS(reopened) + closeErr := reopened.Close() + if e != nil || fsErr != nil || closeErr != nil { + return nil, nil, errors.Join(e, fsErr, closeErr) + } + pinned, e := indexStat(layer.file) + if e != nil { + return nil, nil, e + } + if identity != layer.identity || pinned != layer.identity { + return nil, nil, fmt.Errorf("lower root identity or fingerprint changed") + } + h := sha256.New() + if e = w.walk(layer.file, layer.name, 0, h); e != nil { + return nil, nil, e + } + copy(digests[i][:], h.Sum(nil)) + } + keys := make([]ImageInode, 0, len(w.keys)) + for key := range w.keys { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + a, b := keys[i], keys[j] + if a.Major != b.Major { + return a.Major < b.Major + } + if a.Minor != b.Minor { + return a.Minor < b.Minor + } + return a.Inode < b.Inode + }) + return keys, digests, nil +} +func (l *lowerIndexLease) check(ctx context.Context) error { + if l.closed { + return fmt.Errorf("lower index lease is closed") + } + _, digests, err := l.scan(ctx) + if err != nil { + return err + } + for i := range digests { + if digests[i] != l.layers[i].digest { + return fmt.Errorf("lower layer contents changed") + } + } + return nil +} +func (l *lowerIndexLease) Check() error { return l.CheckContext(context.Background()) } +func (l *lowerIndexLease) CheckContext(ctx context.Context) error { + l.mu.Lock() + defer l.mu.Unlock() + return l.check(ctx) +} +func (l *lowerIndexLease) Close() error { + l.mu.Lock() + defer l.mu.Unlock() + if l.closed { + return nil + } + l.closed = true + l.Complete = false + var errs []error + for _, layer := range l.layers { + errs = append(errs, layer.file.Close()) + } + if l.host != nil { + errs = append(errs, l.host.Close()) + } + return errors.Join(errs...) +} diff --git a/pkg/sandboxpolicy/igbackend/imageindex_test.go b/pkg/sandboxpolicy/igbackend/imageindex_test.go new file mode 100644 index 0000000000..651bd9d30c --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/imageindex_test.go @@ -0,0 +1,198 @@ +package igbackend + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +func indexFixture(t *testing.T) (string, string) { + t.Helper() + root, err := os.MkdirTemp("/dev/shm", "ig-lower-index-") + if err != nil { + t.Skipf("tmpfs fixture unavailable: %v", err) + } + t.Cleanup(func() { require.NoError(t, os.RemoveAll(root)) }) + lower := filepath.Join(root, "layer") + require.NoError(t, os.Mkdir(lower, 0700)) + return root, lower +} +func indexFiles(t *testing.T, lower string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(lower, "plain"), []byte("not executable"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(lower, "exec"), []byte("executable"), 0700)) +} +func descriptorCount(t *testing.T) int { + t.Helper() + entries, err := os.ReadDir("/proc/self/fd") + require.NoError(t, err) + return len(entries) +} +func TestLowerIndexAllRegularAndNoSymlinkTraversal(t *testing.T) { + root, lower := indexFixture(t) + indexFiles(t, lower) + require.NoError(t, os.Link(filepath.Join(lower, "plain"), filepath.Join(lower, "hardlink"))) + require.NoError(t, os.Symlink("/etc/passwd", filepath.Join(lower, "escape"))) + require.NoError(t, os.Symlink("/does/not/exist", filepath.Join(lower, "dangling"))) + require.NoError(t, unix.Mkfifo(filepath.Join(lower, "fifo"), 0600)) + lease, err := buildLowerIndex(context.Background(), root, []string{"/layer"}, ImageIndexLimits{}) + require.NoError(t, err) + defer lease.Close() + require.True(t, lease.Complete) + require.Len(t, lease.Inodes, 2) + require.NoError(t, lease.Check()) + require.NoError(t, lease.Close()) + require.NoError(t, lease.Close()) + require.False(t, lease.Complete) + require.Error(t, lease.Check()) +} +func TestLowerIndexMutations(t *testing.T) { + for _, kind := range []string{"write", "chmod", "create", "remove", "replace-root", "rename-subdir"} { + t.Run(kind, func(t *testing.T) { + root, lower := indexFixture(t) + indexFiles(t, lower) + require.NoError(t, os.Mkdir(filepath.Join(lower, "sub"), 0700)) + require.NoError(t, os.WriteFile(filepath.Join(lower, "sub", "nested"), []byte("data"), 0600)) + lease, err := buildLowerIndex(context.Background(), root, []string{"/layer"}, ImageIndexLimits{}) + require.NoError(t, err) + defer lease.Close() + switch kind { + case "write": + require.NoError(t, os.WriteFile(filepath.Join(lower, "plain"), []byte("changed"), 0600)) + case "chmod": + require.NoError(t, os.Chmod(filepath.Join(lower, "plain"), 0700)) + case "create": + require.NoError(t, os.WriteFile(filepath.Join(lower, "new"), nil, 0600)) + case "remove": + require.NoError(t, os.Remove(filepath.Join(lower, "exec"))) + case "replace-root": + require.NoError(t, os.Rename(lower, lower+"-old")) + require.NoError(t, os.Mkdir(lower, 0700)) + case "rename-subdir": + require.NoError(t, os.Rename(filepath.Join(lower, "sub"), filepath.Join(lower, "other"))) + } + require.Error(t, lease.Check()) + }) + } +} +func TestLowerIndexBudgetsAndFailureCleanup(t *testing.T) { + root, lower := indexFixture(t) + indexFiles(t, lower) + require.NoError(t, os.MkdirAll(filepath.Join(lower, "a", "b"), 0700)) + require.NoError(t, os.WriteFile(filepath.Join(lower, "a", "b", "c"), nil, 0600)) + baseline := descriptorCount(t) + cases := []struct { + names []string + limits ImageIndexLimits + }{ + {[]string{"/layer"}, ImageIndexLimits{Files: 1}}, + {[]string{"/layer"}, ImageIndexLimits{Entries: 1}}, + {[]string{"/layer"}, ImageIndexLimits{Depth: 1}}, + {[]string{"/layer"}, ImageIndexLimits{PathBytes: 7}}, + {[]string{"/layer", "/missing"}, ImageIndexLimits{}}, + {[]string{"/layer", "/other"}, ImageIndexLimits{Roots: 1}}, + {[]string{"/layer", "/layer"}, ImageIndexLimits{}}, + {[]string{"/../layer"}, ImageIndexLimits{}}, + {[]string{"layer"}, ImageIndexLimits{}}, + {[]string{"/"}, ImageIndexLimits{}}, + {nil, ImageIndexLimits{}}, + {[]string{"/layer"}, ImageIndexLimits{Files: 65537}}, + {[]string{"/layer"}, ImageIndexLimits{Roots: -1}}, + } + for _, tc := range cases { + lease, err := buildLowerIndex(context.Background(), root, tc.names, tc.limits) + require.Error(t, err) + require.Nil(t, lease) + require.Equal(t, baseline, descriptorCount(t)) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + lease, err := buildLowerIndex(ctx, root, []string{"/layer"}, ImageIndexLimits{}) + require.ErrorIs(t, err, context.Canceled) + require.Nil(t, lease) + require.Equal(t, baseline, descriptorCount(t)) +} +func TestLowerIndexRejectsSymlinkRoots(t *testing.T) { + root, lower := indexFixture(t) + require.NoError(t, os.Symlink(lower, filepath.Join(root, "alias"))) + baseline := descriptorCount(t) + lease, err := buildLowerIndex(context.Background(), root, []string{"/alias"}, ImageIndexLimits{}) + require.Error(t, err) + require.Nil(t, lease) + lease, err = buildLowerIndex(context.Background(), filepath.Join(root, "alias"), []string{"/child"}, ImageIndexLimits{}) + require.Error(t, err) + require.Nil(t, lease) + require.Equal(t, baseline, descriptorCount(t)) +} +func TestLowerIndexRejectsOverlay(t *testing.T) { + root := t.TempDir() + var fs unix.Statfs_t + require.NoError(t, unix.Statfs(root, &fs)) + if fs.Type != unix.OVERLAYFS_SUPER_MAGIC { + t.Skip("fixture filesystem is not overlay") + } + lower := filepath.Join(root, "layer") + require.NoError(t, os.Mkdir(lower, 0700)) + lease, err := buildLowerIndex(context.Background(), root, []string{"/layer"}, ImageIndexLimits{}) + require.ErrorContains(t, err, "unsupported lower filesystem") + require.Nil(t, lease) +} +func TestLowerIndexMultiLayerDedupAndLeaseCleanup(t *testing.T) { + root, lower := indexFixture(t) + indexFiles(t, lower) + second := filepath.Join(root, "other") + require.NoError(t, os.Mkdir(second, 0700)) + require.NoError(t, os.Link(filepath.Join(lower, "plain"), filepath.Join(second, "linked"))) + before := descriptorCount(t) + lease, err := buildLowerIndex(context.Background(), root, []string{"/layer", "/other"}, ImageIndexLimits{}) + require.NoError(t, err) + require.Len(t, lease.Inodes, 2) + require.Equal(t, before+3, descriptorCount(t)) + require.NoError(t, lease.Close()) + require.Equal(t, before, descriptorCount(t)) +} + +type cancelDuringIndex struct { + context.Context + calls int +} + +func (c *cancelDuringIndex) Err() error { + c.calls++ + if c.calls >= 10 { + return context.Canceled + } + return nil +} +func TestLowerIndexCancellationDuringWalk(t *testing.T) { + root, lower := indexFixture(t) + indexFiles(t, lower) + before := descriptorCount(t) + ctx := &cancelDuringIndex{Context: context.Background()} + lease, err := buildLowerIndex(ctx, root, []string{"/layer"}, ImageIndexLimits{}) + require.ErrorIs(t, err, context.Canceled) + require.Nil(t, lease) + require.Equal(t, before, descriptorCount(t)) +} +func TestLowerIndexHostRootReplacement(t *testing.T) { + root, lower := indexFixture(t) + indexFiles(t, lower) + lease, err := buildLowerIndex(context.Background(), root, []string{"/layer"}, ImageIndexLimits{}) + require.NoError(t, err) + defer lease.Close() + moved := root + "-moved" + require.NoError(t, os.Rename(root, moved)) + defer os.RemoveAll(moved) + require.NoError(t, os.Mkdir(root, 0700)) + require.ErrorContains(t, lease.Check(), "host root identity changed") +} + +func TestLowerIndexRejectsUnsupportedFilesystem(t *testing.T) { + lease, err := buildLowerIndex(context.Background(), "/", []string{"/proc"}, ImageIndexLimits{}) + require.ErrorContains(t, err, "unsupported lower filesystem") + require.Nil(t, lease) +} diff --git a/pkg/sandboxpolicy/igbackend/kernelstats_test.go b/pkg/sandboxpolicy/igbackend/kernelstats_test.go new file mode 100644 index 0000000000..bf26d85b65 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/kernelstats_test.go @@ -0,0 +1,139 @@ +package igbackend + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullruntime" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" +) + +type counterTestInstance struct { + *fullTestInstance + counters fullruntime.KernelEventCounters + err error + reads int +} + +func (i *counterTestInstance) KernelEventCounters() (fullruntime.KernelEventCounters, error) { + i.reads++ + return i.counters, i.err +} +func (i *counterTestInstance) Stop(ctx context.Context) error { + if err := i.fullTestInstance.Stop(ctx); err != nil { + return err + } + i.counters.Final = true + return nil +} + +func TestKernelStatsAggregateRetainAndRemainReadOnly(t *testing.T) { + first := &counterTestInstance{fullTestInstance: &fullTestInstance{}, counters: fullruntime.KernelEventCounters{Submitted: 11, Lost: 3, ObservationErrors: 1}} + second := &counterTestInstance{fullTestInstance: &fullTestInstance{}, counters: fullruntime.KernelEventCounters{Submitted: 17, Lost: 5, ObservationErrors: 2}} + a, b := key(sandboxpolicy.Binding{ContainerID: "a"}), key(sandboxpolicy.Binding{ContainerID: "b"}) + backend := &FullBackend{records: map[identity]*fullRecord{a: {instance: first}, b: {instance: second, retiring: true}}, operationTimeout: time.Second} + stats, err := backend.KernelEventStats(context.Background()) + if err != nil || stats.Owned.Lost != 8 || stats.Owned.Submitted != 28 || stats.Owned.ObservationErrors != 3 || stats.OwnedInstances != 2 || stats.OwnedUnavailable != 0 { + t.Fatalf("aggregate %+v %v", stats, err) + } + if first.stops != 0 || second.stops != 0 || first.updates != 0 || second.resumes != 0 { + t.Fatal("query mutated enforcement") + } + if err := backend.stop(context.Background(), a, backend.records[a]); err != nil { + t.Fatal(err) + } + stats, err = backend.KernelEventStats(context.Background()) + if err != nil || stats.Owned.Lost != 5 || stats.Retired.Lost != 3 || stats.RetiredInstances != 1 || stats.OwnedInstances != 1 { + t.Fatalf("withdraw snapshot %+v %v", stats, err) + } + if err := backend.Close(context.Background()); err != nil { + t.Fatal(err) + } + stats, err = backend.KernelEventStats(context.Background()) + if err != nil || stats.Retired.Lost != 8 || stats.RetiredInstances != 2 || stats.OwnedInstances != 0 { + t.Fatalf("stopped snapshot %+v %v", stats, err) + } +} + +func TestKernelStatsUnavailableAndCancellation(t *testing.T) { + instance := &counterTestInstance{fullTestInstance: &fullTestInstance{}, counters: fullruntime.KernelEventCounters{Lost: 7}, err: errors.New("read failed")} + a, b := key(sandboxpolicy.Binding{ContainerID: "partial"}), key(sandboxpolicy.Binding{ContainerID: "unsupported"}) + backend := &FullBackend{records: map[identity]*fullRecord{a: {instance: instance, retiring: true}, b: {instance: &fullTestInstance{}}}, operationTimeout: time.Second} + stats, err := backend.KernelEventStats(context.Background()) + if err == nil || stats.OwnedUnavailable != 2 || stats.OwnedInstances != 2 || stats.Owned.Lost != 7 { + t.Fatalf("unavailable live stats %+v %v", stats, err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + reads := instance.reads + if _, err := backend.KernelEventStats(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation: %v", err) + } + if instance.reads != reads || instance.stops != 0 { + t.Fatal("canceled query read or stopped owner") + } + if err := backend.Close(context.Background()); err != nil { + t.Fatal(err) + } + stats, err = backend.KernelEventStats(context.Background()) + if err == nil || stats.RetiredUnavailable != 2 || stats.RetiredInstances != 2 || stats.Retired.Lost != 7 { + t.Fatalf("unavailable retained stats %+v %v", stats, err) + } +} + +func TestKernelStatsRejectNonfinalRetirement(t *testing.T) { + backend := &FullBackend{} + backend.retainKernelCounters(&counterTestInstance{fullTestInstance: &fullTestInstance{}, counters: fullruntime.KernelEventCounters{Lost: 9}}) + stats, err := backend.KernelEventStats(context.Background()) + if err == nil || stats.RetiredUnavailable != 1 || stats.Retired.Lost != 9 { + t.Fatalf("nonfinal incorrectly complete: %+v %v", stats, err) + } +} + +type counterTestOwner struct { + *testFullOwner + counters fullruntime.KernelEventCounters + err error +} + +func (o *counterTestOwner) KernelEventCounters() (fullruntime.KernelEventCounters, error) { + return o.counters, o.err +} +func (o *counterTestOwner) ReleaseAfterDetach() error { + if err := o.testFullOwner.ReleaseAfterDetach(); err != nil { + return err + } + o.counters.Final = true + return nil +} + +func TestNativeKernelStatsPartialStartupThroughJoinedStop(t *testing.T) { + var calls []string + owner := &counterTestOwner{testFullOwner: &testFullOwner{calls: &calls}, counters: fullruntime.KernelEventCounters{Lost: 3}} + instance := &nativeFullInstance{owner: owner, manager: &testFullManager{stop: func(context.Context, string) error { + calls = append(calls, "join") + owner.counters.Lost = 8 + return nil + }}} + // A partial start has started=false; its telemetry must still be visible. + got, err := instance.KernelEventCounters() + if err != nil || got.Lost != 3 || len(calls) != 0 { + t.Fatalf("partial startup stats: %+v %v calls=%v", got, err, calls) + } + if err := instance.Stop(context.Background()); err != nil { + t.Fatal(err) + } + got, err = instance.KernelEventCounters() + if err != nil || got.Lost != 8 || !got.Final { + t.Fatalf("joined stats: %+v %v", got, err) + } + if len(calls) != 3 || calls[0] != "drain" || calls[1] != "join" || calls[2] != "release" { + t.Fatalf("stop ordering: %v", calls) + } + unknown := &nativeFullInstance{owner: &testFullOwner{calls: &calls}} + if _, err := unknown.KernelEventCounters(); err == nil { + t.Fatal("missing stats interface reported zero loss") + } +} diff --git a/pkg/sandboxpolicy/igbackend/metadata_fence_test.go b/pkg/sandboxpolicy/igbackend/metadata_fence_test.go new file mode 100644 index 0000000000..b41b6f415b --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/metadata_fence_test.go @@ -0,0 +1,75 @@ +package igbackend + +import ( + "context" + "errors" + "testing" + + "github.com/kubescape/node-agent/pkg/sandboxpolicy" +) + +func TestFullBackendStaleMetadataWithdrawCannotRemoveNewOwner(t *testing.T) { + b, e, _, binding := fullBackendFixture(t) + binding.PodUID = "new-pod" + binding.Namespace = "ns" + binding.PodName = "pod" + binding.Kind = "regular" + ctx := context.Background() + if _, err := b.Replace(ctx, binding, verdictPlan()); err != nil { + t.Fatal(err) + } + for _, stale := range []sandboxpolicy.Binding{ + func() sandboxpolicy.Binding { x := binding; x.PodUID = "old-pod"; return x }(), + func() sandboxpolicy.Binding { x := binding; x.ContainerName = "old-container"; return x }(), + } { + r, err := b.Withdraw(ctx, stale) + if err != nil || !r.Committed || e.instance.stops != 0 || len(b.records) != 1 { + t.Fatal("stale metadata removed current owner", r, err) + } + } + if r, err := b.Withdraw(ctx, binding); err != nil || !r.Committed || len(b.records) != 0 { + t.Fatal(r, err) + } +} + +type metadataPartialLease struct { + fullTestLease + calls int +} + +func (l *metadataPartialLease) Close() error { + l.calls++ + if l.calls == 1 { + return errors.New("consumed pins with OS close error") + } + return nil +} + +type metadataPartialSource struct{ lease *metadataPartialLease } + +func (s metadataPartialSource) Prepare(context.Context, sandboxpolicy.Binding) (FullBindingLease, error) { + return s.lease, errors.New("preparation failed") +} +func TestFullBackendPartialSourceRetainsMetadataForCleanup(t *testing.T) { + _, e, _, binding := fullBackendFixture(t) + binding.PodUID = "pod-uid" + lease := &metadataPartialLease{} + b, err := NewFull(e, metadataPartialSource{lease}, nil, nil) + if err != nil { + t.Fatal(err) + } + if _, err = b.Replace(context.Background(), binding, verdictPlan()); err == nil { + t.Fatal("expected source failure") + } + if len(b.records) != 1 || lease.calls != 1 { + t.Fatal("missing partial cleanup ownership") + } + stale := binding + stale.PodUID = "old" + if _, err = b.Withdraw(context.Background(), stale); err != nil || lease.calls != 1 || len(b.records) != 1 { + t.Fatal("stale metadata consumed partial record", err) + } + if r, err := b.Withdraw(context.Background(), binding); err != nil || !r.Committed || len(b.records) != 0 || lease.calls != 2 { + t.Fatal("legitimate cleanup fenced out", r, err) + } +} diff --git a/pkg/sandboxpolicy/igbackend/native.go b/pkg/sandboxpolicy/igbackend/native.go new file mode 100644 index 0000000000..554452db37 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/native.go @@ -0,0 +1,122 @@ +package igbackend + +import ( + "context" + "errors" + "fmt" + "path/filepath" + + execruntime "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/runtime" + "github.com/inspektor-gadget/inspektor-gadget/pkg/datasource" + gadgetcontext "github.com/inspektor-gadget/inspektor-gadget/pkg/gadget-context" + "github.com/inspektor-gadget/inspektor-gadget/pkg/gadget-service/api" + apihelpers "github.com/inspektor-gadget/inspektor-gadget/pkg/gadget-service/api-helpers" + instancemanager "github.com/inspektor-gadget/inspektor-gadget/pkg/gadget-service/instance-manager" + "github.com/inspektor-gadget/inspektor-gadget/pkg/operators" + _ "github.com/inspektor-gadget/inspektor-gadget/pkg/operators/ebpf" + ocihandler "github.com/inspektor-gadget/inspektor-gadget/pkg/operators/oci-handler" + "github.com/inspektor-gadget/inspektor-gadget/pkg/operators/simple" + "github.com/inspektor-gadget/inspektor-gadget/pkg/runtime" + "github.com/inspektor-gadget/inspektor-gadget/pkg/utils/host" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" + "oras.land/oras-go/v2" +) + +// NewNative uses an already initialized runtime. Its caller retains ownership of +// that runtime and must call Backend.Close before runtime.Close. +func NewNative(rt runtime.Runtime, kube operators.DataOperator, store oras.ReadOnlyTarget, image string, receiver sandboxpolicy.VerdictReceiver, onError func(error)) (*Backend, error) { + if rt == nil || kube == nil || store == nil || image == "" { + return nil, fmt.Errorf("runtime, Kubernetes operator, OCI store and enforcement image are required") + } + manager, err := instancemanager.New(rt) + if err != nil { + return nil, err + } + return New(&nativeEngine{manager: manager, kube: kube, store: store, image: image, onError: onError}, LinuxValidator{ProcRoot: host.HostProcFs, CgroupRoot: filepath.Join(host.HostRoot, "sys/fs/cgroup")}, receiver, onError) +} + +type nativeEngine struct { + manager *instancemanager.Manager + kube operators.DataOperator + store oras.ReadOnlyTarget + image string + onError func(error) +} +type nativeInstance struct { + manager *instancemanager.Manager + id string + bindings *execruntime.Bindings + revision uint64 +} + +func (i *nativeInstance) Ready() error { + status := i.bindings.Status() + if !status.Ready || status.Error != nil || status.Bound != 1 || status.RestrictedBindings != 0 || status.Revision != i.revision { + return fmt.Errorf("enforcement instance not ready: %+v", status) + } + return nil +} +func (i *nativeInstance) Unbind(ctx context.Context, identity execruntime.ContainerIdentity) error { + if !i.bindings.Status().Ready { + return i.Stop(ctx) + } + return i.bindings.Unbind(identity) +} +func (i *nativeInstance) Stop(ctx context.Context) error { + err := i.manager.StopGadget(ctx, i.id) + if errors.Is(err, instancemanager.ErrNotFound) { + return nil + } + return err +} +func (e *nativeEngine) Start(ctx context.Context, id string, config execruntime.Config, emit func(RawVerdict)) (Instance, error) { + handler := ocihandler.New() + params := apihelpers.ToParamDescs(handler.GlobalParams()).ToParams() + if err := params.Set("allow-enforcement", "true"); err != nil { + return nil, err + } + // Keep the upstream verification defaults. NOTE: IG treats an explicit OCI + // target as pretrusted and does not verify its signatures here. The caller + // must supply an administrator-trusted archive (privileged executable code), + // never a policy-provided artifact. This flag alone authenticates no archive. + if err := handler.Init(params); err != nil { + return nil, err + } + instance := &nativeInstance{manager: e.manager, id: id, revision: config.Entries[0].Generation} + events := simple.New("sandbox-exec-verdict", simple.WithPriority(100), simple.OnInit(func(gc operators.GadgetContext) error { + ds := gc.GetDataSources()["verdict"] + if ds == nil { + return fmt.Errorf("missing enforcement verdict datasource") + } + for _, name := range []string{"generation", "cgroup", "deny", "mode", "path_result", "host_pid", "path"} { + if ds.GetField(name) == nil { + return fmt.Errorf("missing verdict field %q", name) + } + } + var err error + instance.bindings, err = execruntime.GetBindings(gc) + if err != nil { + return err + } + return ds.Subscribe(func(ds datasource.DataSource, data datasource.Data) error { + raw, err := readVerdict(ds, data) + if err != nil { + if e.onError != nil { + e.onError(err) + } + return nil + } + emit(raw) + return nil // Exporter failures never modify enforcement lifecycle. + }, 100) + })) + desired := &api.GadgetInstance{Id: id, GadgetConfig: &api.GadgetRunRequest{Version: api.VersionGadgetRunProtocol, ImageName: e.image}} + err := e.manager.StartGadget(ctx, desired, + gadgetcontext.WithOrasReadonlyTarget(e.store), + gadgetcontext.WithAdditionalDataOperators(handler, e.kube, execruntime.New(), events), + execruntime.WithConfig(config)) + if err != nil { + return nil, err + } + return instance, nil +} diff --git a/pkg/sandboxpolicy/igbackend/nativefull.go b/pkg/sandboxpolicy/igbackend/nativefull.go new file mode 100644 index 0000000000..b40cefabea --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/nativefull.go @@ -0,0 +1,283 @@ +package igbackend + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "sync" + + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullpolicy" + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullruntime" + "github.com/inspektor-gadget/inspektor-gadget/pkg/datasource" + gadgetcontext "github.com/inspektor-gadget/inspektor-gadget/pkg/gadget-context" + "github.com/inspektor-gadget/inspektor-gadget/pkg/gadget-service/api" + apihelpers "github.com/inspektor-gadget/inspektor-gadget/pkg/gadget-service/api-helpers" + instancemanager "github.com/inspektor-gadget/inspektor-gadget/pkg/gadget-service/instance-manager" + "github.com/inspektor-gadget/inspektor-gadget/pkg/operators" + ocihandler "github.com/inspektor-gadget/inspektor-gadget/pkg/operators/oci-handler" + "github.com/inspektor-gadget/inspektor-gadget/pkg/operators/simple" + "github.com/inspektor-gadget/inspektor-gadget/pkg/runtime" + "oras.land/oras-go/v2" +) + +// NewNativeFullEngine borrows the initialized runtime and trusted OCI archive. +// The caller must stop every instance before closing the runtime. The one shared +// budget must cover all engines that could target the same cgroup. No fallback to +// immutable/path-only enforcement occurs when the persistent image is unsupported. +func NewNativeFullEngine(rt runtime.Runtime, kube operators.DataOperator, store oras.ReadOnlyTarget, image, controlRoot string, budget *fullruntime.Budget, onError func(error)) (FullEngine, error) { + if rt == nil || kube == nil || store == nil || image == "" || budget == nil || !filepath.IsAbs(controlRoot) || filepath.Clean(controlRoot) != controlRoot { + return nil, fmt.Errorf("runtime, Kubernetes operator, trusted OCI image/store, canonical control root and shared budget are required") + } + manager, err := instancemanager.New(rt) + if err != nil { + return nil, err + } + return &nativeFullEngine{manager: manager, kube: kube, store: store, image: image, controlRoot: controlRoot, budget: budget, onError: onError, instances: make(map[string]*nativeFullInstance)}, nil +} + +type fullManager interface { + StartGadget(context.Context, *api.GadgetInstance, ...gadgetcontext.Option) error + StopGadget(context.Context, string) error + LookupInstance(string) *instancemanager.GadgetInstance +} + +type fullOwner interface { + AbortPending(context.Context) error + Activate(context.Context) (fullruntime.PublicationResult, error) + Update(context.Context, *fullpolicy.KernelProgram) (fullruntime.PublicationResult, error) + Resume(context.Context) (fullruntime.PublicationResult, error) + Generation() uint64 + PendingGeneration() uint64 + Epochs() map[string]uint64 + Check(context.Context) error + BeforeStop(context.Context) error + ReleaseAfterDetach() error +} + +type nativeFullEngine struct { + mu sync.Mutex + manager fullManager + kube operators.DataOperator + store oras.ReadOnlyTarget + image string + controlRoot string + budget *fullruntime.Budget + onError func(error) + instances map[string]*nativeFullInstance +} + +type nativeFullInstance struct { + mu sync.Mutex + owner fullOwner + manager fullManager + id string + started bool + retired bool + detached bool + released bool + onRelease func() +} + +var _ FullEngine = (*nativeFullEngine)(nil) +var _ FullInstance = (*nativeFullInstance)(nil) +var _ FullPendingAborter = (*nativeFullInstance)(nil) +var _ fullOwner = (*fullruntime.Owner)(nil) + +func (e *nativeFullEngine) Start(ctx context.Context, id string, config FullStartConfig, emit func(RawFullVerdict)) (FullInstance, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if id == "" { + return nil, fmt.Errorf("persistent owner requires an instance ID") + } + handler := ocihandler.New() + params := apihelpers.ToParamDescs(handler.GlobalParams()).ToParams() + if err := params.Set("allow-enforcement", "true"); err != nil { + return nil, err + } + // An explicit OCI target is pretrusted by IG, not signature-verified here. + // This image/archive MUST come from administrator configuration, never a CRD. + if err := handler.Init(params); err != nil { + return nil, err + } + e.mu.Lock() + if e.instances[id] != nil { + e.mu.Unlock() + return nil, fmt.Errorf("persistent instance ID %q is still owned", id) + } + owner, err := fullruntime.NewOwner(fullruntime.OwnerConfig{Program: config.Program, CgroupID: config.Binding.CgroupID, InitHostPID: config.Binding.PID, ControlRoot: e.controlRoot, Acquire: config.Acquire, ImageOrigins: config.ImageOrigins}, e.budget) + if err != nil { + e.mu.Unlock() + return nil, err + } + instance := &nativeFullInstance{owner: owner, manager: e.manager, id: id} + instance.onRelease = func() { + e.mu.Lock() + defer e.mu.Unlock() + if e.instances[id] == instance { + delete(e.instances, id) + } + } + e.instances[id] = instance + e.mu.Unlock() + events := simple.New("sandbox-exec-full-verdict", simple.WithPriority(100), simple.OnInit(func(gc operators.GadgetContext) error { + return subscribeFullVerdicts(gc.GetDataSources()["verdict"], emit, e.onError) + })) + desired := &api.GadgetInstance{Id: id, GadgetConfig: &api.GadgetRunRequest{Version: api.VersionGadgetRunProtocol, ImageName: e.image}} + err = e.manager.StartGadget(ctx, desired, + gadgetcontext.WithOrasReadonlyTarget(e.store), + gadgetcontext.WithAdditionalDataOperators(handler, e.kube, fullruntime.New(), events), + fullruntime.WithOwner(owner)) + // StartGadget joins failed startup cleanup. Even then Owner still owns pins + // and its reservation; the caller receives this handle and must call Stop. + instance.started = err == nil + return instance, err +} + +func subscribeFullVerdicts(ds datasource.DataSource, emit func(RawFullVerdict), onError func(error)) error { + if ds == nil || ds.ByteOrder() == nil { + return fmt.Errorf("missing full enforcement verdict datasource") + } + for name, kind := range map[string]api.Kind{"generation": api.Kind_Uint64, "cgroup": api.Kind_Uint64, "deny": api.Kind_Uint32, "mode": api.Kind_Uint32, "path_result": api.Kind_Int32, "host_pid": api.Kind_Uint32, "matched_rules": api.ArrayOf(api.Kind_Uint64), "error": api.Kind_Uint32} { + field := ds.GetField(name) + if field == nil || field.Type() != kind { + return fmt.Errorf("missing or incompatible full verdict field %q", name) + } + size := uint32(4) + if kind == api.Kind_Uint64 { + size = 8 + } + if name == "matched_rules" { + size = 32 + } + if field.Size() != 0 && field.Size() != size { + return fmt.Errorf("invalid full verdict field size for %q", name) + } + } + // Native BTF char arrays are CString; formatted datasource strings use + // String. Both are read by the same String accessor, not byte-array guesses. + path := ds.GetField("path") + if path == nil || (path.Type() != api.Kind_CString && path.Type() != api.Kind_String) { + return fmt.Errorf("missing or incompatible full verdict path") + } + return ds.Subscribe(func(ds datasource.DataSource, data datasource.Data) error { + raw, err := ReadFullVerdict(ds, data) + if err != nil { + if onError != nil { + onError(err) + } + return nil + } + if emit != nil { + emit(raw) + } + return nil // Telemetry decode/export never changes enforcement lifecycle. + }, 100) +} + +func (i *nativeFullInstance) usable() error { + if !i.started || i.retired || i.released { + return fmt.Errorf("persistent instance is not running") + } + return nil +} +func (i *nativeFullInstance) Activate(ctx context.Context) (fullruntime.PublicationResult, error) { + i.mu.Lock() + defer i.mu.Unlock() + if err := i.usable(); err != nil { + return fullruntime.PublicationResult{}, err + } + return i.owner.Activate(ctx) +} +func (i *nativeFullInstance) Update(ctx context.Context, program *fullpolicy.KernelProgram) (fullruntime.PublicationResult, error) { + i.mu.Lock() + defer i.mu.Unlock() + if err := i.usable(); err != nil { + return fullruntime.PublicationResult{}, err + } + return i.owner.Update(ctx, program) +} +func (i *nativeFullInstance) Resume(ctx context.Context) (fullruntime.PublicationResult, error) { + i.mu.Lock() + defer i.mu.Unlock() + if err := i.usable(); err != nil { + return fullruntime.PublicationResult{}, err + } + return i.owner.Resume(ctx) +} +func (i *nativeFullInstance) AbortPending(ctx context.Context) error { + i.mu.Lock() + defer i.mu.Unlock() + if err := i.usable(); err != nil { + return err + } + return i.owner.AbortPending(ctx) +} +func (i *nativeFullInstance) Check(ctx context.Context) error { + i.mu.Lock() + defer i.mu.Unlock() + if err := i.usable(); err != nil { + return err + } + return i.owner.Check(ctx) +} +func (i *nativeFullInstance) Generation() uint64 { return i.owner.Generation() } +func (i *nativeFullInstance) PendingGeneration() uint64 { return i.owner.PendingGeneration() } +func (i *nativeFullInstance) Epochs() map[string]uint64 { return i.owner.Epochs() } + +func (i *nativeFullInstance) Detached() bool { + i.mu.Lock() + defer i.mu.Unlock() + return i.detached +} + +// Stop must retire/drain BEFORE asking the manager to detach: IG continues +// teardown even when an operator's PreStop reports an error. Cancellation while +// joining retains the instance; only confirmed teardown permits pin release. +func (i *nativeFullInstance) Stop(ctx context.Context) error { + i.mu.Lock() + defer i.mu.Unlock() + if i.released { + return nil + } + if !i.retired { + if err := i.owner.BeforeStop(ctx); err != nil { + return err + } + i.retired = true + } + var stopErr error + if !i.detached { + stopErr = i.manager.StopGadget(ctx, i.id) + if errors.Is(stopErr, instancemanager.ErrNotFound) { + stopErr = nil + } + if stopErr != nil && i.manager.LookupInstance(i.id) != nil { + return stopErr // A canceled wait may leave cleanup running/registered. + } + // StopGadget can join and remove an instance yet return its terminal Run + // error. This engine exclusively owns its manager/IDs: absence after Stop + // proves joined teardown, not just a failed/canceled observation. Preserve + // that receipt and the error independently; pins can now be released. + i.detached = true + } + if err := i.owner.ReleaseAfterDetach(); err != nil { + return errors.Join(stopErr, err) + } + i.released = true + if i.onRelease != nil { + i.onRelease() + } + return stopErr +} + +func (i *nativeFullInstance) KernelEventCounters() (fullruntime.KernelEventCounters, error) { + i.mu.Lock() + defer i.mu.Unlock() + source, ok := i.owner.(FullKernelCounterSource) + if !ok { + return fullruntime.KernelEventCounters{}, fmt.Errorf("native owner kernel counters unavailable") + } + return source.KernelEventCounters() +} diff --git a/pkg/sandboxpolicy/igbackend/nativefull_test.go b/pkg/sandboxpolicy/igbackend/nativefull_test.go new file mode 100644 index 0000000000..07d8ad4049 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/nativefull_test.go @@ -0,0 +1,332 @@ +package igbackend + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullpolicy" + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullruntime" + "github.com/inspektor-gadget/inspektor-gadget/pkg/datasource" + gadgetcontext "github.com/inspektor-gadget/inspektor-gadget/pkg/gadget-context" + "github.com/inspektor-gadget/inspektor-gadget/pkg/gadget-service/api" + instancemanager "github.com/inspektor-gadget/inspektor-gadget/pkg/gadget-service/instance-manager" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" +) + +type testFullManager struct { + start func(context.Context, *api.GadgetInstance, ...gadgetcontext.Option) error + stop func(context.Context, string) error + lookup func(string) *instancemanager.GadgetInstance +} + +func (m *testFullManager) StartGadget(ctx context.Context, desired *api.GadgetInstance, opts ...gadgetcontext.Option) error { + return m.start(ctx, desired, opts...) +} +func (m *testFullManager) StopGadget(ctx context.Context, id string) error { return m.stop(ctx, id) } +func (m *testFullManager) LookupInstance(id string) *instancemanager.GadgetInstance { + if m.lookup != nil { + return m.lookup(id) + } + return &instancemanager.GadgetInstance{} // Conservative default: still registered. +} + +type testFullOwner struct { + calls *[]string + beforeErr, releaseErr error + result fullruntime.PublicationResult + publicationErr error +} + +func (o *testFullOwner) Activate(context.Context) (fullruntime.PublicationResult, error) { + *o.calls = append(*o.calls, "activate") + return o.result, o.publicationErr +} +func (o *testFullOwner) Update(context.Context, *fullpolicy.KernelProgram) (fullruntime.PublicationResult, error) { + *o.calls = append(*o.calls, "update") + return o.result, o.publicationErr +} +func (o *testFullOwner) AbortPending(context.Context) error { + *o.calls = append(*o.calls, "abort") + return o.publicationErr +} +func (o *testFullOwner) Resume(context.Context) (fullruntime.PublicationResult, error) { + *o.calls = append(*o.calls, "resume") + return o.result, o.publicationErr +} +func (*testFullOwner) Generation() uint64 { return 3 } +func (*testFullOwner) PendingGeneration() uint64 { return 3 } +func (*testFullOwner) Epochs() map[string]uint64 { return map[string]uint64{"uid": 1} } +func (o *testFullOwner) Check(context.Context) error { + *o.calls = append(*o.calls, "check") + return nil +} +func (o *testFullOwner) BeforeStop(context.Context) error { + *o.calls = append(*o.calls, "drain") + return o.beforeErr +} +func (o *testFullOwner) ReleaseAfterDetach() error { + *o.calls = append(*o.calls, "release") + return o.releaseErr +} + +func TestNativeFullStopRetryOrdering(t *testing.T) { + var calls []string + owner := &testFullOwner{calls: &calls, beforeErr: context.DeadlineExceeded} + managerErr := error(context.Canceled) + manager := &testFullManager{stop: func(context.Context, string) error { calls = append(calls, "stop"); return managerErr }} + i := &nativeFullInstance{owner: owner, manager: manager, id: "exact", started: true, onRelease: func() { calls = append(calls, "unreserve") }} + ctx := context.Background() + if err := i.Stop(ctx); !errors.Is(err, context.DeadlineExceeded) || !reflect.DeepEqual(calls, []string{"drain"}) { + t.Fatalf("failed drain detached resources: %v %v", calls, err) + } + if err := i.Check(ctx); err != nil { + t.Fatal("drain timeout must retain usable owner", err) + } + owner.beforeErr = nil + calls = nil + if err := i.Stop(ctx); !errors.Is(err, context.Canceled) || !reflect.DeepEqual(calls, []string{"drain", "stop"}) { + t.Fatalf("canceled join released pins: %v %v", calls, err) + } + if i.Detached() { + t.Fatal("canceled join falsely confirmed detached") + } + if err := i.Check(ctx); err == nil { + t.Fatal("retired owner usable") + } + if _, err := i.Activate(ctx); err == nil { + t.Fatal("retired owner activated") + } + if _, err := i.Update(ctx, nil); err == nil { + t.Fatal("retired owner updated") + } + if _, err := i.Resume(ctx); err == nil { + t.Fatal("retired owner resumed") + } + managerErr = instancemanager.ErrNotFound // previous join finished and manager removed its entry + owner.releaseErr = errors.New("borrowed FD still open") + calls = nil + if err := i.Stop(ctx); err == nil || !reflect.DeepEqual(calls, []string{"stop", "release"}) { + t.Fatalf("failed release discarded ownership: %v %v", calls, err) + } + if !i.Detached() { + t.Fatal("joined teardown lost detached receipt") + } + owner.releaseErr = nil + calls = nil + if err := i.Stop(ctx); err != nil || !reflect.DeepEqual(calls, []string{"release", "unreserve"}) { + t.Fatalf("release retry: %v %v", calls, err) + } + calls = nil + if err := i.Stop(ctx); err != nil || len(calls) != 0 { + t.Fatalf("stop not idempotent: %v %v", calls, err) + } +} + +func TestNativeFullPublicationResultPreserved(t *testing.T) { + var calls []string + failure := errors.New("postcommit finalization failed") + owner := &testFullOwner{calls: &calls, result: fullruntime.PublicationResult{Committed: true, Generation: 3}, publicationErr: failure} + i := &nativeFullInstance{owner: owner, started: true} + ctx := context.Background() + for _, invoke := range []func() (fullruntime.PublicationResult, error){func() (fullruntime.PublicationResult, error) { return i.Activate(ctx) }, func() (fullruntime.PublicationResult, error) { return i.Update(ctx, nil) }, func() (fullruntime.PublicationResult, error) { return i.Resume(ctx) }} { + result, err := invoke() + if !errors.Is(err, failure) || result != owner.result { + t.Fatalf("lost partial commit: %+v %v", result, err) + } + } + if !reflect.DeepEqual(calls, []string{"activate", "update", "resume"}) || i.Generation() != 3 || i.PendingGeneration() != 3 || i.Epochs()["uid"] != 1 { + t.Fatalf("wrong owner delegation: %v", calls) + } + i.started = false + if _, err := i.Activate(ctx); err == nil { + t.Fatal("failed startup activated") + } +} + +func TestNativeFullFailedStartRetainsOwnerAndID(t *testing.T) { + budget, err := fullruntime.NewBudget(fullruntime.BudgetLimits{Owners: 1, MapPayloadBytes: fullruntime.OwnerMapPayloadLimit, ProgramInstructions: fullruntime.OwnerInstructionLimit, HistoryEntries: fullruntime.OwnerHistoryLimit}) + if err != nil { + t.Fatal(err) + } + startupErr := errors.New("late startup failure") + starts, stops, acquires := 0, 0, 0 + manager := &testFullManager{start: func(ctx context.Context, desired *api.GadgetInstance, options ...gadgetcontext.Option) error { + starts++ + if desired.Id != "binding" || desired.GadgetConfig.ImageName != "trusted-owner" || len(options) != 3 { + t.Fatalf("bad desired instance: %+v", desired) + } + return startupErr + }, stop: func(context.Context, string) error { stops++; return instancemanager.ErrNotFound }} + engine := &nativeFullEngine{manager: manager, image: "trusted-owner", controlRoot: "/sys/fs/cgroup", budget: budget, instances: make(map[string]*nativeFullInstance)} + binding := sandboxpolicy.Binding{ContainerName: "app", PID: 42, CgroupID: 91} + program, err := CompileFull(binding, verdictPlan(), 7, nil) + if err != nil { + t.Fatal(err) + } + config := FullStartConfig{Binding: binding, Program: program, Acquire: func(context.Context) (fullruntime.BindingLease, error) { + acquires++ + return nil, errors.New("must not acquire outside operator start") + }} + instance, err := engine.Start(context.Background(), "binding", config, nil) + if instance == nil || !errors.Is(err, startupErr) || budget.Usage().Owners != 1 || acquires != 0 { + t.Fatalf("lost failed candidate: %v %v %+v", instance, err, budget.Usage()) + } + if _, err = instance.Activate(context.Background()); err == nil { + t.Fatal("failed manager start activated") + } + if second, err := engine.Start(context.Background(), "binding", config, nil); err == nil || second != nil || starts != 1 { + t.Fatal("duplicate ID disturbed existing owner") + } + if err = instance.Stop(context.Background()); err != nil || budget.Usage().Owners != 0 || stops != 1 || len(engine.instances) != 0 { + t.Fatalf("cleanup failed: %v %+v", err, budget.Usage()) + } + // Reusing a released ID gets a fresh owner, not the previous stopped handle. + second, err := engine.Start(context.Background(), "binding", config, nil) + if second == nil || second == instance || !errors.Is(err, startupErr) || starts != 2 { + t.Fatal("released ID not reusable", err) + } + if err = second.Stop(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestNativeFullConstructorRequiresTrustedConfiguration(t *testing.T) { + if _, err := NewNativeFullEngine(nil, nil, nil, "", "", nil, nil); err == nil { + t.Fatal("accepted incomplete native runtime configuration") + } +} + +func TestNativeFullVerdictSubscription(t *testing.T) { + if err := subscribeFullVerdicts(nil, nil, nil); err == nil { + t.Fatal("accepted missing datasource") + } + missing, _ := datasource.New(datasource.TypeSingle, "verdict") + if err := subscribeFullVerdicts(missing, nil, nil); err == nil { + t.Fatal("accepted missing fields") + } + ds, err := datasource.New(datasource.TypeSingle, "verdict") + if err != nil { + t.Fatal(err) + } + for name, kind := range map[string]api.Kind{"generation": api.Kind_Uint64, "cgroup": api.Kind_Uint64, "deny": api.Kind_Uint32, "mode": api.Kind_Uint32, "path_result": api.Kind_Int32, "host_pid": api.Kind_Uint32, "path": api.Kind_CString, "matched_rules": api.ArrayOf(api.Kind_Uint64), "error": api.Kind_Uint32} { + if _, err = ds.AddField(name, kind); err != nil { + t.Fatal(err) + } + } + var got []RawFullVerdict + errorsSeen := 0 + if err = subscribeFullVerdicts(ds, func(r RawFullVerdict) { got = append(got, r) }, func(error) { errorsSeen++ }); err != nil { + t.Fatal(err) + } + for _, size := range []int{32, 31} { + packet, err := ds.NewPacketSingle() + if err != nil { + t.Fatal(err) + } + ds.GetField("generation").PutUint64(packet, 7) + ds.GetField("cgroup").PutUint64(packet, 91) + ds.GetField("mode").PutUint32(packet, 2) + ds.GetField("deny").PutUint32(packet, 0) + ds.GetField("error").PutUint32(packet, 0) + ds.GetField("host_pid").PutUint32(packet, 42) + ds.GetField("path_result").PutInt32(packet, -1) + ds.GetField("path").PutString(packet, "") + bits := make([]byte, size) + ds.ByteOrder().PutUint64(bits, 1) + if err = ds.GetField("matched_rules").Set(packet, bits); err != nil { + t.Fatal(err) + } + if err = ds.EmitAndRelease(packet); err != nil { + t.Fatal("telemetry error escaped into runtime", err) + } + } + if len(got) != 1 || got[0].Generation != 7 || got[0].MatchedRules[0] != 1 || errorsSeen != 1 { + t.Fatalf("wrong decoded/drop callbacks: %+v errors=%d", got, errorsSeen) + } +} + +func TestNativeFullRejectsWrongVerdictSchemaBeforeActivation(t *testing.T) { + for _, bad := range []string{"generation", "matched_rules", "path", "error"} { + ds, err := datasource.New(datasource.TypeSingle, "verdict") + if err != nil { + t.Fatal(err) + } + for name, kind := range map[string]api.Kind{"generation": api.Kind_Uint64, "cgroup": api.Kind_Uint64, "deny": api.Kind_Uint32, "mode": api.Kind_Uint32, "path_result": api.Kind_Int32, "host_pid": api.Kind_Uint32, "path": api.Kind_CString, "matched_rules": api.ArrayOf(api.Kind_Uint64), "error": api.Kind_Uint32} { + if name == bad { + kind = api.Kind_Bytes + } + if _, err = ds.AddField(name, kind); err != nil { + t.Fatal(err) + } + } + if err = subscribeFullVerdicts(ds, nil, nil); err == nil { + t.Fatalf("accepted wrong type for %s", bad) + } + } +} + +func TestNativeFullTerminalStopErrorPreservesDetachedReceipt(t *testing.T) { + for _, releaseFails := range []bool{false, true} { + t.Run(map[bool]string{false: "released", true: "release-retry"}[releaseFails], func(t *testing.T) { + var calls []string + terminal := errors.New("terminal gadget Run error") + releaseErr := errors.New("pinned resource release error") + owner := &testFullOwner{calls: &calls} + if releaseFails { + owner.releaseErr = releaseErr + } + manager := &testFullManager{stop: func(context.Context, string) error { calls = append(calls, "stop"); return terminal }, lookup: func(id string) *instancemanager.GadgetInstance { + if id != "exact" { + t.Fatalf("looked up wrong ID %q", id) + } + calls = append(calls, "lookup-absent") + return nil + }} + i := &nativeFullInstance{owner: owner, manager: manager, id: "exact", started: true, onRelease: func() { calls = append(calls, "unreserve") }} + err := i.Stop(context.Background()) + if !errors.Is(err, terminal) || !i.Detached() { + t.Fatalf("lost terminal error or joined receipt: detached=%v err=%v", i.Detached(), err) + } + want := []string{"drain", "stop", "lookup-absent", "release"} + if !releaseFails { + want = append(want, "unreserve") + } + if !reflect.DeepEqual(calls, want) || errors.Is(err, releaseErr) != releaseFails { + t.Fatalf("wrong release behavior: %v err=%v", calls, err) + } + calls = nil + owner.releaseErr = nil + if err = i.Stop(context.Background()); err != nil { + t.Fatal(err) + } + want = nil + if releaseFails { + want = []string{"release", "unreserve"} + } + if !reflect.DeepEqual(calls, want) { + t.Fatalf("retry re-detached joined instance: %v", calls) + } + }) + } +} + +func TestNativeFullAbortPendingDelegatesOnlyWhileUsable(t *testing.T) { + var calls []string + failure := errors.New("abort failed") + owner := &testFullOwner{calls: &calls, publicationErr: failure} + i := &nativeFullInstance{owner: owner, started: true} + if err := i.AbortPending(t.Context()); !errors.Is(err, failure) || !reflect.DeepEqual(calls, []string{"abort"}) { + t.Fatalf("abort delegation: %v %v", calls, err) + } + for _, state := range []string{"not-started", "retired", "released"} { + t.Run(state, func(t *testing.T) { + i.started, i.retired, i.released = state != "not-started", state == "retired", state == "released" + calls = nil + if err := i.AbortPending(t.Context()); err == nil || len(calls) != 0 { + t.Fatalf("unusable owner abort: %v %v", calls, err) + } + }) + } +} diff --git a/pkg/sandboxpolicy/igbackend/testdata/fullbackend-helper.c b/pkg/sandboxpolicy/igbackend/testdata/fullbackend-helper.c new file mode 100644 index 0000000000..1621e1a9a6 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/testdata/fullbackend-helper.c @@ -0,0 +1,76 @@ +// Disposable integration fixture, not production agent code. +// Build statically and install this binary as /probe/init, /probe/role and +// /probe/target-0 .. /probe/target-12 in the immutable image layer. +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int device_probe(const char *name) { + int fd = open(name, O_RDONLY | O_NONBLOCK | O_CLOEXEC); + if (fd >= 0) { close(fd); return 0; } + return errno == EPERM ? 77 : 78; +} + +static int execute(const char *path, int chain) { + int input[2]; + if (pipe2(input, O_CLOEXEC) != 0) return 90; + pid_t child = fork(); + if (child < 0) { close(input[0]); close(input[1]); return 91; } + if (child == 0) { + close(input[1]); + if (input[0] == STDIN_FILENO) { + if (fcntl(input[0], F_SETFD, 0) < 0) _exit(92); + } else { + if (dup2(input[0], STDIN_FILENO) < 0) _exit(92); + close(input[0]); + } + char *args[] = {(char *)path, chain ? "--chain" : "--target", "--token=ok", NULL}; + execv(path, args); + _exit(errno == EPERM ? 77 : 78); + } + close(input[0]); + close(input[1]); + int status; + while (waitpid(child, &status, 0) < 0) if (errno != EINTR) return 93; + return WIFEXITED(status) ? WEXITSTATUS(status) : 94; +} + +int main(int argc, char **argv) { + if (argc > 1 && strcmp(argv[1], "--target") == 0) return 0; + if (argc > 1 && strcmp(argv[1], "--chain") == 0) + return execute("/probe/target-12", 0); + if (argc != 2 || strcmp(argv[1], "--serve") != 0) return 95; + if (mkdir("/fixture-control", 0700) != 0 && errno != EEXIST) return 96; + if (mkfifo("/fixture-control/request", 0600) != 0 && errno != EEXIST) return 97; + if (mkfifo("/fixture-control/response", 0600) != 0 && errno != EEXIST) return 98; + int request = open("/fixture-control/request", O_RDWR | O_CLOEXEC); + int response = open("/fixture-control/response", O_RDWR | O_CLOEXEC); + if (request < 0 || response < 0) return 99; + FILE *stream = fdopen(request, "r"); + if (!stream) return 100; + char line[128]; + while (fgets(line, sizeof(line), stream)) { + int result = 101, target = -1; + char extra; + if (strcmp(line, "DEVICE char\n") == 0) result = device_probe("/dev/shm/sandbox-policy-device-char"); + else if (strcmp(line, "DEVICE block\n") == 0) result = device_probe("/dev/shm/sandbox-policy-device-block"); + else if (strcmp(line, "CHAIN\n") == 0) result = execute("/probe/role", 1); + else if (sscanf(line, "RUN %d %c", &target, &extra) == 1 && target >= 0 && target <= 12) { + char path[64]; + snprintf(path, sizeof(path), "/probe/target-%d", target); + result = execute(path, 0); + } else if (strcmp(line, "PING\n") == 0) result = 0; + if (dprintf(response, "RESULT %d\n", result) < 0) break; + } + fclose(stream); + close(response); + return 0; +} diff --git a/pkg/sandboxpolicy/igbackend/verdict.go b/pkg/sandboxpolicy/igbackend/verdict.go new file mode 100644 index 0000000000..5098641373 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/verdict.go @@ -0,0 +1,91 @@ +package igbackend + +import ( + "errors" + "fmt" + "path" + "strings" + "time" + + "github.com/inspektor-gadget/inspektor-gadget/pkg/datasource" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" + api "github.com/kubescape/storage/pkg/apis/sandbox/v1alpha1" +) + +type RawVerdict struct { + Generation, Cgroup uint64 + Deny, Mode, PID uint32 + PathResult int32 + Path string +} + +func readVerdict(ds datasource.DataSource, data datasource.Data) (RawVerdict, error) { + var r RawVerdict + var errs [7]error + r.Generation, errs[0] = ds.GetField("generation").Uint64(data) + r.Cgroup, errs[1] = ds.GetField("cgroup").Uint64(data) + r.Deny, errs[2] = ds.GetField("deny").Uint32(data) + r.Mode, errs[3] = ds.GetField("mode").Uint32(data) + r.PID, errs[4] = ds.GetField("host_pid").Uint32(data) + r.PathResult, errs[5] = ds.GetField("path_result").Int32(data) + r.Path, errs[6] = ds.GetField("path").String(data) + return r, errors.Join(errs[:]...) +} + +// DecodeVerdict leaves argv, UID, image origin and actor paths absent when the +// legacy event cannot supply them. Time is receipt time, not kernel time. +func DecodeVerdict(binding sandboxpolicy.Binding, plan sandboxpolicy.Plan, generation uint64, r RawVerdict) (sandboxpolicy.Verdict, error) { + var out sandboxpolicy.Verdict + if len(plan.Policies) != 1 || r.Generation != generation || r.Cgroup != binding.CgroupID || r.Deny > 1 || r.Mode < 1 || r.Mode > 2 { + return out, fmt.Errorf("verdict identity or values do not match active instance") + } + p := plan.Policies[0] + out = sandboxpolicy.Verdict{Time: time.Now(), Policy: p.Identity, Revision: plan.Revision, ContainerID: binding.ContainerID, PodUID: binding.PodUID, BindingPID: binding.PID, MountNamespace: binding.MountNamespace, CgroupID: r.Cgroup, PID: r.PID, TargetPath: r.Path, Mode: api.RuleAudit, Verdict: api.ActionAllow} + if r.Mode == 2 { + out.Mode = api.RuleEnforce + } + if r.Deny == 1 { + out.Verdict = api.ActionDeny + out.WouldDeny = r.Mode == 1 + } + if r.PathResult <= 0 { + out.TargetPath = "" + out.Reason = "path_resolution_error" + return out, nil + } + if r.PathResult > 256 || int(r.PathResult) != len(r.Path)+1 || !path.IsAbs(r.Path) || path.Clean(r.Path) != r.Path || strings.ContainsRune(r.Path, 0) { + return sandboxpolicy.Verdict{}, fmt.Errorf("malformed resolved verdict path") + } + out.Reason = "section_default" + var decisive []api.ExecRule + for _, rule := range p.Exec.Rules { + match := false + for _, pattern := range rule.Target.Path { + if pattern == r.Path || (strings.HasSuffix(pattern, "/**") && strings.HasPrefix(r.Path, strings.TrimSuffix(pattern, "**"))) { + match = true + break + } + } + if !match || rule.Action != out.Verdict { + continue + } + if out.Verdict == api.ActionDeny { + mode := rule.Mode + if mode == "" { + mode = api.RuleMode(p.Mode) + } + if mode != out.Mode { + continue + } + } + decisive = append(decisive, rule) + } + if len(decisive) > 0 { + out.Reason = "matching_rule" + } + if len(decisive) == 1 { + out.Rule = decisive[0].Name + out.Origin = decisive[0].Origin + } + return out, nil +} diff --git a/pkg/sandboxpolicy/integration/bindings.go b/pkg/sandboxpolicy/integration/bindings.go new file mode 100644 index 0000000000..4b726a1e9e --- /dev/null +++ b/pkg/sandboxpolicy/integration/bindings.go @@ -0,0 +1,209 @@ +package integration + +import ( + "context" + "fmt" + "path" + "sort" + "strings" + + containercollection "github.com/inspektor-gadget/inspektor-gadget/pkg/container-collection" + gadgettypes "github.com/inspektor-gadget/inspektor-gadget/pkg/types" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/client-go/kubernetes" +) + +// SnapshotBindings joins runtime identities to authoritative Kubernetes metadata. +// The caller must subscribe before snapshotting and reconcile subsequent churn; +// ContainerRange is not an atomic barrier across independent runtime/API watches. +// The backend must still verify live PID/cgroup ownership immediately before bind. +func SnapshotBindings(ctx context.Context, client kubernetes.Interface, collection *containercollection.ContainerCollection, nodeName string) ([]sandboxpolicy.Binding, error) { + if collection == nil { + return nil, fmt.Errorf("container collection is unavailable") + } + var containers []*containercollection.Container + collection.ContainerRange(func(container *containercollection.Container) { containers = append(containers, container) }) + return SnapshotBindingsFromContainers(ctx, client, containers, nodeName) +} + +// SnapshotBindingsFromContainers accepts the current (not cached/deleted) runtime +// collection. Any incomplete or contradictory live Kubernetes identity rejects +// the entire snapshot, so the caller can retain last-good rather than interpret +// discovery lag as authorization to withdraw enforcement. +func SnapshotBindingsFromContainers(ctx context.Context, client kubernetes.Interface, containers []*containercollection.Container, nodeName string) ([]sandboxpolicy.Binding, error) { + return snapshotBindings(ctx, client, containers, nodeName, nil) +} + +// SnapshotSelectedBindings requires runtime identities only for authoritatively +// selected Kubernetes workloads. It does not inherit observer namespace/agent +// exemptions: an explicitly selected but observer-ignored container still fails +// if discovery cannot provide its exact identity. +func SnapshotSelectedBindings(ctx context.Context, client kubernetes.Interface, collection *containercollection.ContainerCollection, nodeName string, selector func(sandboxpolicy.Binding) bool) ([]sandboxpolicy.Binding, error) { + if collection == nil { + return nil, fmt.Errorf("container collection is unavailable") + } + var containers []*containercollection.Container + collection.ContainerRange(func(container *containercollection.Container) { containers = append(containers, container) }) + return SnapshotSelectedBindingsFromContainers(ctx, client, containers, nodeName, selector) +} + +// SnapshotSelectedBindingsFromContainers is the selected-only snapshot variant +// for explicit runtime collections. Unmatched entries authorize no binding and +// may be ignored. Missing or contradictory selected identities reject the whole +// snapshot, never silently reducing protection. +func SnapshotSelectedBindingsFromContainers(ctx context.Context, client kubernetes.Interface, containers []*containercollection.Container, nodeName string, selector func(sandboxpolicy.Binding) bool) ([]sandboxpolicy.Binding, error) { + if selector == nil { + return nil, fmt.Errorf("selected snapshot requires a policy selector") + } + return snapshotBindings(ctx, client, containers, nodeName, selector) +} + +func snapshotBindings(ctx context.Context, client kubernetes.Interface, containers []*containercollection.Container, nodeName string, selector func(sandboxpolicy.Binding) bool) ([]sandboxpolicy.Binding, error) { + + if client == nil || nodeName == "" { + return nil, fmt.Errorf("Kubernetes client and node name are required") + } + pods, err := client.CoreV1().Pods("").List(ctx, metav1.ListOptions{FieldSelector: fields.OneTermEqualSelector("spec.nodeName", nodeName).String()}) + if err != nil { + return nil, fmt.Errorf("list node pods: %w", err) + } + namespaces, err := client.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("list namespaces: %w", err) + } + namespaceLabels := make(map[string]map[string]string, len(namespaces.Items)) + for _, ns := range namespaces.Items { + namespaceLabels[ns.Name] = copyLabels(ns.Labels) + } + expected := make(map[string]sandboxpolicy.Binding) + for _, pod := range pods.Items { + // Also filter locally: fake clients and unusual proxies may not honor fields. + if pod.Spec.NodeName != nodeName { + continue + } + for _, group := range []struct { + kind string + statuses []corev1.ContainerStatus + }{{"regular", pod.Status.ContainerStatuses}, {"init", pod.Status.InitContainerStatuses}, {"ephemeral", pod.Status.EphemeralContainerStatuses}} { + for _, status := range group.statuses { + if status.State.Running == nil { + continue + } + + // Select from API metadata before requiring runtime IDs. Namespace labels + // must be known even for selection; absence is never treated as empty. + labels, ok := namespaceLabels[pod.Namespace] + if !ok { + return nil, fmt.Errorf("namespace %s missing from authoritative snapshot", pod.Namespace) + } + metadata := sandboxpolicy.Binding{PodUID: pod.UID, Namespace: pod.Namespace, PodName: pod.Name, ContainerName: status.Name, Kind: group.kind, PodLabels: copyLabels(pod.Labels), NamespaceLabels: copyLabels(labels)} + if selector != nil && !selector(metadata) { + continue + } + if pod.UID == "" || pod.Name == "" || pod.Namespace == "" || status.Name == "" || !containerDeclared(pod, group.kind, status.Name) { + return nil, fmt.Errorf("running container in %s/%s has incomplete pod/spec identity", pod.Namespace, pod.Name) + } + runtime, id, ok := strings.Cut(status.ContainerID, "://") + if !ok || gadgettypes.String2RuntimeName(runtime) == gadgettypes.RuntimeNameUnknown || id == "" || strings.ContainsAny(id, "/ \t\r\n") { + return nil, fmt.Errorf("running container %s/%s/%s has invalid full runtime ID", pod.Namespace, pod.Name, status.Name) + } + + key := runtime + "://" + id + if _, duplicate := expected[key]; duplicate { + return nil, fmt.Errorf("duplicate running runtime identity %s", key) + } + metadata.Runtime = runtime + metadata.ContainerID = id + expected[key] = metadata + } + } + } + result := make([]sandboxpolicy.Binding, 0, len(expected)) + seen := make(map[string]bool) + cgroups := make(map[uint64]string) + for _, container := range containers { + if container == nil { + if selector != nil { + continue + } + return nil, fmt.Errorf("nil live container in runtime snapshot") + } + key := string(container.Runtime.RuntimeName) + "://" + container.Runtime.ContainerID + binding, matched := expected[key] + if !matched && selector != nil { + continue + } + hasK8s := container.K8s.Namespace != "" || container.K8s.PodName != "" || container.K8s.PodUID != "" || container.K8s.ContainerName != "" + if !matched && !hasK8s { + continue + } // host/ECS/non-Kubernetes discovery is out of scope + if !matched { + return nil, fmt.Errorf("live Kubernetes container %s is not correlated with a running node pod", key) + } + if seen[key] { + return nil, fmt.Errorf("duplicate runtime collection identity %s", key) + } + seen[key] = true + if container.K8s.Namespace != binding.Namespace || container.K8s.PodName != binding.PodName || container.K8s.PodUID != string(binding.PodUID) || container.K8s.ContainerName != binding.ContainerName { + return nil, fmt.Errorf("runtime/Kubernetes metadata mismatch for %s", key) + } + if container.CgroupID == 0 || container.Runtime.ContainerPID == 0 || container.Mntns == 0 || container.CgroupPath == "" || !path.IsAbs(container.CgroupPath) || path.Clean(container.CgroupPath) != container.CgroupPath || container.CgroupPath == "/" { + return nil, fmt.Errorf("live Kubernetes container %s lacks a strict cgroup/PID/mount identity", key) + } + if previous, duplicate := cgroups[container.CgroupID]; duplicate { + return nil, fmt.Errorf("cgroup ID shared by %s and %s", previous, key) + } + cgroups[container.CgroupID] = key + binding.CgroupID = container.CgroupID + binding.CgroupPath = container.CgroupPath + binding.PID = container.Runtime.ContainerPID + binding.MountNamespace = container.Mntns + result = append(result, binding) + } + for key := range expected { + if !seen[key] { + return nil, fmt.Errorf("running Kubernetes container %s missing from runtime discovery", key) + } + } + sort.Slice(result, func(i, j int) bool { + if result[i].Runtime != result[j].Runtime { + return result[i].Runtime < result[j].Runtime + } + return result[i].ContainerID < result[j].ContainerID + }) + return result, nil +} + +func containerDeclared(pod corev1.Pod, kind, name string) bool { + switch kind { + case "regular": + for _, c := range pod.Spec.Containers { + if c.Name == name { + return true + } + } + case "init": + for _, c := range pod.Spec.InitContainers { + if c.Name == name { + return true + } + } + case "ephemeral": + for _, c := range pod.Spec.EphemeralContainers { + if c.Name == name { + return true + } + } + } + return false +} +func copyLabels(in map[string]string) map[string]string { + out := make(map[string]string, len(in)) + for key, value := range in { + out[key] = value + } + return out +} diff --git a/pkg/sandboxpolicy/integration/bindings_test.go b/pkg/sandboxpolicy/integration/bindings_test.go new file mode 100644 index 0000000000..ae37aba411 --- /dev/null +++ b/pkg/sandboxpolicy/integration/bindings_test.go @@ -0,0 +1,246 @@ +package integration + +import ( + "context" + "errors" + "strings" + "testing" + + containercollection "github.com/inspektor-gadget/inspektor-gadget/pkg/container-collection" + gadgettypes "github.com/inspektor-gadget/inspektor-gadget/pkg/types" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" +) + +func bindingFixtures() (*corev1.Pod, *corev1.Namespace, []*containercollection.Container) { + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod", Namespace: "ns", UID: "pod-uid", Labels: map[string]string{"app": "current"}}, Spec: corev1.PodSpec{NodeName: "node", Containers: []corev1.Container{{Name: "app"}}, InitContainers: []corev1.Container{{Name: "init"}}, EphemeralContainers: []corev1.EphemeralContainer{{EphemeralContainerCommon: corev1.EphemeralContainerCommon{Name: "debug"}}}}} + namespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns", Labels: map[string]string{"team": "red"}}} + var containers []*containercollection.Container + for i, name := range []string{"app", "init", "debug"} { + id := strings.Repeat(string(rune('a'+i)), 64) + status := corev1.ContainerStatus{Name: name, ContainerID: "containerd://" + id, State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}}} + switch i { + case 0: + pod.Status.ContainerStatuses = []corev1.ContainerStatus{status} + case 1: + pod.Status.InitContainerStatuses = []corev1.ContainerStatus{status} + case 2: + pod.Status.EphemeralContainerStatuses = []corev1.ContainerStatus{status} + } + c := &containercollection.Container{CgroupID: uint64(100 + i), CgroupPath: "/kubepods/" + id, Mntns: uint64(1000 + i)} + c.Runtime.RuntimeName = gadgettypes.RuntimeNameContainerd + c.Runtime.ContainerID = id + c.Runtime.ContainerPID = uint32(10000 + i) + c.K8s.Namespace = "ns" + c.K8s.PodName = "pod" + c.K8s.PodUID = "pod-uid" + c.K8s.ContainerName = name + c.K8s.PodLabels = map[string]string{"app": "stale"} + containers = append(containers, c) + } + return pod, namespace, containers +} +func TestSnapshotBindingsCorrelatesExactRunningIdentities(t *testing.T) { + pod, namespace, containers := bindingFixtures() + client := fake.NewSimpleClientset(pod, namespace) + bindings, err := SnapshotBindingsFromContainers(context.Background(), client, containers, "node") + if err != nil { + t.Fatal(err) + } + if len(bindings) != 3 { + t.Fatalf("want three container kinds, got %d", len(bindings)) + } + for i, b := range bindings { + if b.Kind != []string{"regular", "init", "ephemeral"}[i] || b.PodUID != "pod-uid" || b.Runtime != "containerd" || b.ContainerID != containers[i].Runtime.ContainerID || b.CgroupID != containers[i].CgroupID || b.CgroupPath != containers[i].CgroupPath || b.PID != containers[i].Runtime.ContainerPID || b.MountNamespace != containers[i].Mntns || b.PodLabels["app"] != "current" || b.NamespaceLabels["team"] != "red" { + t.Fatalf("incorrect binding: %+v", b) + } + } + bindings[0].PodLabels["app"] = "mutated" + if bindings[1].PodLabels["app"] != "current" || pod.Labels["app"] != "current" { + t.Fatal("snapshot aliases metadata") + } + action := client.Actions()[0].(ktesting.ListAction) + if action.GetListRestrictions().Fields.String() != "spec.nodeName=node" { + t.Fatal("pod list not node-scoped") + } +} +func TestSnapshotRejectsDiscoveryGapsWithoutPartialBindings(t *testing.T) { + cases := map[string]func(*corev1.Pod, *corev1.Namespace, []*containercollection.Container) []*containercollection.Container{ + "missing runtime": func(_ *corev1.Pod, _ *corev1.Namespace, c []*containercollection.Container) []*containercollection.Container { + return c[:2] + }, + "zero cgroup": func(_ *corev1.Pod, _ *corev1.Namespace, c []*containercollection.Container) []*containercollection.Container { + c[1].CgroupID = 0 + return c + }, + "root cgroup": func(_ *corev1.Pod, _ *corev1.Namespace, c []*containercollection.Container) []*containercollection.Container { + c[1].CgroupPath = "/" + return c + }, + "missing PID": func(_ *corev1.Pod, _ *corev1.Namespace, c []*containercollection.Container) []*containercollection.Container { + c[1].Runtime.ContainerPID = 0 + return c + }, + "missing mount": func(_ *corev1.Pod, _ *corev1.Namespace, c []*containercollection.Container) []*containercollection.Container { + c[1].Mntns = 0 + return c + }, + "stale pod UID": func(_ *corev1.Pod, _ *corev1.Namespace, c []*containercollection.Container) []*containercollection.Container { + c[1].K8s.PodUID = "old" + return c + }, + "partial K8s": func(_ *corev1.Pod, _ *corev1.Namespace, c []*containercollection.Container) []*containercollection.Container { + c[1].K8s.Namespace = "" + return c + }, + "short container ID": func(_ *corev1.Pod, _ *corev1.Namespace, c []*containercollection.Container) []*containercollection.Container { + c[1].Runtime.ContainerID = c[1].Runtime.ContainerID[:12] + return c + }, + "status empty ID": func(p *corev1.Pod, _ *corev1.Namespace, c []*containercollection.Container) []*containercollection.Container { + p.Status.ContainerStatuses[0].ContainerID = "" + return c + }, + "unknown runtime": func(p *corev1.Pod, _ *corev1.Namespace, c []*containercollection.Container) []*containercollection.Container { + p.Status.ContainerStatuses[0].ContainerID = "mystery://id" + return c + }, + "duplicate runtime": func(_ *corev1.Pod, _ *corev1.Namespace, c []*containercollection.Container) []*containercollection.Container { + return append(c, c[0]) + }, + "shared cgroup": func(_ *corev1.Pod, _ *corev1.Namespace, c []*containercollection.Container) []*containercollection.Container { + c[1].CgroupID = c[0].CgroupID + return c + }, + "undeclared container": func(p *corev1.Pod, _ *corev1.Namespace, c []*containercollection.Container) []*containercollection.Container { + p.Spec.Containers = nil + return c + }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + p, ns, c := bindingFixtures() + c = mutate(p, ns, c) + bindings, err := SnapshotBindingsFromContainers(context.Background(), fake.NewSimpleClientset(p, ns), c, "node") + if err == nil || bindings != nil { + t.Fatalf("accepted partial snapshot: %+v / %v", bindings, err) + } + }) + } +} +func TestSnapshotSkipsNonKubernetesButNotUncorrelatedKubernetes(t *testing.T) { + pod, ns, c := bindingFixtures() + host := &containercollection.Container{} + host.Runtime.ContainerID = "host" + c = append(c, host) + if _, err := SnapshotBindingsFromContainers(context.Background(), fake.NewSimpleClientset(pod, ns), c, "node"); err != nil { + t.Fatal(err) + } + host.K8s.PodName = "unmatched" + if _, err := SnapshotBindingsFromContainers(context.Background(), fake.NewSimpleClientset(pod, ns), c, "node"); err == nil { + t.Fatal("unmatched live Kubernetes discovery silently omitted") + } +} +func TestSnapshotAPIFailuresAndNodeIsolation(t *testing.T) { + p, ns, _ := bindingFixtures() + p.Spec.NodeName = "other" + bindings, err := SnapshotBindingsFromContainers(context.Background(), fake.NewSimpleClientset(p, ns), nil, "node") + if err != nil || len(bindings) != 0 { + t.Fatalf("cross-node pods included: %v", err) + } + for _, resource := range []string{"pods", "namespaces"} { + p, ns, c := bindingFixtures() + client := fake.NewSimpleClientset(p, ns) + client.PrependReactor("list", resource, func(ktesting.Action) (bool, runtime.Object, error) { return true, nil, errors.New("disconnected") }) + if got, err := SnapshotBindingsFromContainers(context.Background(), client, c, "node"); err == nil || got != nil { + t.Fatal("API failure produced withdrawal snapshot") + } + } + p, _, c := bindingFixtures() + if _, err := SnapshotBindingsFromContainers(context.Background(), fake.NewSimpleClientset(p), c, "node"); err == nil { + t.Fatal("missing namespace labels accepted") + } + if _, err := SnapshotBindings(context.Background(), fake.NewSimpleClientset(), nil, "node"); err == nil { + t.Fatal("nil collection accepted") + } +} +func TestSnapshotUsesCollectionCurrentEntries(t *testing.T) { + collection := &containercollection.ContainerCollection{} + got, err := SnapshotBindings(context.Background(), fake.NewSimpleClientset(), collection, "node") + if err != nil || len(got) != 0 { + t.Fatalf("empty snapshot: %v", err) + } +} + +func TestSelectedSnapshotDoesNotRequireUnselectedAgentDiscovery(t *testing.T) { + p, ns, containers := bindingFixtures() + agent := p.DeepCopy() + agent.Name = "node-agent-own" + agent.UID = "agent-uid" + agent.Labels = map[string]string{"app": "node-agent"} + agent.Spec.InitContainers = nil + agent.Spec.EphemeralContainers = nil + agent.Status.InitContainerStatuses = nil + agent.Status.EphemeralContainerStatuses = nil + agent.Status.ContainerStatuses[0].ContainerID = "containerd://" + strings.Repeat("d", 64) + client := fake.NewSimpleClientset(p, ns, agent) + selectWorkload := func(b sandboxpolicy.Binding) bool { return b.PodLabels["app"] == "current" } + got, err := SnapshotSelectedBindingsFromContainers(context.Background(), client, containers, "node", selectWorkload) + if err != nil || len(got) != 3 { + t.Fatalf("unselected missing agent blocked workload: %+v %v", got, err) + } + selectAgent := func(b sandboxpolicy.Binding) bool { return b.PodName == "node-agent-own" } + if got, err := SnapshotSelectedBindingsFromContainers(context.Background(), client, containers, "node", selectAgent); err == nil || got != nil { + t.Fatal("explicitly selected ignored agent silently exempted") + } + if _, err := SnapshotBindingsFromContainers(context.Background(), client, containers, "node"); err == nil { + t.Fatal("strict-all API unexpectedly weakened") + } +} + +func TestSelectedSnapshotIgnoresUnmatchedDiscoveryButRejectsSelectedMismatch(t *testing.T) { + p, ns, containers := bindingFixtures() + client := fake.NewSimpleClientset(p, ns) + stale := *containers[0] + stale.Runtime.ContainerID = "old-runtime-id" + stale.K8s.PodUID = "old-pod" + containers = append(containers, &stale, nil) + selectAll := func(sandboxpolicy.Binding) bool { return true } + if got, err := SnapshotSelectedBindingsFromContainers(context.Background(), client, containers, "node", selectAll); err != nil || len(got) != 3 { + t.Fatalf("unmatched discovery blocked selected workloads: %v", err) + } + containers[0].K8s.PodUID = "different-pod" + if got, err := SnapshotSelectedBindingsFromContainers(context.Background(), client, containers, "node", selectAll); err == nil || got != nil { + t.Fatal("selected runtime identity mismatch silently ignored") + } +} + +func TestSelectedSnapshotSelectsBeforeRequiringRuntimeIdentity(t *testing.T) { + p, ns, containers := bindingFixtures() + p.Status.InitContainerStatuses[0].ContainerID = "" + selectRegular := func(b sandboxpolicy.Binding) bool { + return b.Kind == "regular" && b.Namespace == "ns" && b.ContainerName == "app" && b.NamespaceLabels["team"] == "red" + } + got, err := SnapshotSelectedBindingsFromContainers(context.Background(), fake.NewSimpleClientset(p, ns), containers[:1], "node", selectRegular) + if err != nil || len(got) != 1 { + t.Fatalf("unselected runtime metadata was required: %v", err) + } + if _, err := SnapshotSelectedBindingsFromContainers(context.Background(), fake.NewSimpleClientset(p, ns), containers, "node", nil); err == nil { + t.Fatal("nil selection predicate accepted") + } + if _, err := SnapshotSelectedBindingsFromContainers(context.Background(), fake.NewSimpleClientset(p), containers, "node", selectRegular); err == nil { + t.Fatal("unknown namespace labels inferred as empty") + } + if _, err := SnapshotSelectedBindings(context.Background(), fake.NewSimpleClientset(), nil, "node", selectRegular); err == nil { + t.Fatal("nil discovery collection accepted") + } + collection := &containercollection.ContainerCollection{} + got, err = SnapshotSelectedBindings(context.Background(), fake.NewSimpleClientset(p, ns), collection, "node", func(sandboxpolicy.Binding) bool { return false }) + if err != nil || len(got) != 0 { + t.Fatalf("no selected policies requires no binding: %v", err) + } +} diff --git a/pkg/sandboxpolicy/integration/readiness.go b/pkg/sandboxpolicy/integration/readiness.go new file mode 100644 index 0000000000..9541bf0064 --- /dev/null +++ b/pkg/sandboxpolicy/integration/readiness.go @@ -0,0 +1,40 @@ +package integration + +import ( + "context" + "errors" + "sync/atomic" + + "github.com/kubescape/node-agent/pkg/sandboxpolicy" + api "github.com/kubescape/storage/pkg/apis/sandbox/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var ErrInputsChanged = errors.New("sandbox inputs changed during synchronization") + +// readinessWriter makes concurrent discovery/configuration hints part of the +// status barrier. Committed enforcement remains installed, but an incomplete +// snapshot cannot announce Ready while an observed input change awaits replay. +type readinessWriter struct { + inner sandboxpolicy.StatusWriter + observed, snapshot *atomic.Uint64 +} + +func (w readinessWriter) WriteStatus(ctx context.Context, report *api.SandboxPolicyNodeStatus) error { + changed := w.observed.Load() != w.snapshot.Load() + if !changed { + return w.inner.WriteStatus(ctx, report) + } + copy := report.DeepCopy() + if copy.Status != nil { + copy.Status.Stale = true + for i := range copy.Status.Conditions { + if copy.Status.Conditions[i].Type == "Ready" && copy.Status.Conditions[i].Status == metav1.ConditionTrue { + copy.Status.Conditions[i].Status = metav1.ConditionFalse + copy.Status.Conditions[i].Reason = "InputsChanged" + copy.Status.Conditions[i].Message = "Observed inputs changed during synchronization; replay is pending and newly selected containers may be unprotected" + } + } + } + return errors.Join(ErrInputsChanged, w.inner.WriteStatus(ctx, copy)) +} diff --git a/pkg/sandboxpolicy/integration/service.go b/pkg/sandboxpolicy/integration/service.go new file mode 100644 index 0000000000..fd920f5ffd --- /dev/null +++ b/pkg/sandboxpolicy/integration/service.go @@ -0,0 +1,313 @@ +package integration + +import ( + "context" + "fmt" + "path/filepath" + "sync" + "sync/atomic" + "time" + + mapset "github.com/deckarep/golang-set/v2" + "github.com/goradd/maps" + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullruntime" + containercollection "github.com/inspektor-gadget/inspektor-gadget/pkg/container-collection" + "github.com/inspektor-gadget/inspektor-gadget/pkg/operators/kubemanager" + "github.com/inspektor-gadget/inspektor-gadget/pkg/runtime/local" + tracercollection "github.com/inspektor-gadget/inspektor-gadget/pkg/tracer-collection" + "github.com/inspektor-gadget/inspektor-gadget/pkg/utils/host" + "github.com/kubescape/node-agent/pkg/containerwatcher" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" + "github.com/kubescape/node-agent/pkg/sandboxpolicy/igbackend" + "github.com/kubescape/node-agent/pkg/utils" + "github.com/kubescape/node-agent/pkg/watcher" + api "github.com/kubescape/storage/pkg/apis/sandbox/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + orasoci "oras.land/oras-go/v2/content/oci" +) + +// ServiceOptions deliberately has no inferred host/runtime-helper exemptions. +// Callers must explicitly enable this service and ship the matching mutator image. +type ServiceOptions struct { + Kubernetes kubernetes.Interface + Dynamic dynamic.Interface + Containers *containercollection.ContainerCollection + Tracers *tracercollection.TracerCollection + NodeName, AgentNamespace, ImageName, ImageArchive string + ContainerdSocket, ControlRoot string + MaxOwners uint64 + Sources sandboxpolicy.SourceResolver + Receivers *maps.SafeMap[utils.EventType, mapset.Set[containerwatcher.GenericEventReceiver]] + Enricher containerwatcher.TaskBasedEnricher + OnError func(error) +} + +type serviceBackend interface { + sandboxpolicy.Backend + Close(context.Context) error +} + +type Service struct { + Controller *sandboxpolicy.Controller + backend serviceBackend + exporter *sandboxpolicy.VerdictExporter + closeRuntime func() error + options ServiceOptions + mu sync.RWMutex + bindings map[string]sandboxpolicy.Binding + snapshot func(context.Context) ([]sandboxpolicy.Binding, error) + cancel context.CancelFunc + done chan struct{} + closeMu sync.Mutex + backendClosed bool + runtimeClosed bool + subscribed bool + closed bool + wake chan struct{} + inputEpoch atomic.Uint64 + snapshotEpoch atomic.Uint64 +} + +// NewService prepares the public watcher/exporter/native runtime bridge. It does +// not authorize any cgroup or start enforcement until Start is called. +func NewService(ctx context.Context, options ServiceOptions) (*Service, error) { + if options.Kubernetes == nil || options.Dynamic == nil || options.Containers == nil || options.Tracers == nil || options.NodeName == "" || options.AgentNamespace == "" || options.ImageName == "" || options.ImageArchive == "" || options.ContainerdSocket == "" || options.ControlRoot == "" || options.MaxOwners == 0 || options.MaxOwners > igbackend.FullBackendMaxBindings { + return nil, fmt.Errorf("sandbox service requires clients, discovery, node identity and explicit image/archive") + } + node, err := options.Kubernetes.CoreV1().Nodes().Get(ctx, options.NodeName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("sandbox node identity: %w", err) + } + store, err := orasoci.NewFromTar(ctx, options.ImageArchive) + if err != nil { + return nil, fmt.Errorf("sandbox image archive: %w", err) + } + r := local.New() + if err = r.Init(nil); err != nil { + return nil, fmt.Errorf("sandbox native runtime: %w", err) + } + s := &Service{options: options, bindings: map[string]sandboxpolicy.Binding{}, closeRuntime: r.Close, done: make(chan struct{}), wake: make(chan struct{}, 1)} + s.exporter, err = sandboxpolicy.NewVerdictExporter(sandboxpolicy.ExporterOptions{Capacity: 1024, Receivers: options.Receivers, Enricher: options.Enricher, ResolveBinding: s.resolveBinding}) + if err != nil { + _ = r.Close() + return nil, err + } + budget, err := fullruntime.NewBudget(fullruntime.BudgetLimits{Owners: options.MaxOwners, MapPayloadBytes: options.MaxOwners * fullruntime.OwnerMapPayloadLimit, ProgramInstructions: options.MaxOwners * fullruntime.OwnerInstructionLimit, HistoryEntries: options.MaxOwners * fullruntime.OwnerHistoryLimit}) + if err != nil { + _ = r.Close() + return nil, err + } + source, err := igbackend.NewContainerdFullSource(options.ContainerdSocket, host.HostRoot, host.HostProcFs, filepath.Join(host.HostRoot, "sys/fs/cgroup"), igbackend.ImageIndexLimits{}) + if err != nil { + _ = r.Close() + return nil, err + } + // Source refuses Close while any acquired lease remains. It is released only + // after backend cleanup confirms native links and borrowed map FDs are gone. + s.closeRuntime = func() error { + if err := source.Close(); err != nil { + return err + } + return r.Close() + } + engine, err := igbackend.NewNativeFullEngine(r, kubemanager.NewKubeManager(options.Containers, options.Tracers), store, options.ImageName, options.ControlRoot, budget, s.report) + if err != nil { + _ = s.closeRuntime() + return nil, err + } + s.backend, err = igbackend.NewFull(engine, source, s.exporter, s.report) + if err != nil { + _ = s.closeRuntime() + return nil, err + } + s.Controller, err = sandboxpolicy.NewController(options.Dynamic, s.backend, options.Sources, readinessWriter{inner: &sandboxpolicy.DynamicStatusWriter{Client: options.Dynamic}, observed: &s.inputEpoch, snapshot: &s.snapshotEpoch}, sandboxpolicy.Options{Node: api.NodeReference{Name: node.Name, UID: node.UID}, AgentNamespace: options.AgentNamespace}) + if err != nil { + _ = s.backend.Close(ctx) + _ = s.closeRuntime() + return nil, err + } + return s, nil +} + +// Watch events are hints only. Generic watcher queues may deliver old updates +// after a relist/deletion; applying their payload could resurrect removed policy. +// Refresh always reads authoritative configuration and cannot revive queued UIDs. +func (s *Service) WatchResources() []watcher.WatchResource { return s.Controller.WatchResources() } +func (s *Service) AddHandler(_ context.Context, obj runtime.Object) { s.configurationHint(obj) } +func (s *Service) ModifyHandler(_ context.Context, obj runtime.Object) { s.configurationHint(obj) } +func (s *Service) DeleteHandler(_ context.Context, obj runtime.Object) { s.configurationHint(obj) } +func (s *Service) configurationHint(obj runtime.Object) { + if obj == nil { + return + } + switch obj.GetObjectKind().GroupVersionKind().Kind { + case "SandboxPolicy", "ClusterSandboxPolicy", "SandboxPolicyClasses": + s.ContainerCallback(containercollection.PubSubEvent{}) + } +} + +// Start is called after container discovery is running and before the generic +// dynamic watcher starts. Relisting repairs watch disconnections and re-resolves +// source profiles/selector labels. Failed snapshots never become an empty list. +func (s *Service) Start(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + s.mu.Lock() + if s.cancel != nil || s.closed { + s.mu.Unlock() + return fmt.Errorf("sandbox service already started") + } + runCtx, cancel := context.WithCancel(ctx) + s.cancel = cancel + if s.options.Containers != nil { + s.options.Containers.Subscribe(s, containercollection.ContainerSelector{}, s.ContainerCallback) + s.subscribed = true + } + s.mu.Unlock() + go s.exporter.Run(runCtx) + // Drain observed startup churn into the readiness barrier before handing + // work to the background loop. Persistent churn stays explicitly NotReady. +drain: + for attempt := 0; attempt < 4; attempt++ { + s.refresh(runCtx) + select { + case <-s.wake: + if attempt == 3 { + select { + case s.wake <- struct{}{}: + default: + } + } + continue + default: + break drain + } + } + go func() { + defer close(s.done) + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + select { + case <-runCtx.Done(): + return + case <-ticker.C: + s.refresh(runCtx) + case <-s.wake: + s.refresh(runCtx) + } + } + }() + return nil +} +func (s *Service) refresh(ctx context.Context) { + s.snapshotEpoch.Store(s.inputEpoch.Load()) + // Configuration and selected-runtime discovery share one commit barrier. A + // candidate cannot report Ready between its relist and a failed binding list. + err := s.Controller.Refresh(ctx, func(ctx context.Context, selected func(sandboxpolicy.Binding) bool) ([]sandboxpolicy.Binding, error) { + var bindings []sandboxpolicy.Binding + var err error + if s.snapshot != nil { + bindings, err = s.snapshot(ctx) + } else { + bindings, err = SnapshotSelectedBindings(ctx, s.options.Kubernetes, s.options.Containers, s.options.NodeName, selected) + } + if err != nil { + return nil, fmt.Errorf("sandbox binding snapshot retained: %w", err) + } + // Publish exact correlation before native startup can emit its first verdict. + // This cache is metadata, not an enforcement readiness or commit declaration. + s.mu.Lock() + next := make(map[string]sandboxpolicy.Binding, len(bindings)) + for _, b := range bindings { + b.PodLabels = copyLabels(b.PodLabels) + b.NamespaceLabels = copyLabels(b.NamespaceLabels) + next[b.ContainerID] = b + } + s.bindings = next + s.mu.Unlock() + return bindings, nil + }) + if err != nil { + s.report(err) + } +} + +// ContainerCallback is deliberately nonblocking: event delivery must never hold +// discovery locks while a gadget is loading or Kubernetes is unavailable. +func (s *Service) ContainerCallback(containercollection.PubSubEvent) { + s.inputEpoch.Add(1) + select { + case s.wake <- struct{}{}: + default: + } +} + +func (s *Service) resolveBinding(v sandboxpolicy.Verdict) (sandboxpolicy.Binding, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + b, ok := s.bindings[v.ContainerID] + return b, ok && b.CgroupID == v.CgroupID && b.PodUID == v.PodUID && b.PID == v.BindingPID && b.MountNamespace == v.MountNamespace +} + +// ExporterStats exposes telemetry loss separately from enforcement readiness. +func (s *Service) ExporterStats() sandboxpolicy.ExporterStats { return s.exporter.Stats() } + +func (s *Service) report(err error) { + if err != nil && s.options.OnError != nil { + s.options.OnError(err) + } +} + +// Close withdraws exact owned bindings before releasing their runtime. It waits +// for the reconciliation loop before cleanup so no concurrent pass can reinstall. +func (s *Service) Close(ctx context.Context) error { + s.closeMu.Lock() + defer s.closeMu.Unlock() + s.mu.Lock() + s.closed = true + cancel := s.cancel + if s.subscribed { + s.options.Containers.Unsubscribe(s) + s.subscribed = false + } + s.mu.Unlock() + if cancel != nil { + cancel() + select { + case <-s.done: + case <-ctx.Done(): + return ctx.Err() + } + } + if !s.backendClosed { + if err := s.backend.Close(ctx); err != nil { + return err + } + s.backendClosed = true + } + if !s.runtimeClosed { + if err := s.closeRuntime(); err != nil { + return err + } + s.runtimeClosed = true + } + return nil +} + +// KernelEventStats exposes lifetime kernel verdict-ring statistics separately +// from ExporterStats. Unavailable counts/errors mean totals are incomplete; +// these evaluator counters do not cover early native-owner exec refusals. +func (s *Service) KernelEventStats(ctx context.Context) (igbackend.KernelEventStats, error) { + source, ok := s.backend.(interface { + KernelEventStats(context.Context) (igbackend.KernelEventStats, error) + }) + if !ok { + return igbackend.KernelEventStats{}, fmt.Errorf("kernel event statistics unavailable") + } + return source.KernelEventStats(ctx) +} diff --git a/pkg/sandboxpolicy/integration/service_test.go b/pkg/sandboxpolicy/integration/service_test.go new file mode 100644 index 0000000000..8539199cfb --- /dev/null +++ b/pkg/sandboxpolicy/integration/service_test.go @@ -0,0 +1,267 @@ +package integration + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + containercollection "github.com/inspektor-gadget/inspektor-gadget/pkg/container-collection" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" + api "github.com/kubescape/storage/pkg/apis/sandbox/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" +) + +type serviceTestBackend struct { + replaces, withdraws, closes int + closeErr error + onReplace func() +} + +func (b *serviceTestBackend) Replace(_ context.Context, _ sandboxpolicy.Binding, p sandboxpolicy.Plan) (sandboxpolicy.ReplaceResult, error) { + b.replaces++ + if b.onReplace != nil { + b.onReplace() + } + return sandboxpolicy.ReplaceResult{Committed: true, ActiveRevision: p.Revision}, nil +} +func (b *serviceTestBackend) Withdraw(context.Context, sandboxpolicy.Binding) (sandboxpolicy.WithdrawResult, error) { + b.withdraws++ + return sandboxpolicy.WithdrawResult{Committed: true}, nil +} +func (b *serviceTestBackend) Close(context.Context) error { b.closes++; return b.closeErr } +func testService(t *testing.T) (*Service, *serviceTestBackend) { + t.Helper() + client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{sandboxpolicy.PolicyGVR: "SandboxPolicyList", sandboxpolicy.ClusterPolicyGVR: "ClusterSandboxPolicyList", sandboxpolicy.ClassesGVR: "SandboxPolicyClassesList"}) + p := &unstructured.Unstructured{Object: map[string]any{"apiVersion": api.SchemeGroupVersion.String(), "kind": "SandboxPolicy", "metadata": map[string]any{"name": "test", "namespace": "ns", "uid": "policy-uid", "generation": int64(1)}, "spec": map[string]any{"selector": map[string]any{"podSelector": map[string]any{}}, "mode": "enforce", "containers": []any{"app"}, "exec": map[string]any{"default": "deny"}}}} + if _, err := client.Resource(sandboxpolicy.PolicyGVR).Namespace("ns").Create(t.Context(), p, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + backend := &serviceTestBackend{} + controller, err := sandboxpolicy.NewController(client, backend, nil, nil, sandboxpolicy.Options{Node: api.NodeReference{Name: "node", UID: "node-uid"}, AgentNamespace: "agent"}) + if err != nil { + t.Fatal(err) + } + exporter, err := sandboxpolicy.NewVerdictExporter(sandboxpolicy.ExporterOptions{Capacity: 8}) + if err != nil { + t.Fatal(err) + } + return &Service{Controller: controller, backend: backend, exporter: exporter, closeRuntime: func() error { return nil }, options: ServiceOptions{Dynamic: client}, bindings: map[string]sandboxpolicy.Binding{}, done: make(chan struct{}), wake: make(chan struct{}, 1)}, backend +} +func serviceBinding() sandboxpolicy.Binding { + return sandboxpolicy.Binding{Runtime: "containerd", ContainerID: "exact-id", CgroupID: 42, CgroupPath: "/pod/container", PID: 10, MountNamespace: 99, PodUID: "pod-uid", PodName: "pod", Namespace: "ns", ContainerName: "app", Kind: "regular", PodLabels: map[string]string{"app": "test"}} +} +func TestServiceSnapshotFailureNeverWithdraws(t *testing.T) { + s, b := testService(t) + binding := serviceBinding() + s.snapshot = func(context.Context) ([]sandboxpolicy.Binding, error) { return []sandboxpolicy.Binding{binding}, nil } + s.refresh(t.Context()) + if b.replaces != 1 { + t.Fatalf("initial bind: %d", b.replaces) + } + failure := errors.New("API unavailable") + s.snapshot = func(context.Context) ([]sandboxpolicy.Binding, error) { return nil, failure } + s.refresh(t.Context()) + if b.withdraws != 0 || s.Controller.LastError() == nil { + t.Fatal("failed snapshot withdrew or hid readiness failure") + } + v := sandboxpolicy.Verdict{ContainerID: binding.ContainerID, CgroupID: binding.CgroupID, PodUID: binding.PodUID, BindingPID: binding.PID, MountNamespace: binding.MountNamespace} + if _, ok := s.resolveBinding(v); !ok { + t.Fatal("last binding metadata removed") + } + s.snapshot = func(context.Context) ([]sandboxpolicy.Binding, error) { return nil, nil } + s.refresh(t.Context()) + if b.withdraws != 1 { + t.Fatalf("valid empty snapshot did not withdraw: %d", b.withdraws) + } + if _, ok := s.resolveBinding(v); ok { + t.Fatal("deleted identity still resolves") + } +} +func TestServiceMetadataCorrelationAndCopy(t *testing.T) { + s, _ := testService(t) + binding := serviceBinding() + s.snapshot = func(context.Context) ([]sandboxpolicy.Binding, error) { return []sandboxpolicy.Binding{binding}, nil } + s.refresh(t.Context()) + binding.PodLabels["app"] = "mutated" + v := sandboxpolicy.Verdict{ContainerID: binding.ContainerID, CgroupID: 42, PodUID: binding.PodUID, BindingPID: binding.PID, MountNamespace: binding.MountNamespace} + got, ok := s.resolveBinding(v) + if !ok || got.PodLabels["app"] != "test" { + t.Fatal("metadata aliased caller") + } + v.CgroupID = 43 + if _, ok := s.resolveBinding(v); ok { + t.Fatal("stale cgroup identity accepted") + } +} +func TestServiceStartCloseAndNonblockingDiscovery(t *testing.T) { + s, b := testService(t) + var snapshots atomic.Int32 + s.snapshot = func(context.Context) ([]sandboxpolicy.Binding, error) { snapshots.Add(1); return nil, nil } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if err := s.Start(ctx); err != nil { + t.Fatal(err) + } + for range 1000 { + s.ContainerCallback(containercollection.PubSubEvent{}) + } + cleanupCtx, cleanupCancel := context.WithTimeout(t.Context(), time.Second) + defer cleanupCancel() + if err := s.Close(cleanupCtx); err != nil { + t.Fatal(err) + } + if err := s.Close(cleanupCtx); err != nil || b.closes != 1 { + t.Fatalf("close not idempotent: %v %d", err, b.closes) + } + if err := s.Start(ctx); err == nil { + t.Fatal("restarted closed runtime") + } + if snapshots.Load() == 0 { + t.Fatal("no initial snapshot") + } +} +func TestServiceCloseBeforeStart(t *testing.T) { + s, b := testService(t) + if err := s.Close(t.Context()); err != nil || b.closes != 1 { + t.Fatal(err) + } + if err := s.Start(t.Context()); err == nil { + t.Fatal("closed service started") + } +} +func TestNewServiceRequiresExplicitInputs(t *testing.T) { + if _, err := NewService(t.Context(), ServiceOptions{}); err == nil { + t.Fatal("missing explicit inputs accepted") + } +} + +func TestServiceRejectsReusedPodMetadata(t *testing.T) { + s, _ := testService(t) + b := serviceBinding() + s.bindings[b.ContainerID] = b + v := sandboxpolicy.Verdict{ContainerID: b.ContainerID, CgroupID: b.CgroupID, PodUID: "old-pod", BindingPID: b.PID, MountNamespace: b.MountNamespace} + if _, ok := s.resolveBinding(v); ok { + t.Fatal("old workload event attributed to replacement pod") + } + v.PodUID = b.PodUID + v.BindingPID++ + if _, ok := s.resolveBinding(v); ok { + t.Fatal("old init task accepted") + } +} + +func TestServiceCloseRetriesWithoutReleasingRuntime(t *testing.T) { + s, b := testService(t) + closes := 0 + s.closeRuntime = func() error { closes++; return nil } + b.closeErr = errors.New("cleanup pending") + if err := s.Close(t.Context()); err == nil { + t.Fatal("cleanup error hidden") + } + if closes != 0 { + t.Fatal("runtime released before cleanup") + } + b.closeErr = nil + if err := s.Close(t.Context()); err != nil { + t.Fatal(err) + } + if b.closes != 2 || closes != 1 { + t.Fatalf("cleanup retry: backend %d runtime %d", b.closes, closes) + } +} + +func TestDelayedWatchHintCannotResurrectDeletedPolicy(t *testing.T) { + s, b := testService(t) + binding := serviceBinding() + s.snapshot = func(context.Context) ([]sandboxpolicy.Binding, error) { return []sandboxpolicy.Binding{binding}, nil } + s.refresh(t.Context()) + if b.replaces != 1 { + t.Fatal("missing initial bind") + } + client := s.options.Dynamic + old, err := client.Resource(sandboxpolicy.PolicyGVR).Namespace("ns").Get(t.Context(), "test", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + if err = client.Resource(sandboxpolicy.PolicyGVR).Namespace("ns").Delete(t.Context(), "test", metav1.DeleteOptions{}); err != nil { + t.Fatal(err) + } + s.refresh(t.Context()) + if b.withdraws != 1 { + t.Fatal("delete not withdrawn") + } + s.ModifyHandler(t.Context(), old) + s.refresh(t.Context()) + if b.replaces != 1 { + t.Fatal("delayed event revived deleted policy") + } +} + +type readinessCapture struct { + reports []*api.SandboxPolicyNodeStatus + failure error +} + +func (w *readinessCapture) WriteStatus(_ context.Context, r *api.SandboxPolicyNodeStatus) error { + w.reports = append(w.reports, r.DeepCopy()) + return w.failure +} +func TestReadinessWriterInputEpoch(t *testing.T) { + var observed, snapshot atomic.Uint64 + inner := &readinessCapture{} + writer := readinessWriter{inner: inner, observed: &observed, snapshot: &snapshot} + report := &api.SandboxPolicyNodeStatus{Status: &api.NodeStatus{Conditions: []metav1.Condition{{Type: "Ready", Status: metav1.ConditionTrue}}}} + if err := writer.WriteStatus(t.Context(), report); err != nil { + t.Fatal(err) + } + observed.Add(1) + inner.failure = errors.New("API unavailable") + if err := writer.WriteStatus(t.Context(), report); !errors.Is(err, ErrInputsChanged) || !errors.Is(err, inner.failure) { + t.Fatalf("barrier/write failure not preserved: %v", err) + } + last := inner.reports[len(inner.reports)-1] + if last.Status.Conditions[0].Status != metav1.ConditionFalse || last.Status.Conditions[0].Reason != "InputsChanged" || !last.Status.Stale { + t.Fatal("concurrent input still reported Ready") + } + if report.Status.Conditions[0].Status != metav1.ConditionTrue || report.Status.Stale { + t.Fatal("writer mutated caller report") + } +} +func TestServiceDrainsStartupChurnBeforeReady(t *testing.T) { + s, b := testService(t) + capture := &readinessCapture{} + writer := readinessWriter{inner: capture, observed: &s.inputEpoch, snapshot: &s.snapshotEpoch} + controller, err := sandboxpolicy.NewController(s.options.Dynamic, b, nil, writer, sandboxpolicy.Options{Node: api.NodeReference{Name: "node", UID: "node-uid"}, AgentNamespace: "agent"}) + if err != nil { + t.Fatal(err) + } + s.Controller = controller + var snapshots atomic.Int32 + s.snapshot = func(context.Context) ([]sandboxpolicy.Binding, error) { + snapshots.Add(1) + return []sandboxpolicy.Binding{serviceBinding()}, nil + } + b.onReplace = func() { b.onReplace = nil; s.ContainerCallback(containercollection.PubSubEvent{}) } + if err = s.Start(t.Context()); err != nil { + t.Fatal(err) + } + if err = s.Close(t.Context()); err != nil { + t.Fatal(err) + } + if snapshots.Load() < 2 || len(capture.reports) < 2 { + t.Fatal("startup hint not replayed") + } + first, last := capture.reports[0], capture.reports[len(capture.reports)-1] + if first.Status.Conditions[0].Status != metav1.ConditionFalse || first.Status.Conditions[0].Reason != "InputsChanged" { + t.Fatal("premature Ready during native startup churn") + } + if last.Status.Conditions[0].Status != metav1.ConditionTrue { + t.Fatalf("stable replay never became Ready: %+v", last.Status.Conditions) + } +} diff --git a/pkg/sandboxpolicy/integration/sources.go b/pkg/sandboxpolicy/integration/sources.go new file mode 100644 index 0000000000..7e4596ae8d --- /dev/null +++ b/pkg/sandboxpolicy/integration/sources.go @@ -0,0 +1,115 @@ +package integration + +import ( + "context" + "crypto/sha256" + "fmt" + "path" + "sort" + "strings" + + helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" + api "github.com/kubescape/storage/pkg/apis/sandbox/v1alpha1" + profiles "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + spdxv1beta1 "github.com/kubescape/storage/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type ProfileResolver struct { + client spdxv1beta1.ContainerProfilesGetter +} + +var _ sandboxpolicy.SourceResolver = (*ProfileResolver)(nil) + +func NewProfileResolver(client spdxv1beta1.ContainerProfilesGetter) *ProfileResolver { + return &ProfileResolver{client: client} +} + +// ResolveExecs imports only complete, unambiguous literal-path exec allowlists. +// Exact/anchored argv (including ArgsRequired's explicit-empty semantics), dynamic +// path tokens, and environment constraints cannot be faithfully represented by +// SandboxPolicy's prefix/contains vocabulary: refuse, never widen silently. +// The service must Reconcile on source changes (periodic refresh is sufficient). +func (r *ProfileResolver) ResolveExecs(ctx context.Context, namespace, name, container string) ([]api.ExecRule, error) { + if r == nil || r.client == nil { + return nil, fmt.Errorf("ContainerProfile client unavailable") + } + if namespace == "" || name == "" || container == "" { + return nil, fmt.Errorf("ContainerProfile namespace/name/container are required") + } + profile, err := r.client.ContainerProfiles(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("read ContainerProfile %s/%s: %w", namespace, name, err) + } + if profile == nil || profile.Namespace != namespace || profile.Name != name || profile.UID == "" { + return nil, fmt.Errorf("ContainerProfile identity is incomplete or mismatched") + } + if profile.Annotations[helpersv1.StatusMetadataKey] != helpersv1.Completed || profile.Annotations[helpersv1.CompletionMetadataKey] != helpersv1.Full { + return nil, fmt.Errorf("ContainerProfile %s/%s is not completed with full coverage", namespace, name) + } + execs, err := profileExecs(profile, container) + if err != nil { + return nil, err + } + if len(execs) > sandboxpolicy.MaxRulesPerPolicy { + return nil, fmt.Errorf("ContainerProfile exceeds %d exec entries", sandboxpolicy.MaxRulesPerPolicy) + } + paths := make(map[string]bool) + for _, exec := range execs { + if exec.ArgsRequired || len(exec.Args) != 0 { + return nil, fmt.Errorf("ContainerProfile exec %q argv semantics cannot be represented without weakening", exec.Path) + } + if len(exec.Envs) != 0 { + return nil, fmt.Errorf("ContainerProfile exec %q environment constraints are unsupported", exec.Path) + } + if exec.Path == "" || !path.IsAbs(exec.Path) || strings.ContainsAny(exec.Path, "*?[]\\\x00") || strings.Contains(exec.Path, "⋯") { + return nil, fmt.Errorf("ContainerProfile exec path %q is not a supported literal absolute path", exec.Path) + } + paths[exec.Path] = true + } + ordered := make([]string, 0, len(paths)) + for value := range paths { + ordered = append(ordered, value) + } + sort.Strings(ordered) + result := make([]api.ExecRule, 0, len(ordered)) + for _, value := range ordered { + sum := sha256.Sum256([]byte(value)) + result = append(result, api.ExecRule{Rule: api.Rule{Name: fmt.Sprintf("profile-exec-%x", sum[:12]), Action: api.ActionAllow, Origin: "ContainerProfile/" + namespace + "/" + name}, Target: api.ExecTarget{PathTarget: api.PathTarget{Path: []string{value}}}}) + } + return result, nil +} + +func profileExecs(profile *profiles.ContainerProfile, container string) ([]profiles.ExecCalls, error) { + groups := [][]profiles.ContainerProfileContainer{profile.Spec.Containers, profile.Spec.InitContainers, profile.Spec.EphemeralContainers} + hasGroups := false + matches := 0 + var selected []profiles.ExecCalls + for _, group := range groups { + if len(group) > 0 { + hasGroups = true + } + for _, entry := range group { + if entry.Name == container { + matches++ + selected = entry.Execs + } + } + } + if hasGroups { + if len(profile.Spec.Execs) != 0 { + return nil, fmt.Errorf("ContainerProfile mixes top-level and named-container execs") + } + if matches != 1 { + return nil, fmt.Errorf("ContainerProfile must contain exactly one entry for container %q (found %d)", container, matches) + } + return selected, nil + } + // Agent-learned profiles carry the canonical name label. There is no safe + // positional or name-prefix inference for selecting a container's execs. + if profile.Labels[helpersv1.ContainerNameMetadataKey] != container { + return nil, fmt.Errorf("ContainerProfile container identity does not match %q", container) + } + return profile.Spec.Execs, nil +} diff --git a/pkg/sandboxpolicy/integration/sources_test.go b/pkg/sandboxpolicy/integration/sources_test.go new file mode 100644 index 0000000000..5cb438f5d2 --- /dev/null +++ b/pkg/sandboxpolicy/integration/sources_test.go @@ -0,0 +1,108 @@ +package integration + +import ( + "context" + "reflect" + "strings" + "testing" + + helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" + api "github.com/kubescape/storage/pkg/apis/sandbox/v1alpha1" + profiles "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/kubescape/storage/pkg/generated/clientset/versioned/fake" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func completedProfile() *profiles.ContainerProfile { + return &profiles.ContainerProfile{ObjectMeta: metav1.ObjectMeta{Name: "profile", Namespace: "ns", UID: "profile-uid", Annotations: map[string]string{helpersv1.StatusMetadataKey: helpersv1.Completed, helpersv1.CompletionMetadataKey: helpersv1.Full}, Labels: map[string]string{helpersv1.ContainerNameMetadataKey: "app"}}, Spec: profiles.ContainerProfileSpec{Execs: []profiles.ExecCalls{{Path: "/bin/z"}, {Path: "/bin/a"}, {Path: "/bin/z"}}}} +} +func TestProfileResolverReadsCompleteLiteralExecsOnly(t *testing.T) { + p := completedProfile() + original := p.DeepCopy() + client := fake.NewSimpleClientset(p) + resolver := NewProfileResolver(client.SpdxV1beta1()) + rules, err := resolver.ResolveExecs(context.Background(), "ns", "profile", "app") + if err != nil { + t.Fatal(err) + } + if len(rules) != 2 || rules[0].Target.Path[0] != "/bin/a" || rules[1].Target.Path[0] != "/bin/z" { + t.Fatalf("nondeterministic import: %+v", rules) + } + for _, r := range rules { + if r.Action != api.ActionAllow || r.Origin != "ContainerProfile/ns/profile" || r.Target.Argv != nil || r.Name == "" { + t.Fatalf("wrong import: %+v", r) + } + } + if !reflect.DeepEqual(original, p) { + t.Fatal("profile mutated") + } + for _, action := range client.Actions() { + if action.GetVerb() != "get" || action.GetNamespace() != "ns" { + t.Fatal("resolver wrote or escaped selected namespace") + } + } +} +func TestProfileResolverRejectsUnfaithfulTranslations(t *testing.T) { + cases := map[string]func(*profiles.ContainerProfile){ + "incomplete": func(p *profiles.ContainerProfile) { p.Annotations[helpersv1.StatusMetadataKey] = "learning" }, + "partial": func(p *profiles.ContainerProfile) { p.Annotations[helpersv1.CompletionMetadataKey] = helpersv1.Partial }, + "missing signal": func(p *profiles.ContainerProfile) { p.Annotations = nil }, + "wrong container": func(p *profiles.ContainerProfile) { p.Labels[helpersv1.ContainerNameMetadataKey] = "other" }, + "unknown container": func(p *profiles.ContainerProfile) { p.Labels = nil }, + "exact argv": func(p *profiles.ContainerProfile) { p.Spec.Execs[0].ArgsRequired = true }, + "recorded argv": func(p *profiles.ContainerProfile) { p.Spec.Execs[0].Args = []string{"-c", "id"} }, + "environment": func(p *profiles.ContainerProfile) { p.Spec.Execs[0].Envs = []string{"MODE=secret"} }, + "dynamic path": func(p *profiles.ContainerProfile) { p.Spec.Execs[0].Path = "/tmp/⋯/bin" }, + "glob path": func(p *profiles.ContainerProfile) { p.Spec.Execs[0].Path = "/bin/*" }, + "relative path": func(p *profiles.ContainerProfile) { p.Spec.Execs[0].Path = "bin/tool" }, + "missing UID": func(p *profiles.ContainerProfile) { p.UID = "" }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + p := completedProfile() + mutate(p) + resolver := NewProfileResolver(fake.NewSimpleClientset(p).SpdxV1beta1()) + if rules, err := resolver.ResolveExecs(context.Background(), "ns", "profile", "app"); err == nil || rules != nil { + t.Fatalf("silently widened unsupported import: %+v / %v", rules, err) + } + }) + } +} +func TestProfileResolverMultiContainerProjectionAndAmbiguity(t *testing.T) { + p := completedProfile() + p.Spec.Execs = nil + p.Labels = nil + p.Spec.InitContainers = []profiles.ContainerProfileContainer{{Name: "init", Execs: []profiles.ExecCalls{{Path: "/bin/init"}}}} + p.Spec.Containers = []profiles.ContainerProfileContainer{{Name: "app", Execs: []profiles.ExecCalls{{Path: "/bin/app"}}}} + p.Spec.EphemeralContainers = []profiles.ContainerProfileContainer{{Name: "debug", Execs: []profiles.ExecCalls{{Path: "/bin/debug"}}}} + for _, name := range []string{"app", "init", "debug"} { + rules, err := NewProfileResolver(fake.NewSimpleClientset(p).SpdxV1beta1()).ResolveExecs(context.Background(), "ns", "profile", name) + if err != nil || len(rules) != 1 || rules[0].Target.Path[0] != "/bin/"+name { + t.Fatalf("container projection %s: %+v %v", name, rules, err) + } + } + p.Spec.InitContainers = append(p.Spec.InitContainers, profiles.ContainerProfileContainer{Name: "app"}) + if _, err := NewProfileResolver(fake.NewSimpleClientset(p).SpdxV1beta1()).ResolveExecs(context.Background(), "ns", "profile", "app"); err == nil { + t.Fatal("ambiguous container name accepted") + } + p = completedProfile() + p.Spec.Containers = []profiles.ContainerProfileContainer{{Name: "app"}} + if _, err := NewProfileResolver(fake.NewSimpleClientset(p).SpdxV1beta1()).ResolveExecs(context.Background(), "ns", "profile", "app"); err == nil { + t.Fatal("mixed flat/named execs accepted") + } +} +func TestProfileResolverEmptyAndMissingSource(t *testing.T) { + p := completedProfile() + p.Spec.Execs = nil + resolver := NewProfileResolver(fake.NewSimpleClientset(p).SpdxV1beta1()) + rules, err := resolver.ResolveExecs(context.Background(), "ns", "profile", "app") + if err != nil || len(rules) != 0 { + t.Fatal("known complete empty source rejected") + } + if _, err := resolver.ResolveExecs(context.Background(), "different", "profile", "app"); err == nil || !strings.Contains(err.Error(), "read ContainerProfile") { + t.Fatal("missing namespaced source accepted") + } + if _, err := NewProfileResolver(nil).ResolveExecs(context.Background(), "ns", "profile", "app"); err == nil { + t.Fatal("nil client accepted") + } +} diff --git a/pkg/sandboxpolicy/status.go b/pkg/sandboxpolicy/status.go new file mode 100644 index 0000000000..bc7536faaf --- /dev/null +++ b/pkg/sandboxpolicy/status.go @@ -0,0 +1,160 @@ +package sandboxpolicy + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "unicode/utf8" + + api "github.com/kubescape/storage/pkg/apis/sandbox/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/dynamic" +) + +func (c *Controller) report(p desiredPolicy, failure error) *api.SandboxPolicyNodeStatus { + now := metav1.Now() + namespace := p.identity.Namespace + if p.identity.Kind == "ClusterSandboxPolicy" { + namespace = c.options.AgentNamespace + } + status := &api.NodeStatus{Tier: "bpf-lsm", ObservedGeneration: p.identity.Generation, LastReported: now} + ready := metav1.ConditionTrue + reason, message := "Reconciled", "No selected running containers" + active, stale := 0, false + unconfirmed := false + var cleanup *cleanupError + cleanupPending := errors.As(failure, &cleanup) + revisions := make(map[string]bool) + for _, installed := range c.active { + for _, compiled := range installed.plan.Policies { + if compiled.Identity.UID != p.identity.UID { + continue + } + if installed.protectionUnconfirmed { + unconfirmed = true + continue + } + active++ + revisions[fmt.Sprintf("generation=%d revision=%s", compiled.Identity.Generation, compiled.Revision)] = true + if compiled.Identity.Generation != p.identity.Generation { + stale = true + } + } + } + if active > 0 { + status.Tier = "bpf-lsm" + message = "Selected containers have active enforcement" + } + if p.spec.Mode == api.ModeOff { + message = "Policy is off" + } + var uncertain *protectionError + unconfirmed = unconfirmed || errors.As(failure, &uncertain) + if unconfirmed { + failure = errors.Join(failure, fmt.Errorf("owned enforcement protection cannot be confirmed; workload may be unprotected")) + } + generationStale := stale + if failure != nil { + ready = metav1.ConditionFalse + reason = "NotReady" + message = "Desired revision rejected; no last-good enforcement exists" + if active > 0 { + message = "Desired revision rejected; retaining existing last-good bindings; new bindings may be unprotected" + stale = true + } + if cleanupPending { + reason = "CleanupPending" + message = "A desired binding committed; reconciliation or previous-instance cleanup is incomplete; see active revisions" + stale = generationStale + } + var withdrawn *withdrawalCleanupError + if errors.As(failure, &withdrawn) { + reason = "WithdrawalCleanupPending" + message = "Binding withdrawal committed; enforcement removed for that binding, resource cleanup pending" + stale = generationStale + } + if unconfirmed { + reason = "ProtectionUnconfirmed" + message = "Owned binding protection cannot be confirmed; workload may be unprotected; ownership retained only for recovery/cleanup" + stale = true + } + status.Error, status.Truncated = boundedText(failure.Error(), api.MaxStatusErrorLength) + } + status.Stale = stale + status.Conditions = []metav1.Condition{{Type: "Ready", Status: ready, Reason: reason, Message: message, ObservedGeneration: p.identity.Generation, LastTransitionTime: now}} + if len(revisions) > 0 { + ordered := make([]string, 0, len(revisions)) + for revision := range revisions { + ordered = append(ordered, revision) + } + sort.Strings(ordered) + summary, truncated := boundedText(strings.Join(ordered, "; "), api.MaxStatusDiagnosticLength) + status.Truncated = status.Truncated || truncated + status.Conditions = append(status.Conditions, metav1.Condition{Type: "ActiveRevision", Status: metav1.ConditionTrue, Reason: "Installed", Message: summary, ObservedGeneration: p.identity.Generation, LastTransitionTime: now}) + } + + return &api.SandboxPolicyNodeStatus{ + TypeMeta: metav1.TypeMeta{APIVersion: api.SchemeGroupVersion.String(), Kind: "SandboxPolicyNodeStatus"}, + ObjectMeta: metav1.ObjectMeta{Name: api.NodeStatusName(p.identity.UID, c.options.Node.UID), Namespace: namespace, OwnerReferences: []metav1.OwnerReference{{APIVersion: api.SchemeGroupVersion.String(), Kind: p.identity.Kind, Name: p.identity.Name, UID: p.identity.UID}}}, + Spec: api.SandboxPolicyNodeStatusSpec{PolicyRef: api.SandboxPolicyReference{Kind: p.identity.Kind, Name: p.identity.Name, UID: p.identity.UID}, NodeRef: c.options.Node}, Status: status, + } +} + +func boundedText(value string, limit int) (string, bool) { + if utf8.RuneCountInString(value) <= limit { + return value, false + } + return string([]rune(value)[:limit]), true +} + +// DynamicStatusWriter only creates the UID-scoped report and writes its status +// subresource. It never edits policy parent aggregates. Conflicts are returned so +// the controller's periodic reconciliation can retry without hiding write loss. +type DynamicStatusWriter struct{ Client dynamic.Interface } + +func (w DynamicStatusWriter) WriteStatus(ctx context.Context, report *api.SandboxPolicyNodeStatus) error { + resource := w.Client.Resource(NodeStatusGVR).Namespace(report.Namespace) + existing, err := resource.Get(ctx, report.Name, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + initial := report.DeepCopy() + initial.Status = nil + raw, convertErr := runtime.DefaultUnstructuredConverter.ToUnstructured(initial) + if convertErr != nil { + return convertErr + } + existing, err = resource.Create(ctx, &unstructured.Unstructured{Object: raw}, metav1.CreateOptions{}) + } + if err != nil { + return err + } + var current api.SandboxPolicyNodeStatus + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(existing.Object, ¤t); err != nil { + return err + } + if current.Spec != report.Spec { + return fmt.Errorf("node report identity mismatch for %s", report.Name) + } + status := report.Status.DeepCopy() + // Preserve transition timestamps for stable conditions across heartbeats. + if current.Status != nil { + for i := range status.Conditions { + for _, previous := range current.Status.Conditions { + if previous.Type == status.Conditions[i].Type && previous.Status == status.Conditions[i].Status { + status.Conditions[i].LastTransitionTime = previous.LastTransitionTime + } + } + } + } + raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(status) + if err != nil { + return err + } + existing.Object["status"] = raw + _, err = resource.UpdateStatus(ctx, existing, metav1.UpdateOptions{}) + return err +} diff --git a/pkg/sandboxpolicy/status_test.go b/pkg/sandboxpolicy/status_test.go new file mode 100644 index 0000000000..2e9be7575e --- /dev/null +++ b/pkg/sandboxpolicy/status_test.go @@ -0,0 +1,76 @@ +package sandboxpolicy + +import ( + "context" + "errors" + "strings" + "testing" + + api "github.com/kubescape/storage/pkg/apis/sandbox/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestStatusIdentityBoundsAndUnprotected(t *testing.T) { + c, _, _, _ := setup(t) + p := parseTypedPolicy(t, policy("policy", "ns")) + r := c.report(p, errors.New(strings.Repeat("x", api.MaxStatusErrorLength+1))) + if r.Name != api.NodeStatusName("policy", "node-uid") || r.Spec.PolicyRef.UID != "policy" || r.Spec.NodeRef.UID != "node-uid" || len(r.OwnerReferences) != 1 || r.OwnerReferences[0].UID != "policy" { + t.Fatalf("identity/ownership: %+v", r) + } + if !r.Status.Truncated || len(r.Status.Error) != api.MaxStatusErrorLength || r.Status.Conditions[0].Status != metav1.ConditionFalse || !strings.Contains(r.Status.Conditions[0].Message, "no last-good") { + t.Fatalf("incorrect failure: %+v", r.Status) + } +} + +func parseTypedPolicy(t *testing.T, p *api.SandboxPolicy) desiredPolicy { + t.Helper() + raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(p) + if err != nil { + t.Fatal(err) + } + return parsePolicy(&unstructured.Unstructured{Object: raw}) +} + +func TestDynamicStatusWriterCreateHeartbeatAndIdentity(t *testing.T) { + ctx := context.Background() + c, _, _, client := setup(t) + r := c.report(parseTypedPolicy(t, policy("policy", "ns")), nil) + writer := DynamicStatusWriter{Client: client} + if err := writer.WriteStatus(ctx, r); err != nil { + t.Fatal(err) + } + current, err := client.Resource(NodeStatusGVR).Namespace("ns").Get(ctx, r.Name, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + var first api.SandboxPolicyNodeStatus + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(current.Object, &first); err != nil { + t.Fatal(err) + } + r.Status.Conditions[0].LastTransitionTime = metav1.Now() + if err := writer.WriteStatus(ctx, r); err != nil { + t.Fatal(err) + } + current, err = client.Resource(NodeStatusGVR).Namespace("ns").Get(ctx, r.Name, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + var second api.SandboxPolicyNodeStatus + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(current.Object, &second); err != nil { + t.Fatal(err) + } + if !first.Status.Conditions[0].LastTransitionTime.Equal(&second.Status.Conditions[0].LastTransitionTime) { + t.Fatal("heartbeat changed transition timestamp") + } + r.Spec.NodeRef.UID = "other" + if err := writer.WriteStatus(ctx, r); err == nil { + t.Fatal("accepted different report identity") + } + for _, action := range client.Actions() { + if action.GetVerb() == "update" && action.GetSubresource() != "status" { + t.Fatal("updated spec/parent instead of status") + } + } +} diff --git a/pkg/sandboxpolicy/types.go b/pkg/sandboxpolicy/types.go new file mode 100644 index 0000000000..752f184177 --- /dev/null +++ b/pkg/sandboxpolicy/types.go @@ -0,0 +1,168 @@ +// Package sandboxpolicy selects and compiles Kubernetes sandbox policies. It does +// not attach programs: Backend owns kernel capability validation and lifecycle. +package sandboxpolicy + +import ( + "context" + "time" + + api "github.com/kubescape/storage/pkg/apis/sandbox/v1alpha1" + "k8s.io/apimachinery/pkg/types" +) + +// Binding is an immutable runtime identity. CgroupID must be verified by the +// backend against the live container, not inferred from pod metadata. +type Binding struct { + Runtime string + CgroupPath string + PID uint32 + MountNamespace uint64 + ContainerID string + CgroupID uint64 + PodUID types.UID + Namespace string + PodName string + ContainerName string + // Kind is regular, init, or ephemeral. Wildcard policy selectors match regular only. + Kind string + PodLabels map[string]string + NamespaceLabels map[string]string +} + +type PolicyIdentity struct { + Kind string `json:"kind"` + Namespace string `json:"namespace,omitempty"` + Name string `json:"name"` + UID types.UID `json:"uid"` + Generation int64 `json:"generation"` +} + +// CompiledPolicy keeps policy-local role namespaces, mode overrides and defaults +// separate. Backends must implement deny-wins across policies, not first-match. +type CompiledPolicy struct { + Identity PolicyIdentity `json:"identity"` + Revision string `json:"revision"` + Mode api.PolicyMode `json:"mode"` + RootRole string `json:"rootRole"` + Exec api.ExecSection `json:"exec"` +} + +type Plan struct { + Revision string `json:"revision"` + Policies []CompiledPolicy `json:"policies"` +} + +// ReplaceResult distinguishes a failed candidate from a committed candidate whose +// old-instance cleanup failed. Committed results MUST identify the desired plan's +// exact revision. An error does not imply rollback after the commit boundary. +type ReplaceResult struct { + Committed bool + ActiveRevision string + // ProtectionUnconfirmed indicates externally lost/uncertain health or identity + // of owned enforcement, not an ordinary rejected candidate. Ownership remains + // for cleanup, but callers MUST NOT claim active or last-good protection. This + // flag does not relax the requirement to preserve healthy old enforcement when + // a candidate fails. A confirmed candidate/re-attestation clears it. + ProtectionUnconfirmed bool +} + +// WithdrawResult records the point after which this binding is no longer +// protected. Committed=true is required on success and after removal even if +// subsequent resource cleanup fails. +type WithdrawResult struct { + Committed bool + // A failed drain/retirement may leave membership present without healthy + // admission. Do not report that retained owner as confirmed last-good. + ProtectionUnconfirmed bool +} + +// Backend owns candidate readiness, immutable replacement and cleanup. Before +// commit, failure MUST leave the previous plan enforced and clean up the candidate. +// After commit, return Committed=true even if retiring old instances fails; the +// new plan is active but reconciliation remains NotReady until cleanup succeeds. +// Same-plan Replace retries must safely finish cleanup without resetting roles or +// creating gaps. Success requires Committed=true and ActiveRevision=Plan.Revision. +// Temporary overlap may overblock, but never introduce an enforcement gap. +// Unsupported predicates, limits and incomplete identities must return errors. +// Withdraw is idempotent and removes only this exact container/cgroup identity; +// stale deletion must never remove a newer registration. Before commit, errors +// retain enforcement; after commit, errors MUST return Committed=true. Repeated +// calls after removal finish cleanup without touching newer runtime identities. +type Backend interface { + Replace(context.Context, Binding, Plan) (ReplaceResult, error) + Withdraw(context.Context, Binding) (WithdrawResult, error) +} + +// ProtectionAttester optionally verifies an already-owned exact binding/revision. +// It must not install, update, resume, abort or publish any policy. Unsupported +// attestation leaves ownership retained but protection unconfirmed during discovery +// uncertainty; only an authoritative snapshot may authorize reconciliation again. +type ProtectionAttester interface { + Attest(context.Context, Binding, string) error +} + +// SourceResolver reads ContainerProfile execs in the selected pod's namespace. +// It must return an error for missing/incomplete profiles; empty successful results +// mean a known complete profile with no execs. Rules retain their predicates. +// The caller invokes Controller.Reconcile when referenced profile content changes. +type SourceResolver interface { + ResolveExecs(context.Context, string, string, string) ([]api.ExecRule, error) +} + +type StatusWriter interface { + WriteStatus(context.Context, *api.SandboxPolicyNodeStatus) error +} + +// PolicyRevision identifies one immutable policy applied to a decision. +type PolicyRevision struct { + Policy PolicyIdentity + Revision string +} + +// RuleMatch records a reported match, not necessarily a decisive rule. +type RuleMatch struct { + Policy PolicyIdentity + Revision string + Name string + Origin string + Action api.Action + Mode api.RuleMode +} + +// Verdict is independent of observer trace_exec: denied attempts are not process +// creation events. Optional fields remain absent when the backend cannot know them. +type Verdict struct { + // PolicySet contains every applied policy; Policy/Rule below are singular + // only when attribution is unambiguous. Matches may be partial on faults. + PolicySet []PolicyRevision + Matches []RuleMatch + // MatchesComplete is false for legacy events and operational failures. + MatchesComplete bool + Time time.Time + Policy PolicyIdentity + // Revision is always the aggregate Plan.Revision, independent of singular + // attribution. Policy-specific revisions belong to PolicySet and Matches. + Revision string + Rule string + Origin string + Mode api.RuleMode + Verdict api.Action + WouldDeny bool + Reason string + ContainerID string + PodUID types.UID + BindingPID uint32 + MountNamespace uint64 + CgroupID uint64 + PID uint32 + ParentPID uint32 + UID *uint32 + ActorPath string + TargetPath string + Argv []string + FromImage *bool +} + +type VerdictReceiver interface { + ReportVerdict(context.Context, Verdict) error +} diff --git a/pkg/utils/events.go b/pkg/utils/events.go index 73f8470c79..cdd8b83f87 100644 --- a/pkg/utils/events.go +++ b/pkg/utils/events.go @@ -276,6 +276,9 @@ const ( UnshareEventType EventType = "unshare" ) +// SandboxExecVerdictEventType identifies authorization attempts, not process creation. +const SandboxExecVerdictEventType EventType = "sandboxExecVerdict" + // Get the path of the file on the node. func GetHostFilePathFromEvent(event EnrichEvent, containerPid uint32) (string, error) { switch event.GetEventType() { From 7362e7fbdec72c0415f39c51588806a9b1ba95a4 Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Tue, 27 Oct 2026 12:51:47 +0100 Subject: [PATCH 02/23] build(sandboxpolicy): pin reviewed enforce-exec revision Signed-off-by: Matthias Bertschy --- docs/features/sandbox-policy.md | 3 ++- go.mod | 2 +- go.sum | 2 ++ pkg/sandboxpolicy/igbackend/README.md | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/features/sandbox-policy.md b/docs/features/sandbox-policy.md index 43eee26175..73bb738794 100644 --- a/docs/features/sandbox-policy.md +++ b/docs/features/sandbox-policy.md @@ -169,7 +169,8 @@ not sufficient completion evidence. The expanded IG revision is published as signed-off commit `fe1de461890489288685634d12821f976b16d0a6`, followed by the lint-only commit -`6b8b24afaf6f2fd9eeb1bcaa9f7a52f4f11e9ea1`, in IG PR #15. Node-agent pins the latter exact +`6b8b24afaf6f2fd9eeb1bcaa9f7a52f4f11e9ea1`, and review fixes through +`d715b98cb9415bf4ab5a014cfaeffa83563b0824`, in IG PR #15. Node-agent pins that exact public pseudo-version and passes targeted tests, race checks, vet and binary compilation with `GOWORK=off`. The repository's merge guard intentionally blocks this stacked dependency until IG #15 merges; re-pin to its merged main revision diff --git a/go.mod b/go.mod index 49feb26bff..1e1e9c825b 100644 --- a/go.mod +++ b/go.mod @@ -480,7 +480,7 @@ require ( zombiezen.com/go/sqlite v1.4.0 // indirect ) -replace github.com/inspektor-gadget/inspektor-gadget => github.com/kubescape/inspektor-gadget v0.0.0-20261027103046-6b8b24afaf6f +replace github.com/inspektor-gadget/inspektor-gadget => github.com/kubescape/inspektor-gadget v0.0.0-20261027114358-d715b98cb941 replace github.com/anchore/syft => github.com/kubescape/syft v1.32.0-ks.2 diff --git a/go.sum b/go.sum index f2a9438856..fba350e4ad 100644 --- a/go.sum +++ b/go.sum @@ -1222,6 +1222,8 @@ github.com/kubescape/inspektor-gadget v0.0.0-20261027101712-fe1de4618904 h1:wwkl github.com/kubescape/inspektor-gadget v0.0.0-20261027101712-fe1de4618904/go.mod h1:cwCFczq1LJ6Frpur0Vr5Ncic77a9ihki07Xpwzy+ItI= github.com/kubescape/inspektor-gadget v0.0.0-20261027103046-6b8b24afaf6f h1:ZSYQ1kf32Hc2Y3jib49tX8Cv7JTQcdWVuRUczJJfYcA= github.com/kubescape/inspektor-gadget v0.0.0-20261027103046-6b8b24afaf6f/go.mod h1:cwCFczq1LJ6Frpur0Vr5Ncic77a9ihki07Xpwzy+ItI= +github.com/kubescape/inspektor-gadget v0.0.0-20261027114358-d715b98cb941 h1:G0YKdOA9d+lqe7a8vJH+36PbBvEpsb/tYg+6iXGATIo= +github.com/kubescape/inspektor-gadget v0.0.0-20261027114358-d715b98cb941/go.mod h1:cwCFczq1LJ6Frpur0Vr5Ncic77a9ihki07Xpwzy+ItI= github.com/kubescape/k8s-interface v0.0.214 h1:j7KP0/5VvYOoQdBGV2+gRM3qnR8PWLAGF8RM/k/DmJ0= github.com/kubescape/k8s-interface v0.0.214/go.mod h1:WNYUG93aZ5kDmuaRKFLtVhp18Yc6EfaHdD1gLYtVTN4= github.com/kubescape/kubescape/v3 v3.0.4/go.mod h1:upPCVTCRT3+LuZ1bawGtjreRmr/Xa+LT0fHtPzlylRU= diff --git a/pkg/sandboxpolicy/igbackend/README.md b/pkg/sandboxpolicy/igbackend/README.md index bda588d433..c76934b54e 100644 --- a/pkg/sandboxpolicy/igbackend/README.md +++ b/pkg/sandboxpolicy/igbackend/README.md @@ -47,7 +47,7 @@ kubelet/API server or establish generic runtime/kernel compatibility. The exact narrow. Private OTel implementation is outside this public change. The expanded IG revision is published and pinned at -`v0.0.0-20261027103046-6b8b24afaf6f`; standalone node-agent targeted tests, race +`v0.0.0-20261027114358-d715b98cb941`; standalone node-agent targeted tests, race checks, vet and binary builds pass without a development workspace. The pin is still on PR #15 rather than main, so the repository merge guard correctly blocks consumer merging until the upstream change is merged and re-pinned. From c695a72ce8622c527ab6e16e14dd7b36da231fe8 Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Tue, 27 Oct 2026 13:17:00 +0100 Subject: [PATCH 03/23] fix(sandboxpolicy): address config review 5666688060 and refresh IG pin Signed-off-by: Matthias Bertschy --- docs/features/sandbox-policy.md | 2 +- go.mod | 2 +- go.sum | 2 ++ pkg/config/config.go | 2 ++ pkg/config/config_test.go | 21 +++++++++++++++++++++ pkg/sandboxpolicy/igbackend/README.md | 2 +- 6 files changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/features/sandbox-policy.md b/docs/features/sandbox-policy.md index 73bb738794..36a25aa7e2 100644 --- a/docs/features/sandbox-policy.md +++ b/docs/features/sandbox-policy.md @@ -170,7 +170,7 @@ not sufficient completion evidence. The expanded IG revision is published as signed-off commit `fe1de461890489288685634d12821f976b16d0a6`, followed by the lint-only commit `6b8b24afaf6f2fd9eeb1bcaa9f7a52f4f11e9ea1`, and review fixes through -`d715b98cb9415bf4ab5a014cfaeffa83563b0824`, in IG PR #15. Node-agent pins that exact +`c2aa29f9ca0d4dfa9300ffb9a7461a464b60f998`, in IG PR #15. Node-agent pins that exact public pseudo-version and passes targeted tests, race checks, vet and binary compilation with `GOWORK=off`. The repository's merge guard intentionally blocks this stacked dependency until IG #15 merges; re-pin to its merged main revision diff --git a/go.mod b/go.mod index 1e1e9c825b..aa37c06e34 100644 --- a/go.mod +++ b/go.mod @@ -480,7 +480,7 @@ require ( zombiezen.com/go/sqlite v1.4.0 // indirect ) -replace github.com/inspektor-gadget/inspektor-gadget => github.com/kubescape/inspektor-gadget v0.0.0-20261027114358-d715b98cb941 +replace github.com/inspektor-gadget/inspektor-gadget => github.com/kubescape/inspektor-gadget v0.0.0-20261027120845-c2aa29f9ca0d replace github.com/anchore/syft => github.com/kubescape/syft v1.32.0-ks.2 diff --git a/go.sum b/go.sum index fba350e4ad..48822390e3 100644 --- a/go.sum +++ b/go.sum @@ -1224,6 +1224,8 @@ github.com/kubescape/inspektor-gadget v0.0.0-20261027103046-6b8b24afaf6f h1:ZSYQ github.com/kubescape/inspektor-gadget v0.0.0-20261027103046-6b8b24afaf6f/go.mod h1:cwCFczq1LJ6Frpur0Vr5Ncic77a9ihki07Xpwzy+ItI= github.com/kubescape/inspektor-gadget v0.0.0-20261027114358-d715b98cb941 h1:G0YKdOA9d+lqe7a8vJH+36PbBvEpsb/tYg+6iXGATIo= github.com/kubescape/inspektor-gadget v0.0.0-20261027114358-d715b98cb941/go.mod h1:cwCFczq1LJ6Frpur0Vr5Ncic77a9ihki07Xpwzy+ItI= +github.com/kubescape/inspektor-gadget v0.0.0-20261027120845-c2aa29f9ca0d h1:ZI+hmRKzC9p+E8PjOvWz6DwD4ITn1cenFcg6E8cEXks= +github.com/kubescape/inspektor-gadget v0.0.0-20261027120845-c2aa29f9ca0d/go.mod h1:cwCFczq1LJ6Frpur0Vr5Ncic77a9ihki07Xpwzy+ItI= github.com/kubescape/k8s-interface v0.0.214 h1:j7KP0/5VvYOoQdBGV2+gRM3qnR8PWLAGF8RM/k/DmJ0= github.com/kubescape/k8s-interface v0.0.214/go.mod h1:WNYUG93aZ5kDmuaRKFLtVhp18Yc6EfaHdD1gLYtVTN4= github.com/kubescape/kubescape/v3 v3.0.4/go.mod h1:upPCVTCRT3+LuZ1bawGtjreRmr/Xa+LT0fHtPzlylRU= diff --git a/pkg/config/config.go b/pkg/config/config.go index 62e9abf8c9..473ecf268f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -226,6 +226,8 @@ func LoadConfigOptional(path string, errNotFound bool) (Config, error) { viper.SetDefault("sandboxPolicyEnabled", false) viper.SetDefault("sandboxPolicyImage", "") + viper.SetDefault("sandboxPolicyContainerdSocket", "") + viper.SetDefault("sandboxPolicyControlRoot", "") viper.SetDefault("sandboxPolicyMaxOwners", 4) viper.SetDefault("fullPathTracingEnabled", true) viper.SetDefault("initialDelay", 2*time.Minute) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 5ceea26a12..eb0904ae86 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1043,3 +1043,24 @@ func TestSandboxPolicyOptIn(t *testing.T) { }) } } + +func TestSandboxPolicyEnvironmentOnlyConfig(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + for name, value := range map[string]string{ + "SANDBOXPOLICYENABLED": "true", + "SANDBOXPOLICYIMAGE": "enforce_exec:test", + "SANDBOXPOLICYCONTAINERDSOCKET": "/run/containerd/containerd.sock", + "SANDBOXPOLICYCONTROLROOT": "/sys/fs/cgroup", + "SANDBOXPOLICYMAXOWNERS": "7", + } { + t.Setenv(name, value) + } + cfg, err := LoadConfigOptional(t.TempDir(), false) + require.NoError(t, err) + assert.True(t, cfg.EnableSandboxPolicy) + assert.Equal(t, "enforce_exec:test", cfg.SandboxPolicyImage) + assert.Equal(t, "/run/containerd/containerd.sock", cfg.SandboxPolicyContainerdSocket) + assert.Equal(t, "/sys/fs/cgroup", cfg.SandboxPolicyControlRoot) + assert.Equal(t, uint64(7), cfg.SandboxPolicyMaxOwners) +} diff --git a/pkg/sandboxpolicy/igbackend/README.md b/pkg/sandboxpolicy/igbackend/README.md index c76934b54e..878b5813b2 100644 --- a/pkg/sandboxpolicy/igbackend/README.md +++ b/pkg/sandboxpolicy/igbackend/README.md @@ -47,7 +47,7 @@ kubelet/API server or establish generic runtime/kernel compatibility. The exact narrow. Private OTel implementation is outside this public change. The expanded IG revision is published and pinned at -`v0.0.0-20261027114358-d715b98cb941`; standalone node-agent targeted tests, race +`v0.0.0-20261027120845-c2aa29f9ca0d`; standalone node-agent targeted tests, race checks, vet and binary builds pass without a development workspace. The pin is still on PR #15 rather than main, so the repository merge guard correctly blocks consumer merging until the upstream change is merged and re-pinned. From 1f11a406523cf2813b0c06903283722072337ffa Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Tue, 27 Oct 2026 13:36:42 +0100 Subject: [PATCH 04/23] build(sandboxpolicy): preserve upstream uprobe streaming in IG pin Signed-off-by: Matthias Bertschy --- docs/features/sandbox-policy.md | 4 +++- go.mod | 2 +- go.sum | 2 ++ pkg/sandboxpolicy/igbackend/README.md | 7 ++++--- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/features/sandbox-policy.md b/docs/features/sandbox-policy.md index 36a25aa7e2..b87dde3140 100644 --- a/docs/features/sandbox-policy.md +++ b/docs/features/sandbox-policy.md @@ -170,7 +170,9 @@ not sufficient completion evidence. The expanded IG revision is published as signed-off commit `fe1de461890489288685634d12821f976b16d0a6`, followed by the lint-only commit `6b8b24afaf6f2fd9eeb1bcaa9f7a52f4f11e9ea1`, and review fixes through -`c2aa29f9ca0d4dfa9300ffb9a7461a464b60f998`, in IG PR #15. Node-agent pins that exact +`c2aa29f9ca0d4dfa9300ffb9a7461a464b60f998`, in IG PR #15. The signed-off merge +`90147104198e91083298a132854d9de77d98091c` also incorporates IG main through #16, +preserving its ordinary-symbol streaming fix. Node-agent pins that exact public pseudo-version and passes targeted tests, race checks, vet and binary compilation with `GOWORK=off`. The repository's merge guard intentionally blocks this stacked dependency until IG #15 merges; re-pin to its merged main revision diff --git a/go.mod b/go.mod index aa37c06e34..4d244be212 100644 --- a/go.mod +++ b/go.mod @@ -480,7 +480,7 @@ require ( zombiezen.com/go/sqlite v1.4.0 // indirect ) -replace github.com/inspektor-gadget/inspektor-gadget => github.com/kubescape/inspektor-gadget v0.0.0-20261027120845-c2aa29f9ca0d +replace github.com/inspektor-gadget/inspektor-gadget => github.com/kubescape/inspektor-gadget v0.0.0-20261027123410-90147104198e replace github.com/anchore/syft => github.com/kubescape/syft v1.32.0-ks.2 diff --git a/go.sum b/go.sum index 48822390e3..6640d9d840 100644 --- a/go.sum +++ b/go.sum @@ -1226,6 +1226,8 @@ github.com/kubescape/inspektor-gadget v0.0.0-20261027114358-d715b98cb941 h1:G0YK github.com/kubescape/inspektor-gadget v0.0.0-20261027114358-d715b98cb941/go.mod h1:cwCFczq1LJ6Frpur0Vr5Ncic77a9ihki07Xpwzy+ItI= github.com/kubescape/inspektor-gadget v0.0.0-20261027120845-c2aa29f9ca0d h1:ZI+hmRKzC9p+E8PjOvWz6DwD4ITn1cenFcg6E8cEXks= github.com/kubescape/inspektor-gadget v0.0.0-20261027120845-c2aa29f9ca0d/go.mod h1:cwCFczq1LJ6Frpur0Vr5Ncic77a9ihki07Xpwzy+ItI= +github.com/kubescape/inspektor-gadget v0.0.0-20261027123410-90147104198e h1:qZNMJ2Fk/hsCNntbUoh+4WPEkpV6q+mjpZnuQdz7ZWs= +github.com/kubescape/inspektor-gadget v0.0.0-20261027123410-90147104198e/go.mod h1:cwCFczq1LJ6Frpur0Vr5Ncic77a9ihki07Xpwzy+ItI= github.com/kubescape/k8s-interface v0.0.214 h1:j7KP0/5VvYOoQdBGV2+gRM3qnR8PWLAGF8RM/k/DmJ0= github.com/kubescape/k8s-interface v0.0.214/go.mod h1:WNYUG93aZ5kDmuaRKFLtVhp18Yc6EfaHdD1gLYtVTN4= github.com/kubescape/kubescape/v3 v3.0.4/go.mod h1:upPCVTCRT3+LuZ1bawGtjreRmr/Xa+LT0fHtPzlylRU= diff --git a/pkg/sandboxpolicy/igbackend/README.md b/pkg/sandboxpolicy/igbackend/README.md index 878b5813b2..3054f5c6ed 100644 --- a/pkg/sandboxpolicy/igbackend/README.md +++ b/pkg/sandboxpolicy/igbackend/README.md @@ -47,9 +47,10 @@ kubelet/API server or establish generic runtime/kernel compatibility. The exact narrow. Private OTel implementation is outside this public change. The expanded IG revision is published and pinned at -`v0.0.0-20261027120845-c2aa29f9ca0d`; standalone node-agent targeted tests, race -checks, vet and binary builds pass without a development workspace. The pin is -still on PR #15 rather than main, so the repository merge guard correctly blocks +`v0.0.0-20261027123410-90147104198e`; standalone node-agent targeted tests, race +checks, vet and binary builds pass without a development workspace. This revision +also includes IG main through #16, preserving the ordinary-symbol streaming fix. +The pin is still on PR #15 rather than main, so the repository merge guard correctly blocks consumer merging until the upstream change is merged and re-pinned. No chart version has changed. No image is implicitly downloaded or trusted: the local OCI archive is privileged administrator-supplied code, and IG skips its From d68af2db1dc96e186f0a76b08e71ef42d1123860 Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Tue, 27 Oct 2026 14:01:18 +0100 Subject: [PATCH 05/23] build(sandboxpolicy): pin reviewed IG prefix and attachment fixes Signed-off-by: Matthias Bertschy --- docs/features/sandbox-policy.md | 4 +++- go.mod | 2 +- go.sum | 2 ++ pkg/sandboxpolicy/igbackend/README.md | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/features/sandbox-policy.md b/docs/features/sandbox-policy.md index b87dde3140..142943228d 100644 --- a/docs/features/sandbox-policy.md +++ b/docs/features/sandbox-policy.md @@ -172,7 +172,9 @@ The expanded IG revision is published as signed-off commit `6b8b24afaf6f2fd9eeb1bcaa9f7a52f4f11e9ea1`, and review fixes through `c2aa29f9ca0d4dfa9300ffb9a7461a464b60f998`, in IG PR #15. The signed-off merge `90147104198e91083298a132854d9de77d98091c` also incorporates IG main through #16, -preserving its ordinary-symbol streaming fix. Node-agent pins that exact +preserving its ordinary-symbol streaming fix. Follow-up review fixes through +`f9467312a221885cf144aa3289df3b0260af25e9` reject noncanonical exec prefixes +and make attachment initialization cancellable. Node-agent pins that exact public pseudo-version and passes targeted tests, race checks, vet and binary compilation with `GOWORK=off`. The repository's merge guard intentionally blocks this stacked dependency until IG #15 merges; re-pin to its merged main revision diff --git a/go.mod b/go.mod index 4d244be212..53fc14420e 100644 --- a/go.mod +++ b/go.mod @@ -480,7 +480,7 @@ require ( zombiezen.com/go/sqlite v1.4.0 // indirect ) -replace github.com/inspektor-gadget/inspektor-gadget => github.com/kubescape/inspektor-gadget v0.0.0-20261027123410-90147104198e +replace github.com/inspektor-gadget/inspektor-gadget => github.com/kubescape/inspektor-gadget v0.0.0-20261027125008-f9467312a221 replace github.com/anchore/syft => github.com/kubescape/syft v1.32.0-ks.2 diff --git a/go.sum b/go.sum index 6640d9d840..a62ef10144 100644 --- a/go.sum +++ b/go.sum @@ -1228,6 +1228,8 @@ github.com/kubescape/inspektor-gadget v0.0.0-20261027120845-c2aa29f9ca0d h1:ZI+h github.com/kubescape/inspektor-gadget v0.0.0-20261027120845-c2aa29f9ca0d/go.mod h1:cwCFczq1LJ6Frpur0Vr5Ncic77a9ihki07Xpwzy+ItI= github.com/kubescape/inspektor-gadget v0.0.0-20261027123410-90147104198e h1:qZNMJ2Fk/hsCNntbUoh+4WPEkpV6q+mjpZnuQdz7ZWs= github.com/kubescape/inspektor-gadget v0.0.0-20261027123410-90147104198e/go.mod h1:cwCFczq1LJ6Frpur0Vr5Ncic77a9ihki07Xpwzy+ItI= +github.com/kubescape/inspektor-gadget v0.0.0-20261027125008-f9467312a221 h1:6VyVDeMUYjDyidVuO/M3Xp6epsGZBYUIIdqpvyzjYuY= +github.com/kubescape/inspektor-gadget v0.0.0-20261027125008-f9467312a221/go.mod h1:cwCFczq1LJ6Frpur0Vr5Ncic77a9ihki07Xpwzy+ItI= github.com/kubescape/k8s-interface v0.0.214 h1:j7KP0/5VvYOoQdBGV2+gRM3qnR8PWLAGF8RM/k/DmJ0= github.com/kubescape/k8s-interface v0.0.214/go.mod h1:WNYUG93aZ5kDmuaRKFLtVhp18Yc6EfaHdD1gLYtVTN4= github.com/kubescape/kubescape/v3 v3.0.4/go.mod h1:upPCVTCRT3+LuZ1bawGtjreRmr/Xa+LT0fHtPzlylRU= diff --git a/pkg/sandboxpolicy/igbackend/README.md b/pkg/sandboxpolicy/igbackend/README.md index 3054f5c6ed..6d7ae4cd39 100644 --- a/pkg/sandboxpolicy/igbackend/README.md +++ b/pkg/sandboxpolicy/igbackend/README.md @@ -47,7 +47,7 @@ kubelet/API server or establish generic runtime/kernel compatibility. The exact narrow. Private OTel implementation is outside this public change. The expanded IG revision is published and pinned at -`v0.0.0-20261027123410-90147104198e`; standalone node-agent targeted tests, race +`v0.0.0-20261027125008-f9467312a221`; standalone node-agent targeted tests, race checks, vet and binary builds pass without a development workspace. This revision also includes IG main through #16, preserving the ordinary-symbol streaming fix. The pin is still on PR #15 rather than main, so the repository merge guard correctly blocks From eb5a409ed6edddbae3ac50d267d99ad933f0ae34 Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Tue, 27 Oct 2026 14:02:55 +0100 Subject: [PATCH 06/23] fix(sandboxpolicy): cancel stats waits on busy backend Signed-off-by: Matthias Bertschy --- pkg/sandboxpolicy/igbackend/fullbackend.go | 18 ++++++++- .../igbackend/kernelstats_test.go | 38 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/pkg/sandboxpolicy/igbackend/fullbackend.go b/pkg/sandboxpolicy/igbackend/fullbackend.go index 9b38dde146..1e0fe7b666 100644 --- a/pkg/sandboxpolicy/igbackend/fullbackend.go +++ b/pkg/sandboxpolicy/igbackend/fullbackend.go @@ -364,7 +364,23 @@ func (b *FullBackend) KernelEventStats(ctx context.Context) (KernelEventStats, e if err := ctx.Err(); err != nil { return KernelEventStats{}, err } - b.mu.Lock() + // Mutating backend operations may hold this mutex during native work. + // Poll only this read-only API so cancellation never leaves a lock waiter + // goroutine behind or delays sampler shutdown until enforcement completes. + if !b.mu.TryLock() { + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return KernelEventStats{}, ctx.Err() + case <-ticker.C: + } + if b.mu.TryLock() { + break + } + } + } defer b.mu.Unlock() if err := ctx.Err(); err != nil { return KernelEventStats{}, err diff --git a/pkg/sandboxpolicy/igbackend/kernelstats_test.go b/pkg/sandboxpolicy/igbackend/kernelstats_test.go index bf26d85b65..35acac8824 100644 --- a/pkg/sandboxpolicy/igbackend/kernelstats_test.go +++ b/pkg/sandboxpolicy/igbackend/kernelstats_test.go @@ -137,3 +137,41 @@ func TestNativeKernelStatsPartialStartupThroughJoinedStop(t *testing.T) { t.Fatal("missing stats interface reported zero loss") } } + +func TestKernelStatsCancellationWhileBackendBusy(t *testing.T) { + for _, deadline := range []bool{false, true} { + t.Run(map[bool]string{false: "cancel", true: "deadline"}[deadline], func(t *testing.T) { + instance := &counterTestInstance{fullTestInstance: &fullTestInstance{}} + backend := &FullBackend{records: map[identity]*fullRecord{{}: {instance: instance}}} + backend.mu.Lock() + defer backend.mu.Unlock() + ctx, cancel := context.WithCancel(context.Background()) + if deadline { + cancel() + ctx, cancel = context.WithTimeout(context.Background(), 20*time.Millisecond) + } + defer cancel() + result := make(chan error, 1) + go func() { _, err := backend.KernelEventStats(ctx); result <- err }() + if !deadline { + time.Sleep(20 * time.Millisecond) + cancel() + } + want := context.Canceled + if deadline { + want = context.DeadlineExceeded + } + select { + case err := <-result: + if !errors.Is(err, want) { + t.Fatalf("got %v, want %v", err, want) + } + case <-time.After(time.Second): + t.Fatal("stats did not cancel while backend mutex remained locked") + } + if instance.reads != 0 { + t.Fatal("canceled waiter read instance counters") + } + }) + } +} From ed7151b10fc48f3e0bcfcc39181c20b6d11824f5 Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Tue, 27 Oct 2026 14:17:31 +0100 Subject: [PATCH 07/23] fix(sandboxpolicy): validate retained health and discovered cgroup membership Address review 5667584645: attest protection after failed withdrawal and use IG cgroup-v2 membership rather than its mount-prefixed path. Signed-off-by: Matthias Bertschy --- pkg/sandboxpolicy/igbackend/backend.go | 20 +++++++- pkg/sandboxpolicy/igbackend/backend_test.go | 48 +++++++++++++++++++ pkg/sandboxpolicy/integration/bindings.go | 6 ++- .../integration/bindings_test.go | 19 ++++++-- 4 files changed, 86 insertions(+), 7 deletions(-) diff --git a/pkg/sandboxpolicy/igbackend/backend.go b/pkg/sandboxpolicy/igbackend/backend.go index 0e25bd6e94..14aaecce76 100644 --- a/pkg/sandboxpolicy/igbackend/backend.go +++ b/pkg/sandboxpolicy/igbackend/backend.go @@ -211,7 +211,7 @@ func (b *Backend) Replace(ctx context.Context, binding sandboxpolicy.Binding, pl return result, cleanup(ctx, r) } -func (b *Backend) Withdraw(ctx context.Context, binding sandboxpolicy.Binding) (sandboxpolicy.WithdrawResult, error) { +func (b *Backend) Withdraw(ctx context.Context, binding sandboxpolicy.Binding) (result sandboxpolicy.WithdrawResult, resultErr error) { b.mu.Lock() defer b.mu.Unlock() k := key(binding) @@ -219,6 +219,22 @@ func (b *Backend) Withdraw(ctx context.Context, binding sandboxpolicy.Binding) ( if r == nil { return sandboxpolicy.WithdrawResult{Committed: true}, nil } + // An incomplete withdrawal retains ownership, not necessarily protection. + // Re-attest after either cleanup or unbind fails, as either operation may + // race native failure. A retiring-only record has no confirmed active owner. + defer func() { + if result.Committed { + return + } + health := fmt.Errorf("no active enforcement owner") + if r.active != nil { + health = errors.Join(r.active.lease.Check(), r.active.instance.Ready()) + } + result.ProtectionUnconfirmed = health != nil + if health != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("existing enforcement protection cannot be confirmed: %w", health)) + } + }() // Retired instances may still enforce during failed cleanup. Do not report // withdrawal committed while any of those old links may protect the binding. if err := cleanup(ctx, r); err != nil { @@ -231,7 +247,7 @@ func (b *Backend) Withdraw(ctx context.Context, binding sandboxpolicy.Binding) ( r.retiring = append(r.retiring, r.active) r.active = nil } - result := sandboxpolicy.WithdrawResult{Committed: true} + result = sandboxpolicy.WithdrawResult{Committed: true} if err := cleanup(ctx, r); err != nil { return result, err } diff --git a/pkg/sandboxpolicy/igbackend/backend_test.go b/pkg/sandboxpolicy/igbackend/backend_test.go index d9ea4165fa..ddd0893020 100644 --- a/pkg/sandboxpolicy/igbackend/backend_test.go +++ b/pkg/sandboxpolicy/igbackend/backend_test.go @@ -468,3 +468,51 @@ func TestChangedRuntimeIdentityDoesNotHideLostPriorOwner(t *testing.T) { t.Fatalf("identity conflict hid lost prior protection: %+v %v", result, err) } } + +func TestWithdrawalFailureChecksRetainedProtection(t *testing.T) { + for _, stage := range []string{"cleanup", "unbind"} { + for _, health := range []string{"healthy", "not-ready", "lease-lost"} { + t.Run(stage+"/"+health, func(t *testing.T) { + b, e, v, binding, plan := fixture(t) + ctx := context.Background() + if _, err := b.Replace(ctx, binding, plan); err != nil { + t.Fatal(err) + } + active, lease := e.instances[0], v.leases[0] + operationErr := errors.New("withdrawal failed") + healthErr := errors.New("retained protection lost") + if stage == "cleanup" { + retired := &fakeInstance{stopErr: operationErr, readyErr: healthErr} + b.records[key(binding)].retiring = []*owned{{instance: retired, lease: &fakeLease{}}} + } else { + active.unbindErr = operationErr + } + if health == "not-ready" { + active.readyErr = healthErr + } + if health == "lease-lost" { + lease.checkErr = healthErr + } + result, err := b.Withdraw(ctx, binding) + if result.Committed || result.ProtectionUnconfirmed != (health != "healthy") || !errors.Is(err, operationErr) { + t.Fatalf("wrong withdrawal result: %+v %v", result, err) + } + if health != "healthy" && !errors.Is(err, healthErr) { + t.Fatalf("health failure missing: %v", err) + } + if b.records[key(binding)].active == nil || lease.closed != 0 { + t.Fatal("failed withdrawal lost retained owner") + } + }) + } + } +} + +func TestWithdrawalRetiringOnlyDoesNotClaimProtection(t *testing.T) { + b, _, _, binding, _ := fixture(t) + b.records[key(binding)] = &record{retiring: []*owned{{instance: &fakeInstance{stopErr: failure}, lease: &fakeLease{}}}} + result, err := b.Withdraw(context.Background(), binding) + if result.Committed || !result.ProtectionUnconfirmed || !errors.Is(err, failure) { + t.Fatalf("retiring ownership reported as confirmed protection: %+v %v", result, err) + } +} diff --git a/pkg/sandboxpolicy/integration/bindings.go b/pkg/sandboxpolicy/integration/bindings.go index 4b726a1e9e..8efcf4c8bb 100644 --- a/pkg/sandboxpolicy/integration/bindings.go +++ b/pkg/sandboxpolicy/integration/bindings.go @@ -150,7 +150,7 @@ func snapshotBindings(ctx context.Context, client kubernetes.Interface, containe if container.K8s.Namespace != binding.Namespace || container.K8s.PodName != binding.PodName || container.K8s.PodUID != string(binding.PodUID) || container.K8s.ContainerName != binding.ContainerName { return nil, fmt.Errorf("runtime/Kubernetes metadata mismatch for %s", key) } - if container.CgroupID == 0 || container.Runtime.ContainerPID == 0 || container.Mntns == 0 || container.CgroupPath == "" || !path.IsAbs(container.CgroupPath) || path.Clean(container.CgroupPath) != container.CgroupPath || container.CgroupPath == "/" { + if container.CgroupID == 0 || container.Runtime.ContainerPID == 0 || container.Mntns == 0 || container.CgroupV2 == "" || !path.IsAbs(container.CgroupV2) || path.Clean(container.CgroupV2) != container.CgroupV2 || container.CgroupV2 == "/" { return nil, fmt.Errorf("live Kubernetes container %s lacks a strict cgroup/PID/mount identity", key) } if previous, duplicate := cgroups[container.CgroupID]; duplicate { @@ -158,7 +158,9 @@ func snapshotBindings(ctx context.Context, client kubernetes.Interface, containe } cgroups[container.CgroupID] = key binding.CgroupID = container.CgroupID - binding.CgroupPath = container.CgroupPath + // IG CgroupPath includes the mountpoint; strict owners need the cgroup-v2 + // membership path from /proc//cgroup, relative to their cgroup root. + binding.CgroupPath = container.CgroupV2 binding.PID = container.Runtime.ContainerPID binding.MountNamespace = container.Mntns result = append(result, binding) diff --git a/pkg/sandboxpolicy/integration/bindings_test.go b/pkg/sandboxpolicy/integration/bindings_test.go index ae37aba411..203472deeb 100644 --- a/pkg/sandboxpolicy/integration/bindings_test.go +++ b/pkg/sandboxpolicy/integration/bindings_test.go @@ -31,7 +31,7 @@ func bindingFixtures() (*corev1.Pod, *corev1.Namespace, []*containercollection.C case 2: pod.Status.EphemeralContainerStatuses = []corev1.ContainerStatus{status} } - c := &containercollection.Container{CgroupID: uint64(100 + i), CgroupPath: "/kubepods/" + id, Mntns: uint64(1000 + i)} + c := &containercollection.Container{CgroupID: uint64(100 + i), CgroupPath: "/sys/fs/cgroup/kubepods/" + id, CgroupV2: "/kubepods/" + id, Mntns: uint64(1000 + i)} c.Runtime.RuntimeName = gadgettypes.RuntimeNameContainerd c.Runtime.ContainerID = id c.Runtime.ContainerPID = uint32(10000 + i) @@ -55,7 +55,7 @@ func TestSnapshotBindingsCorrelatesExactRunningIdentities(t *testing.T) { t.Fatalf("want three container kinds, got %d", len(bindings)) } for i, b := range bindings { - if b.Kind != []string{"regular", "init", "ephemeral"}[i] || b.PodUID != "pod-uid" || b.Runtime != "containerd" || b.ContainerID != containers[i].Runtime.ContainerID || b.CgroupID != containers[i].CgroupID || b.CgroupPath != containers[i].CgroupPath || b.PID != containers[i].Runtime.ContainerPID || b.MountNamespace != containers[i].Mntns || b.PodLabels["app"] != "current" || b.NamespaceLabels["team"] != "red" { + if b.Kind != []string{"regular", "init", "ephemeral"}[i] || b.PodUID != "pod-uid" || b.Runtime != "containerd" || b.ContainerID != containers[i].Runtime.ContainerID || b.CgroupID != containers[i].CgroupID || b.CgroupPath != containers[i].CgroupV2 || b.PID != containers[i].Runtime.ContainerPID || b.MountNamespace != containers[i].Mntns || b.PodLabels["app"] != "current" || b.NamespaceLabels["team"] != "red" { t.Fatalf("incorrect binding: %+v", b) } } @@ -78,7 +78,7 @@ func TestSnapshotRejectsDiscoveryGapsWithoutPartialBindings(t *testing.T) { return c }, "root cgroup": func(_ *corev1.Pod, _ *corev1.Namespace, c []*containercollection.Container) []*containercollection.Container { - c[1].CgroupPath = "/" + c[1].CgroupV2 = "/" return c }, "missing PID": func(_ *corev1.Pod, _ *corev1.Namespace, c []*containercollection.Container) []*containercollection.Container { @@ -244,3 +244,16 @@ func TestSelectedSnapshotSelectsBeforeRequiringRuntimeIdentity(t *testing.T) { t.Fatalf("no selected policies requires no binding: %v", err) } } + +func TestSnapshotRejectsInvalidMembershipDespiteMountedPath(t *testing.T) { + for _, membership := range []string{"", "relative", "/", "/kubepods/../other", "/kubepods//target"} { + t.Run(membership, func(t *testing.T) { + p, ns, containers := bindingFixtures() + containers[0].CgroupV2 = membership + got, err := SnapshotBindingsFromContainers(context.Background(), fake.NewSimpleClientset(p, ns), containers, "node") + if err == nil || got != nil { + t.Fatalf("invalid membership accepted: %+v %v", got, err) + } + }) + } +} From 1d99df5fe1151d5a8358d8d29253a9c4cad7e52f Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Tue, 27 Oct 2026 14:50:04 +0100 Subject: [PATCH 08/23] build(sandboxpolicy): pin reviewed exact Kubernetes kernel matrix Signed-off-by: Matthias Bertschy --- docs/features/sandbox-policy.md | 8 ++++++-- go.mod | 2 +- go.sum | 2 ++ pkg/sandboxpolicy/igbackend/README.md | 7 +++++-- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/features/sandbox-policy.md b/docs/features/sandbox-policy.md index 142943228d..bc13f17e9b 100644 --- a/docs/features/sandbox-policy.md +++ b/docs/features/sandbox-policy.md @@ -174,8 +174,12 @@ The expanded IG revision is published as signed-off commit `90147104198e91083298a132854d9de77d98091c` also incorporates IG main through #16, preserving its ordinary-symbol streaming fix. Follow-up review fixes through `f9467312a221885cf144aa3289df3b0260af25e9` reject noncanonical exec prefixes -and make attachment initialization cancellable. Node-agent pins that exact -public pseudo-version and passes targeted tests, race checks, vet and binary +and make attachment initialization cancellable. The reviewed revision +`87a796fe97803ec6788e985e6570efea46124fba` additionally admits only the exact +Kubernetes-capable kernel BTF proved by the full predicate, persistent OCI and +authenticated CRI matrix, while retaining the original artifact. Private +Kubernetes/OTLP integration remains separate and pending. Node-agent pins that +exact public pseudo-version and passes targeted tests, race checks, vet and binary compilation with `GOWORK=off`. The repository's merge guard intentionally blocks this stacked dependency until IG #15 merges; re-pin to its merged main revision before merging the consumer change. Do not bypass that guard. diff --git a/go.mod b/go.mod index 53fc14420e..7296dfab87 100644 --- a/go.mod +++ b/go.mod @@ -480,7 +480,7 @@ require ( zombiezen.com/go/sqlite v1.4.0 // indirect ) -replace github.com/inspektor-gadget/inspektor-gadget => github.com/kubescape/inspektor-gadget v0.0.0-20261027125008-f9467312a221 +replace github.com/inspektor-gadget/inspektor-gadget => github.com/kubescape/inspektor-gadget v0.0.0-20261027133411-87a796fe9780 replace github.com/anchore/syft => github.com/kubescape/syft v1.32.0-ks.2 diff --git a/go.sum b/go.sum index a62ef10144..d9cd14fcc0 100644 --- a/go.sum +++ b/go.sum @@ -1230,6 +1230,8 @@ github.com/kubescape/inspektor-gadget v0.0.0-20261027123410-90147104198e h1:qZNM github.com/kubescape/inspektor-gadget v0.0.0-20261027123410-90147104198e/go.mod h1:cwCFczq1LJ6Frpur0Vr5Ncic77a9ihki07Xpwzy+ItI= github.com/kubescape/inspektor-gadget v0.0.0-20261027125008-f9467312a221 h1:6VyVDeMUYjDyidVuO/M3Xp6epsGZBYUIIdqpvyzjYuY= github.com/kubescape/inspektor-gadget v0.0.0-20261027125008-f9467312a221/go.mod h1:cwCFczq1LJ6Frpur0Vr5Ncic77a9ihki07Xpwzy+ItI= +github.com/kubescape/inspektor-gadget v0.0.0-20261027133411-87a796fe9780 h1:Z0YwWklWLqf6b/8YXfqsleZXBfQY482LFzuDAfVX+Pw= +github.com/kubescape/inspektor-gadget v0.0.0-20261027133411-87a796fe9780/go.mod h1:cwCFczq1LJ6Frpur0Vr5Ncic77a9ihki07Xpwzy+ItI= github.com/kubescape/k8s-interface v0.0.214 h1:j7KP0/5VvYOoQdBGV2+gRM3qnR8PWLAGF8RM/k/DmJ0= github.com/kubescape/k8s-interface v0.0.214/go.mod h1:WNYUG93aZ5kDmuaRKFLtVhp18Yc6EfaHdD1gLYtVTN4= github.com/kubescape/kubescape/v3 v3.0.4/go.mod h1:upPCVTCRT3+LuZ1bawGtjreRmr/Xa+LT0fHtPzlylRU= diff --git a/pkg/sandboxpolicy/igbackend/README.md b/pkg/sandboxpolicy/igbackend/README.md index 6d7ae4cd39..2133579ef9 100644 --- a/pkg/sandboxpolicy/igbackend/README.md +++ b/pkg/sandboxpolicy/igbackend/README.md @@ -47,9 +47,12 @@ kubelet/API server or establish generic runtime/kernel compatibility. The exact narrow. Private OTel implementation is outside this public change. The expanded IG revision is published and pinned at -`v0.0.0-20261027125008-f9467312a221`; standalone node-agent targeted tests, race +`v0.0.0-20261027133411-87a796fe9780`; standalone node-agent targeted tests, race checks, vet and binary builds pass without a development workspace. This revision -also includes IG main through #16, preserving the ordinary-symbol streaming fix. +also includes IG main through #16, preserving the ordinary-symbol streaming fix, +and the individually proved Kubernetes-capable exact BTF artifact. Its isolated +upstream predicate/persistent OCI/CRI proof does not replace the still-pending +private Kubernetes/OTLP integration proof. The pin is still on PR #15 rather than main, so the repository merge guard correctly blocks consumer merging until the upstream change is merged and re-pinned. No chart version has changed. No image is implicitly downloaded or trusted: the From c19e804909da055c2b224849d1c78a537bf52f4e Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Tue, 27 Oct 2026 14:59:27 +0100 Subject: [PATCH 09/23] fix(sandboxpolicy): keep service diagnostic logging readiness-neutral Address the follow-up finding in review 5668222661 without inferring enforcement readiness from telemetry diagnostics. Signed-off-by: Matthias Bertschy --- cmd/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/main.go b/cmd/main.go index d488a6e60e..3886794e10 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -580,7 +580,7 @@ func main() { ContainerdSocket: cfg.SandboxPolicyContainerdSocket, ControlRoot: cfg.SandboxPolicyControlRoot, MaxOwners: cfg.SandboxPolicyMaxOwners, Sources: sandboxintegration.NewProfileResolver(storageClient.GetStorageClient()), Receivers: thirdPartyTracers.ThirdPartyEventReceivers, - OnError: func(err error) { logger.L().Ctx(ctx).Error("sandbox policy NotReady", helpers.Error(err)) }, + OnError: func(err error) { logger.L().Ctx(ctx).Error("sandbox policy service diagnostic", helpers.Error(err)) }, }) if sandboxErr != nil { logger.L().Ctx(ctx).Fatal("creating sandbox policy service", helpers.Error(sandboxErr)) From 3ca76d85755670b5d180438aba274ecb2a4c9c38 Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Tue, 27 Oct 2026 15:13:38 +0100 Subject: [PATCH 10/23] fix(sandboxpolicy): retain legacy binding metadata across lifecycle Address review 5668369317: reject contradictory metadata before reusing legacy owners, ignore stale metadata withdrawals, and preserve authoritative binding metadata during Close. Signed-off-by: Matthias Bertschy --- pkg/sandboxpolicy/igbackend/backend.go | 20 ++++--- pkg/sandboxpolicy/igbackend/backend_test.go | 62 ++++++++++++++++++++- 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/pkg/sandboxpolicy/igbackend/backend.go b/pkg/sandboxpolicy/igbackend/backend.go index 14aaecce76..dfeaf16ac2 100644 --- a/pkg/sandboxpolicy/igbackend/backend.go +++ b/pkg/sandboxpolicy/igbackend/backend.go @@ -55,6 +55,7 @@ func native(b sandboxpolicy.Binding) execruntime.ContainerIdentity { } type record struct { + binding sandboxpolicy.Binding revision string active *owned retiring []*owned @@ -118,6 +119,9 @@ func (b *Backend) Replace(ctx context.Context, binding sandboxpolicy.Binding, pl } }() r := b.records[k] + if r != nil && !sameFullBindingMetadata(r.binding, binding) { + return result, fmt.Errorf("binding metadata changed for legacy identity") + } if r != nil { // Finish previous cleanup before a new plan can be installed. Never restart // an already committed same-revision instance just to retry cleanup. @@ -193,14 +197,14 @@ func (b *Backend) Replace(ctx context.Context, binding sandboxpolicy.Binding, pl next := &owned{instance: candidate, lease: lease} if err := errors.Join(lease.Check(), candidate.Ready()); err != nil { if r == nil { - r = &record{} + r = &record{binding: binding} b.records[k] = r } r.retiring = append(r.retiring, next) return result, errors.Join(err, cleanup(ctx, r)) } if r == nil { - r = &record{} + r = &record{binding: binding} b.records[k] = r } if r.active != nil { @@ -216,7 +220,7 @@ func (b *Backend) Withdraw(ctx context.Context, binding sandboxpolicy.Binding) ( defer b.mu.Unlock() k := key(binding) r := b.records[k] - if r == nil { + if r == nil || !sameFullBindingMetadata(r.binding, binding) { return sandboxpolicy.WithdrawResult{Committed: true}, nil } // An incomplete withdrawal retains ownership, not necessarily protection. @@ -309,14 +313,14 @@ func Compile(binding sandboxpolicy.Binding, plan sandboxpolicy.Plan, generation func (b *Backend) Close(ctx context.Context) error { b.mu.Lock() b.closed = true - keys := make([]identity, 0, len(b.records)) - for k := range b.records { - keys = append(keys, k) + bindings := make([]sandboxpolicy.Binding, 0, len(b.records)) + for _, r := range b.records { + bindings = append(bindings, r.binding) } b.mu.Unlock() var errs []error - for _, k := range keys { - _, err := b.Withdraw(ctx, sandboxpolicy.Binding{Runtime: k.runtime, ContainerID: k.container, CgroupPath: k.path, CgroupID: k.cgroup, MountNamespace: k.mountns, PID: k.pid}) + for _, binding := range bindings { + _, err := b.Withdraw(ctx, binding) errs = append(errs, err) } return errors.Join(errs...) diff --git a/pkg/sandboxpolicy/igbackend/backend_test.go b/pkg/sandboxpolicy/igbackend/backend_test.go index ddd0893020..bb7bf431c5 100644 --- a/pkg/sandboxpolicy/igbackend/backend_test.go +++ b/pkg/sandboxpolicy/igbackend/backend_test.go @@ -276,7 +276,7 @@ func TestOwnedIdentityCapacityIncludesRetiringRecords(t *testing.T) { for i := 0; i < execpolicy.KernelMaxBindings; i++ { instance := &fakeInstance{} owner := &owned{instance: instance, lease: &fakeLease{}} - state := &record{revision: "old"} + state := &record{binding: bindingAt(i), revision: "old"} if i%2 == 0 { state.retiring = []*owned{owner} instance.stopErr = failure @@ -510,9 +510,67 @@ func TestWithdrawalFailureChecksRetainedProtection(t *testing.T) { func TestWithdrawalRetiringOnlyDoesNotClaimProtection(t *testing.T) { b, _, _, binding, _ := fixture(t) - b.records[key(binding)] = &record{retiring: []*owned{{instance: &fakeInstance{stopErr: failure}, lease: &fakeLease{}}}} + b.records[key(binding)] = &record{binding: binding, retiring: []*owned{{instance: &fakeInstance{stopErr: failure}, lease: &fakeLease{}}}} result, err := b.Withdraw(context.Background(), binding) if result.Committed || !result.ProtectionUnconfirmed || !errors.Is(err, failure) { t.Fatalf("retiring ownership reported as confirmed protection: %+v %v", result, err) } } + +func TestLegacyBindingMetadataCannotReuseOrWithdrawOwner(t *testing.T) { + for name, mutate := range map[string]func(*sandboxpolicy.Binding){ + "pod UID": func(b *sandboxpolicy.Binding) { b.PodUID = "stale-uid" }, + "namespace": func(b *sandboxpolicy.Binding) { b.Namespace = "other-ns" }, + "pod name": func(b *sandboxpolicy.Binding) { b.PodName = "other-pod" }, + "container name": func(b *sandboxpolicy.Binding) { b.ContainerName = "other-container" }, + "kind": func(b *sandboxpolicy.Binding) { b.Kind = "init" }, + } { + t.Run(name, func(t *testing.T) { + b, e, v, binding, plan := fixture(t) + binding.PodUID, binding.Namespace, binding.PodName, binding.ContainerName, binding.Kind = "current-uid", "ns", "pod", "app", "regular" + ctx := context.Background() + if _, err := b.Replace(ctx, binding, plan); err != nil { + t.Fatal(err) + } + retired := &fakeInstance{} + b.records[key(binding)].retiring = []*owned{{instance: retired, lease: &fakeLease{}}} + stale := binding + mutate(&stale) + for _, revision := range []string{plan.Revision, "next"} { + candidate := plan + candidate.Revision = revision + result, err := b.Replace(ctx, stale, candidate) + if err == nil || result.Committed { + t.Fatalf("contradictory metadata reused owner: %+v %v", result, err) + } + } + result, err := b.Withdraw(ctx, stale) + if err != nil || !result.Committed { + t.Fatalf("stale withdrawal not ignored: %+v %v", result, err) + } + if len(e.instances) != 1 || e.instances[0].stops != 0 || e.instances[0].unbound != 0 || v.leases[0].closed != 0 || retired.stops != 0 { + t.Fatal("stale metadata mutated the current owner") + } + if err := b.Close(ctx); err != nil { + t.Fatal(err) + } + if e.instances[0].unbound != 1 || e.instances[0].stops != 1 || v.leases[0].closed != 1 || len(b.records) != 0 { + t.Fatal("Close lost authoritative binding metadata") + } + }) + } +} + +func TestLegacyStalePodWithdrawalPreservesOwner(t *testing.T) { + b, e, v, binding, plan := fixture(t) + binding.PodUID = "current" + if _, err := b.Replace(context.Background(), binding, plan); err != nil { + t.Fatal(err) + } + stale := binding + stale.PodUID = "old" + result, err := b.Withdraw(context.Background(), stale) + if err != nil || !result.Committed || e.instances[0].unbound != 0 || e.instances[0].stops != 0 || v.leases[0].closed != 0 || len(b.records) != 1 { + t.Fatalf("stale pod withdrew live protection: %+v %v", result, err) + } +} From e19c3c09b57a08d0c9c4bb005608ad341bd119b5 Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Tue, 27 Oct 2026 16:07:05 +0100 Subject: [PATCH 11/23] fix(watcher): treat stopped event queues as normal shutdown Signed-off-by: Matthias Bertschy --- pkg/watcher/dynamicwatcher/shutdown_test.go | 72 +++++++++++++++++++++ pkg/watcher/dynamicwatcher/watch.go | 1 + 2 files changed, 73 insertions(+) create mode 100644 pkg/watcher/dynamicwatcher/shutdown_test.go diff --git a/pkg/watcher/dynamicwatcher/shutdown_test.go b/pkg/watcher/dynamicwatcher/shutdown_test.go new file mode 100644 index 0000000000..3d5950b4dd --- /dev/null +++ b/pkg/watcher/dynamicwatcher/shutdown_test.go @@ -0,0 +1,72 @@ +package dynamicwatcher + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/kubescape/k8s-interface/k8sinterface" + "github.com/kubescape/node-agent/pkg/cooldownqueue" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" +) + +// A subprocess keeps the regression safe even when watchRetry calls os.Exit. +// Stop is deterministic: it precedes delivery of the next watch event/closure. +func TestStoppedQueueDoesNotExitProcess(t *testing.T) { + if mode := os.Getenv("NODE_AGENT_WATCH_STOP_TEST"); mode != "" { + q := cooldownqueue.NewCooldownQueue[watch.Event](time.Hour, time.Hour) + defer q.Stop() + w := watch.NewRaceFreeFake() + client := fake.NewClientset() + calls := 0 + client.PrependWatchReactor("pods", func(ktesting.Action) (bool, watch.Interface, error) { + calls++ + if mode == "retry-then-stop" && calls == 1 { + closed := watch.NewRaceFreeFake() + closed.Stop() + return true, closed, nil // Unexpected stream closure must still retry. + } + q.Stop() + if mode == "late-event" { + w.Add(&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "late", ResourceVersion: "42"}}) + } else { + w.Stop() + } + return true, w, nil + }) + wh := NewWatchHandler(&k8sinterface.KubernetesApi{KubernetesClient: client}, nil, func(string) bool { return false }) + defer fmt.Println("normal shutdown cleanup ran") + wh.watchRetry(context.Background(), resourcePod, metav1.ListOptions{}, q) + wantCalls := 1 + if mode == "retry-then-stop" { + wantCalls = 2 + } + if calls != wantCalls || !w.IsStopped() { + t.Fatalf("stopped queue retried or left watch running: calls=%d stopped=%t", calls, w.IsStopped()) + } + return + } + for _, mode := range []string{"late-event", "closed-stream", "retry-then-stop"} { + t.Run(mode, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestStoppedQueueDoesNotExitProcess$", "-test.count=1") + cmd.Env = append(os.Environ(), "NODE_AGENT_WATCH_STOP_TEST="+mode) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("normal queue shutdown terminated process: %v\n%s", err, output) + } + if !strings.Contains(string(output), "normal shutdown cleanup ran") { + t.Fatalf("shutdown skipped deferred cleanup: %s", output) + } + }) + } +} diff --git a/pkg/watcher/dynamicwatcher/watch.go b/pkg/watcher/dynamicwatcher/watch.go index 4a621e9b4c..efa1d4663c 100644 --- a/pkg/watcher/dynamicwatcher/watch.go +++ b/pkg/watcher/dynamicwatcher/watch.go @@ -222,6 +222,7 @@ func (wh *WatchHandler) watchRetry(_ context.Context, res schema.GroupVersionRes watchOpts.ResourceVersion = metaObject.GetResourceVersion() } if eventQueue.Closed() { + exitFatal = false // Intentional shutdown must not bypass deferred cleanup. w.Stop() return backoff.Permanent(errors.New("event queue closed")) } From 1769ac1344e967ce441dbd1af39cd761a32c1dac Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Tue, 27 Oct 2026 18:17:50 +0100 Subject: [PATCH 12/23] refactor(sandbox): name exec authorization events explicitly Rename the internal event, constructor and dispatch key without routing authorization through exec observation processing. Document operation-specific event contracts and the shared metadata extraction boundary for a second enforcement gadget. Signed-off-by: Matthias Bertschy --- docs/features/sandbox-policy.md | 39 +++++++++++- pkg/sandboxpolicy/exporter.go | 99 +++++++++++++++--------------- pkg/sandboxpolicy/exporter_test.go | 54 ++++++++++++---- pkg/utils/events.go | 5 +- 4 files changed, 132 insertions(+), 65 deletions(-) diff --git a/docs/features/sandbox-policy.md b/docs/features/sandbox-policy.md index bc13f17e9b..ede9f39922 100644 --- a/docs/features/sandbox-policy.md +++ b/docs/features/sandbox-policy.md @@ -117,12 +117,18 @@ the CRD exec vocabulary and are refused rather than broadened. ## Public event hooks Register receivers in `ThirdPartyTracers.ThirdPartyEventReceivers` under -`utils.SandboxExecVerdictEventType`. The public `VerdictExporter` accepts +`utils.ExecAuthorizationEventType`. The public `VerdictExporter` accepts `EnrichedEventReceiver`, `EventReceiver`, or `sandboxpolicy.VerdictReceiver`. Optional process-tree enrichment uses the existing `TaskBasedEnricher` interface. Private OTel export is intentionally not implemented here. -`VerdictEvent` implements `EnrichEvent`, **not** `ExecEvent`: an LSM denial is not +`ExecAuthorizationEventType` (`execAuthorization`) is an internal receiver-dispatch +key, not an OTEL event name. Construct snapshots with `NewExecVerdictEvent`. +An exporter may reuse the ordinary exec wire family with an explicit authorization +phase, but that mapping must not route authorization through ordinary exec +observation processing or imply syscall success. + +`ExecVerdictEvent` implements `EnrichEvent`, **not** `ExecEvent`: an LSM denial is not process creation. The payload preserves policy/revision, verdict, effective mode, `WouldDeny`, and available rule/origin metadata. Unknown argv, UID, image origin and actor path remain absent for legacy events. Legacy time is receipt time. @@ -135,6 +141,35 @@ kernel decisions. Void third-party receiver calls must return promptly; cancella cannot forcibly interrupt arbitrary user callbacks. Events are independent of trace_exec's successful-exec filters and do not mutate the process tree as an exec. +### Adding a second enforcement gadget + +`ExecVerdictEvent` and the current `Verdict` payload are exec-specific; their +existing names do not define a generic contract for open, connect or syscall +authorization. When adding the second gadget: + +1. Define a distinct operation-specific event and authorization dispatch key. + Keep exec target/argv, open path/flags, connect destination and syscall facts + in their own typed payloads, rather than adding unrelated optional fields to + `ExecVerdictEvent` or making authorization implement observation interfaces. +2. Compare the two concrete payloads and extract only genuinely shared decision, + mode, policy/revision/match, completeness and captured-binding metadata. + Preserve absence, immutable snapshots and identity validation. Do not assume + exec's binding requirements or provenance fields apply unchanged to another + hook; validate that contract before sharing it. No shared abstraction is added + preemptively by this exec-only implementation. +3. Preserve separate authorization dispatch through the public receiver hooks. + At the exporter boundary, extend the corresponding existing operation's wire + family with explicit authorization versus observation semantics and policy + metadata. Update its backend contract/tests; do not carry exec-specific + attribute names into another operation or silently imply operation success. +4. Reuse bounded telemetry/loss handling only where semantics match. Reporting + failures must not alter enforcement decisions. Test each operation's unknown + fields, deny/audit/allow cases and receiver isolation, plus mixed-operation + dispatch and snapshot ownership for any shared metadata extracted. +5. Treat cross-producer occurrence correlation as a separate contract. A common + metadata struct or wire family does not make two records the same attempt; + do not suppress observations using PID/path/time heuristics. + ## Verification boundary Unit/race tests cover selection, reload bookkeeping, import refusal, status, diff --git a/pkg/sandboxpolicy/exporter.go b/pkg/sandboxpolicy/exporter.go index 5f3d71b821..dfb5c7e46d 100644 --- a/pkg/sandboxpolicy/exporter.go +++ b/pkg/sandboxpolicy/exporter.go @@ -15,21 +15,24 @@ import ( "github.com/kubescape/node-agent/pkg/utils" ) -// VerdictEvent is an exec authorization attempt, NOT an observed process creation. +// ExecVerdictEvent is an exec authorization attempt, NOT an observed process creation. // It deliberately does not implement utils.ExecEvent. The raw Verdict retains // policy/rule provenance and audit/deny semantics even after optional enrichment. // Unknown enrichment fields are absent (FieldPresent=false), not fabricated from // the target executable. In particular GetError's zero means unavailable, not a // successful exec syscall: this event describes an LSM decision, not syscall exit. -type VerdictEvent struct { +// Keep this event exec-specific. When a second enforce gadget is added, give its +// operation facts a distinct event and dispatch key; extract only metadata proven +// common to both. See docs/features/sandbox-policy.md#adding-a-second-enforcement-gadget. +type ExecVerdictEvent struct { Verdict Verdict Binding Binding extra any } -var _ utils.EnrichEvent = (*VerdictEvent)(nil) +var _ utils.EnrichEvent = (*ExecVerdictEvent)(nil) -func NewVerdictEvent(verdict Verdict, binding Binding) *VerdictEvent { +func NewExecVerdictEvent(verdict Verdict, binding Binding) *ExecVerdictEvent { verdict.PolicySet = append([]PolicyRevision(nil), verdict.PolicySet...) verdict.Matches = append([]RuleMatch(nil), verdict.Matches...) verdict.Argv = append([]string(nil), verdict.Argv...) @@ -43,54 +46,54 @@ func NewVerdictEvent(verdict Verdict, binding Binding) *VerdictEvent { } binding.PodLabels = cloneLabels(binding.PodLabels) binding.NamespaceLabels = cloneLabels(binding.NamespaceLabels) - return &VerdictEvent{Verdict: verdict, Binding: binding} + return &ExecVerdictEvent{Verdict: verdict, Binding: binding} } -func (e *VerdictEvent) GetContainerID() string { return e.Verdict.ContainerID } -func (*VerdictEvent) GetEventType() utils.EventType { return utils.SandboxExecVerdictEventType } -func (e *VerdictEvent) GetNamespace() string { return e.Binding.Namespace } -func (e *VerdictEvent) GetPod() string { return e.Binding.PodName } -func (e *VerdictEvent) GetTimestamp() gadgettypes.Time { +func (e *ExecVerdictEvent) GetContainerID() string { return e.Verdict.ContainerID } +func (*ExecVerdictEvent) GetEventType() utils.EventType { return utils.ExecAuthorizationEventType } +func (e *ExecVerdictEvent) GetNamespace() string { return e.Binding.Namespace } +func (e *ExecVerdictEvent) GetPod() string { return e.Binding.PodName } +func (e *ExecVerdictEvent) GetTimestamp() gadgettypes.Time { if e.Verdict.Time.IsZero() { return 0 } return gadgettypes.Time(e.Verdict.Time.UnixNano()) } -func (*VerdictEvent) HasDroppedEvents() bool { return false } // loss is exposed by ExporterStats, never invented per record -func (*VerdictEvent) Release() {} -func (*VerdictEvent) GetComm() string { return "" } -func (e *VerdictEvent) GetContainer() string { return e.Binding.ContainerName } -func (*VerdictEvent) GetContainerImage() string { return "" } -func (*VerdictEvent) GetContainerImageDigest() string { return "" } -func (*VerdictEvent) GetError() int64 { return 0 } -func (e *VerdictEvent) GetExtra() any { return e.extra } -func (e *VerdictEvent) SetExtra(extra any) { e.extra = extra } -func (*VerdictEvent) GetGid() *uint32 { return nil } -func (*VerdictEvent) GetHostNetwork() bool { return false } -func (e *VerdictEvent) GetMountNsID() uint64 { return e.Binding.MountNamespace } -func (*VerdictEvent) GetPcomm() string { return "" } -func (e *VerdictEvent) GetPID() uint32 { return e.Verdict.PID } -func (e *VerdictEvent) GetPID64() uint64 { return uint64(e.Verdict.PID) } -func (e *VerdictEvent) GetPodLabels() map[string]string { return cloneLabels(e.Binding.PodLabels) } -func (e *VerdictEvent) GetPpid() uint32 { return e.Verdict.ParentPID } -func (e *VerdictEvent) GetUid() *uint32 { +func (*ExecVerdictEvent) HasDroppedEvents() bool { return false } // loss is exposed by ExporterStats, never invented per record +func (*ExecVerdictEvent) Release() {} +func (*ExecVerdictEvent) GetComm() string { return "" } +func (e *ExecVerdictEvent) GetContainer() string { return e.Binding.ContainerName } +func (*ExecVerdictEvent) GetContainerImage() string { return "" } +func (*ExecVerdictEvent) GetContainerImageDigest() string { return "" } +func (*ExecVerdictEvent) GetError() int64 { return 0 } +func (e *ExecVerdictEvent) GetExtra() any { return e.extra } +func (e *ExecVerdictEvent) SetExtra(extra any) { e.extra = extra } +func (*ExecVerdictEvent) GetGid() *uint32 { return nil } +func (*ExecVerdictEvent) GetHostNetwork() bool { return false } +func (e *ExecVerdictEvent) GetMountNsID() uint64 { return e.Binding.MountNamespace } +func (*ExecVerdictEvent) GetPcomm() string { return "" } +func (e *ExecVerdictEvent) GetPID() uint32 { return e.Verdict.PID } +func (e *ExecVerdictEvent) GetPID64() uint64 { return uint64(e.Verdict.PID) } +func (e *ExecVerdictEvent) GetPodLabels() map[string]string { return cloneLabels(e.Binding.PodLabels) } +func (e *ExecVerdictEvent) GetPpid() uint32 { return e.Verdict.ParentPID } +func (e *ExecVerdictEvent) GetUid() *uint32 { if e.Verdict.UID == nil { return nil } value := *e.Verdict.UID return &value } -func (*VerdictEvent) GetEcsClusterName() string { return "" } -func (*VerdictEvent) GetEcsClusterARN() string { return "" } -func (*VerdictEvent) GetEcsTaskARN() string { return "" } -func (*VerdictEvent) GetEcsTaskFamily() string { return "" } -func (*VerdictEvent) GetEcsTaskDefinitionARN() string { return "" } -func (*VerdictEvent) GetEcsServiceName() string { return "" } -func (*VerdictEvent) GetEcsContainerName() string { return "" } -func (*VerdictEvent) GetEcsContainerARN() string { return "" } -func (*VerdictEvent) GetEcsContainerInstance() string { return "" } -func (*VerdictEvent) GetEcsAvailabilityZone() string { return "" } -func (*VerdictEvent) GetEcsLaunchType() string { return "" } -func (e *VerdictEvent) FieldPresent(name string) bool { +func (*ExecVerdictEvent) GetEcsClusterName() string { return "" } +func (*ExecVerdictEvent) GetEcsClusterARN() string { return "" } +func (*ExecVerdictEvent) GetEcsTaskARN() string { return "" } +func (*ExecVerdictEvent) GetEcsTaskFamily() string { return "" } +func (*ExecVerdictEvent) GetEcsTaskDefinitionARN() string { return "" } +func (*ExecVerdictEvent) GetEcsServiceName() string { return "" } +func (*ExecVerdictEvent) GetEcsContainerName() string { return "" } +func (*ExecVerdictEvent) GetEcsContainerARN() string { return "" } +func (*ExecVerdictEvent) GetEcsContainerInstance() string { return "" } +func (*ExecVerdictEvent) GetEcsAvailabilityZone() string { return "" } +func (*ExecVerdictEvent) GetEcsLaunchType() string { return "" } +func (e *ExecVerdictEvent) FieldPresent(name string) bool { switch name { case "timestamp": return !e.Verdict.Time.IsZero() @@ -138,7 +141,7 @@ type ExporterStats struct { // queue and drops telemetry rather than blocking the enforcement producer. type VerdictExporter struct { options ExporterOptions - queue chan *VerdictEvent + queue chan *ExecVerdictEvent mu sync.Mutex stopped bool started atomic.Bool @@ -155,7 +158,7 @@ func NewVerdictExporter(options ExporterOptions) (*VerdictExporter, error) { if options.Capacity <= 0 || options.Capacity > MaxVerdictQueueCapacity { return nil, fmt.Errorf("verdict queue capacity must be between 1 and %d", MaxVerdictQueueCapacity) } - return &VerdictExporter{options: options, queue: make(chan *VerdictEvent, options.Capacity)}, nil + return &VerdictExporter{options: options, queue: make(chan *ExecVerdictEvent, options.Capacity)}, nil } func (e *VerdictExporter) ReportVerdict(ctx context.Context, verdict Verdict) error { binding := Binding{} @@ -165,7 +168,7 @@ func (e *VerdictExporter) ReportVerdict(ctx context.Context, verdict Verdict) er binding = resolved } } - event := NewVerdictEvent(verdict, binding) + event := NewExecVerdictEvent(verdict, binding) e.mu.Lock() defer e.mu.Unlock() if e.stopped { @@ -218,7 +221,7 @@ func (e *VerdictExporter) Run(ctx context.Context) error { } } } -func (e *VerdictExporter) deliver(ctx context.Context, event *VerdictEvent) { +func (e *VerdictExporter) deliver(ctx context.Context, event *ExecVerdictEvent) { if e.options.Enricher == nil { e.dispatch(ctx, event) return @@ -250,18 +253,18 @@ func (e *VerdictExporter) deliver(ctx context.Context, event *VerdictEvent) { once.Do(func() { e.dropped.Add(1); close(done) }) } } -func (e *VerdictExporter) dispatch(ctx context.Context, event *VerdictEvent) { +func (e *VerdictExporter) dispatch(ctx context.Context, event *ExecVerdictEvent) { if e.options.Receivers == nil { e.dropped.Add(1) return } - receivers, ok := e.options.Receivers.Load(utils.SandboxExecVerdictEventType) + receivers, ok := e.options.Receivers.Load(utils.ExecAuthorizationEventType) if !ok || receivers.Cardinality() == 0 { e.dropped.Add(1) return } for _, receiver := range receivers.ToSlice() { - copy := NewVerdictEvent(event.Verdict, event.Binding) + copy := NewExecVerdictEvent(event.Verdict, event.Binding) copy.extra = event.extra err := guardReceiver(func() error { if enriched, ok := receiver.(containerwatcher.EnrichedEventReceiver); ok { @@ -270,7 +273,7 @@ func (e *VerdictExporter) dispatch(ctx context.Context, event *VerdictEvent) { return nil } if raw, ok := receiver.(containerwatcher.EventReceiver); ok { - raw.ReportEvent(utils.SandboxExecVerdictEventType, copy) + raw.ReportEvent(utils.ExecAuthorizationEventType, copy) return nil } if verdict, ok := receiver.(VerdictReceiver); ok { diff --git a/pkg/sandboxpolicy/exporter_test.go b/pkg/sandboxpolicy/exporter_test.go index a518bdbd91..04514c2d90 100644 --- a/pkg/sandboxpolicy/exporter_test.go +++ b/pkg/sandboxpolicy/exporter_test.go @@ -60,7 +60,7 @@ func (f *fakeVerdictEnricher) SubmitEnrichmentTask(event utils.EnrichEvent, sysc func receiverMap(receivers ...containerwatcher.GenericEventReceiver) *maps.SafeMap[utils.EventType, mapset.Set[containerwatcher.GenericEventReceiver]] { result := &maps.SafeMap[utils.EventType, mapset.Set[containerwatcher.GenericEventReceiver]]{} - result.Set(utils.SandboxExecVerdictEventType, mapset.NewSet[containerwatcher.GenericEventReceiver](receivers...)) + result.Set(utils.ExecAuthorizationEventType, mapset.NewSet[containerwatcher.GenericEventReceiver](receivers...)) return result } func sampleVerdict() Verdict { @@ -68,12 +68,12 @@ func sampleVerdict() Verdict { image := false return Verdict{Time: time.Unix(1710000000, 1234), Policy: PolicyIdentity{Kind: "SandboxPolicy", Namespace: "ns", Name: "policy", UID: "policy-uid", Generation: 3}, Revision: "revision", Rule: "rule", Origin: "authored", Mode: api.RuleEnforce, Verdict: api.ActionDeny, Reason: "matched deny", ContainerID: "container", CgroupID: 12, PID: 41, ParentPID: 40, UID: &uid, ActorPath: "/bin/actor", TargetPath: "/bin/target", Argv: []string{"target", "--flag"}, FromImage: &image} } -func TestVerdictEventRetainsKnownMetadataWithoutInventingExec(t *testing.T) { +func TestExecVerdictEventRetainsKnownMetadataWithoutInventingExec(t *testing.T) { v := sampleVerdict() b := binding() b.MountNamespace = 123 - event := NewVerdictEvent(v, b) - if event.GetEventType() != utils.SandboxExecVerdictEventType || event.GetEventType() == utils.ExecveEventType { + event := NewExecVerdictEvent(v, b) + if event.GetEventType() != utils.ExecAuthorizationEventType || event.GetEventType() == utils.ExecveEventType { t.Fatal("verdict classified as successful exec observation") } if _, ok := any(event).(utils.ExecEvent); ok { @@ -97,6 +97,34 @@ func TestVerdictEventRetainsKnownMetadataWithoutInventingExec(t *testing.T) { } } +// An authorization-only receiver must not depend on observation dispatch keys +// or on a serializer's choice to reuse an existing exec wire format. +func TestExecAuthorizationDispatchKey(t *testing.T) { + if utils.ExecAuthorizationEventType != "execAuthorization" || utils.ExecAuthorizationEventType == utils.ExecveEventType { + t.Fatal("exec authorization must retain its own internal dispatch key") + } + receiver := &dispatchKeyReceiver{received: make(chan utils.EventType, 1)} + exporter, err := NewVerdictExporter(ExporterOptions{Capacity: 1, Receivers: receiverMap(receiver)}) + if err != nil { + t.Fatal(err) + } + exporter.dispatch(context.Background(), NewExecVerdictEvent(sampleVerdict(), binding())) + select { + case got := <-receiver.received: + if got != utils.ExecAuthorizationEventType { + t.Fatalf("raw receiver dispatched under %q", got) + } + default: + t.Fatal("authorization receiver was not called") + } +} + +type dispatchKeyReceiver struct{ received chan utils.EventType } + +func (r *dispatchKeyReceiver) ReportEvent(eventType utils.EventType, _ utils.K8sEvent) { + r.received <- eventType +} + func TestDeniedAuditAndAllowedEventsReachPublicReceivers(t *testing.T) { raw := &rawVerdictReceiver{make(chan utils.K8sEvent, 3)} enriched := &enrichedVerdictReceiver{make(chan *events.EnrichedEvent, 3)} @@ -125,7 +153,7 @@ func TestDeniedAuditAndAllowedEventsReachPublicReceivers(t *testing.T) { } select { case event := <-raw.received: - if !reflect.DeepEqual(event.(*VerdictEvent).Verdict, v) { + if !reflect.DeepEqual(event.(*ExecVerdictEvent).Verdict, v) { t.Fatal("raw verdict lost") } case <-time.After(3 * time.Second): @@ -133,7 +161,7 @@ func TestDeniedAuditAndAllowedEventsReachPublicReceivers(t *testing.T) { } select { case event := <-enriched.received: - if !reflect.DeepEqual(event.Event.(*VerdictEvent).Verdict, v) || event.PID != v.PID || event.ContainerID != v.ContainerID || event.ProcessTree.PID != 0 { + if !reflect.DeepEqual(event.Event.(*ExecVerdictEvent).Verdict, v) || event.PID != v.PID || event.ContainerID != v.ContainerID || event.ProcessTree.PID != 0 { t.Fatal("enrichment lost verdict or fabricated process tree") } case <-time.After(3 * time.Second): @@ -155,11 +183,11 @@ func TestReceiverFailuresCannotChangeVerdictsOrBlockOtherSubscribers(t *testing. t.Fatal(err) } original := sampleVerdict() - exporter.dispatch(context.Background(), NewVerdictEvent(original, binding())) + exporter.dispatch(context.Background(), NewExecVerdictEvent(original, binding())) if failing.calls.Load() != 1 || exporter.Stats().ReceiverErrors != 2 || exporter.Stats().Delivered != 1 { t.Fatalf("errors not isolated/reported: %+v", exporter.Stats()) } - if got := (<-raw.received).(*VerdictEvent); !reflect.DeepEqual(got.Verdict, original) { + if got := (<-raw.received).(*ExecVerdictEvent); !reflect.DeepEqual(got.Verdict, original) { t.Fatal("receiver failures changed enforcement verdict") } } @@ -172,9 +200,9 @@ func TestOptionalEnrichmentDoesNotFabricateForkOrReplaceVerdict(t *testing.T) { t.Fatal(err) } v := sampleVerdict() - exporter.deliver(context.Background(), NewVerdictEvent(v, binding())) + exporter.deliver(context.Background(), NewExecVerdictEvent(v, binding())) got := <-receiver.received - if enricher.calls != 1 || len(enricher.syscalls) != 0 || got.PID != v.PID || got.ContainerID != v.ContainerID || !reflect.DeepEqual(got.Event.(*VerdictEvent).Verdict, v) || got.Event.(utils.EnrichEvent).GetExtra() != "stack-context" { + if enricher.calls != 1 || len(enricher.syscalls) != 0 || got.PID != v.PID || got.ContainerID != v.ContainerID || !reflect.DeepEqual(got.Event.(*ExecVerdictEvent).Verdict, v) || got.Event.(utils.EnrichEvent).GetExtra() != "stack-context" { t.Fatal("enricher invented fork or replaced authoritative attempt metadata") } } @@ -232,7 +260,7 @@ func TestCancelledPendingEnrichmentCountsLossOnce(t *testing.T) { } ctx, cancel := context.WithCancel(context.Background()) cancel() - exporter.deliver(ctx, NewVerdictEvent(sampleVerdict(), binding())) + exporter.deliver(ctx, NewExecVerdictEvent(sampleVerdict(), binding())) enricher.callback(enricher.event, "container", 41) if exporter.Stats().Dropped != 1 || exporter.Stats().Delivered != 0 { t.Fatalf("late callback double-counted or delivered after cancellation: %+v", exporter.Stats()) @@ -256,8 +284,8 @@ func TestFullVerdictProvenanceCloneBoundaries(t *testing.T) { v.Matches[0].Name = "producer mutation" queued := <-exporter.queue exporter.dispatch(context.Background(), queued) - first := (<-a.received).(*VerdictEvent) - second := (<-b.received).(*VerdictEvent) + first := (<-a.received).(*ExecVerdictEvent) + second := (<-b.received).(*ExecVerdictEvent) first.Verdict.PolicySet[0].Revision = "receiver mutation" first.Verdict.Matches[0].Name = "receiver mutation" if second.Verdict.PolicySet[0].Revision != "revision" || second.Verdict.Matches[0].Name != "original" || queued.Verdict.Matches[0].Name != "original" || !second.Verdict.MatchesComplete { diff --git a/pkg/utils/events.go b/pkg/utils/events.go index cdd8b83f87..d39bc8520c 100644 --- a/pkg/utils/events.go +++ b/pkg/utils/events.go @@ -276,8 +276,9 @@ const ( UnshareEventType EventType = "unshare" ) -// SandboxExecVerdictEventType identifies authorization attempts, not process creation. -const SandboxExecVerdictEventType EventType = "sandboxExecVerdict" +// ExecAuthorizationEventType is an internal dispatch key for exec authorization, +// not process observation or an exporter wire-format name. +const ExecAuthorizationEventType EventType = "execAuthorization" // Get the path of the file on the node. func GetHostFilePathFromEvent(event EnrichEvent, containerPid uint32) (string, error) { From 456c84304d4cbafa96cbf1fe17cc00fb16133385 Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Thu, 29 Oct 2026 17:59:38 +0100 Subject: [PATCH 13/23] fix(sandboxpolicy): isolate binding operations and drain engine ownership Bound backend ownership operations per binding while retaining drain and failed-withdrawal recovery. This is a concurrency/lifecycle prerequisite only: no shared native implementation, dependency pin change, default selection, or production shared BPF collection claim. Signed-off-by: Matthias Bertschy --- pkg/sandboxpolicy/igbackend/fullbackend.go | 321 +++++++++--- .../igbackend/fullbackend_concurrency_test.go | 459 ++++++++++++++++++ pkg/sandboxpolicy/igbackend/fullengine.go | 11 + 3 files changed, 726 insertions(+), 65 deletions(-) create mode 100644 pkg/sandboxpolicy/igbackend/fullbackend_concurrency_test.go diff --git a/pkg/sandboxpolicy/igbackend/fullbackend.go b/pkg/sandboxpolicy/igbackend/fullbackend.go index 1e0fe7b666..d712648aba 100644 --- a/pkg/sandboxpolicy/igbackend/fullbackend.go +++ b/pkg/sandboxpolicy/igbackend/fullbackend.go @@ -24,11 +24,18 @@ type FullBindingLease interface { // FullBindingSource transfers an owned lease, including any nonnil lease returned // alongside an error. The backend retains it until cleanup is confirmed. +// Prepare may run concurrently for distinct bindings. type FullBindingSource interface { Prepare(context.Context, sandboxpolicy.Binding) (FullBindingLease, error) } type fullRecord struct { + // mu serializes native operations. Never wait for it while holding b.mu. + mu sync.Mutex + // cancel, draining and drainSequence are protected by b.mu; binding is immutable. + cancel context.CancelFunc + draining bool + drainSequence uint64 binding sandboxpolicy.Binding instance FullInstance lease FullBindingLease // only until the engine's Acquire transfers it @@ -54,6 +61,8 @@ type FullBackend struct { retiredInstances uint64 retiredUnavailable uint64 mu sync.Mutex + closeMu sync.Mutex + engineClosed bool engine FullEngine source FullBindingSource receiver sandboxpolicy.VerdictReceiver @@ -77,12 +86,17 @@ func NewFull(engine FullEngine, source FullBindingSource, receiver sandboxpolicy // Attest observes only the exact committed owner; it never resumes a pending // publication, including an uncommitted candidate from a previously failed reload. func (b *FullBackend) Attest(ctx context.Context, binding sandboxpolicy.Binding, revision string) error { - b.mu.Lock() - defer b.mu.Unlock() ctx, cancel := context.WithTimeout(ctx, b.operationTimeout) defer cancel() - r := b.records[key(binding)] - if b.closed || r == nil || r.instance == nil || r.retiring || r.protocolFault { + r, _, err := b.lockRecord(ctx, binding, false) + if err != nil { + return err + } + if r == nil { + return fmt.Errorf("binding has no attestable enforcement owner") + } + defer b.unlockRecord(key(binding), r) + if r.instance == nil || r.retiring || r.protocolFault { return fmt.Errorf("binding has no attestable enforcement owner") } if !sameFullBindingMetadata(r.binding, binding) || revision == "" || r.revision != revision { @@ -95,18 +109,34 @@ func (b *FullBackend) Attest(ctx context.Context, binding sandboxpolicy.Binding, } func (b *FullBackend) Replace(ctx context.Context, binding sandboxpolicy.Binding, plan sandboxpolicy.Plan) (result sandboxpolicy.ReplaceResult, resultErr error) { - b.mu.Lock() - defer b.mu.Unlock() k := key(binding) - timeout := b.operationTimeout - if b.records[k] == nil { - timeout = FullStartupTimeout - } - ctx, cancel := context.WithTimeout(ctx, timeout) + // The startup bound also bounds queueing; established owners get the shorter + // native-operation bound after acquiring their own record. + ctx, cancel := context.WithTimeout(ctx, FullStartupTimeout) defer cancel() + r, drainSequence, err := b.lockRecord(ctx, binding, true) + if err != nil { + return sandboxpolicy.ReplaceResult{ProtectionUnconfirmed: true}, err + } + defer func() { b.unlockRecord(k, r) }() + if r.instance != nil { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, b.operationTimeout) + defer cancel() + } + ctx, cancelOperation := context.WithCancel(ctx) + b.mu.Lock() + if b.closed || b.records[k] != r || r.drainSequence != drainSequence { + b.mu.Unlock() + cancelOperation() + return sandboxpolicy.ReplaceResult{ProtectionUnconfirmed: true}, fmt.Errorf("binding is draining") + } + r.cancel = cancelOperation + draining := r.draining + b.mu.Unlock() + defer cancelOperation() defer func() { - r := b.records[k] - if r != nil && r.instance != nil { + if r.instance != nil { if r.retiring { result.ProtectionUnconfirmed = true return @@ -116,25 +146,31 @@ func (b *FullBackend) Replace(ctx context.Context, binding sandboxpolicy.Binding resultErr = errors.Join(resultErr, health) } }() - if b.closed { - return sandboxpolicy.ReplaceResult{ProtectionUnconfirmed: true}, fmt.Errorf("backend closed") - } - r := b.records[k] - if r != nil && !r.retiring && !sameFullBindingMetadata(r.binding, binding) { + if !sameFullBindingMetadata(r.binding, binding) { return result, fmt.Errorf("binding metadata changed for persistent identity") } - if r != nil && r.retiring { + if r.retiring || draining { + r.retiring = true if err := b.stop(ctx, k, r); err != nil { return result, err } - r = nil - } - for other := range b.records { - if other != k && other.cgroup == k.cgroup { - return result, fmt.Errorf("cgroup is still owned by another binding") + // Successful retirement is a new ownership lifetime, not a reopening of + // the old record. Both queued Replace and Withdraw calls retain the old + // pointer and must fail revalidation rather than touch the replacement. + b.mu.Lock() + if b.closed || b.records[k] != r || r.drainSequence != drainSequence || ctx.Err() != nil { + b.mu.Unlock() + return sandboxpolicy.ReplaceResult{ProtectionUnconfirmed: true}, errors.Join(fmt.Errorf("binding ownership changed during retirement"), ctx.Err()) } - } - if r != nil && r.pending != 0 && r.pendingRevision != plan.Revision && r.instance.Generation() != r.pending { + next := &fullRecord{binding: r.binding, cancel: cancelOperation} + next.mu.Lock() + b.records[k] = next + r.cancel = nil + b.mu.Unlock() + r.mu.Unlock() + r = next + } + if r.pending != 0 && r.pendingRevision != plan.Revision && r.instance.Generation() != r.pending { // An uncommitted candidate is not authority to publish a now-superseded // revision. Abort its staging before considering the current request. aborter, ok := r.instance.(FullPendingAborter) @@ -154,7 +190,7 @@ func (b *FullBackend) Replace(ctx context.Context, binding sandboxpolicy.Binding r.pending = 0 r.pendingRevision = "" } - if r != nil && r.pending != 0 { + if r.pending != 0 { publication, err := r.instance.Resume(ctx) if e := b.publication(r, publication); e != nil { err = errors.Join(err, e) @@ -167,35 +203,35 @@ func (b *FullBackend) Replace(ctx context.Context, binding sandboxpolicy.Binding return result, errors.Join(err, pendingError(r)) } } - if r != nil && r.revision == plan.Revision { + if r.instance != nil && r.revision == plan.Revision { return sandboxpolicy.ReplaceResult{Committed: true, ActiveRevision: r.revision}, nil } - if r == nil && len(b.records) >= FullBackendMaxBindings { - return result, fmt.Errorf("full binding ownership capacity exceeded") - } + b.mu.Lock() if b.sequence == math.MaxUint64 { + b.mu.Unlock() return result, fmt.Errorf("full generation exhausted") } b.sequence++ generation := b.sequence + b.mu.Unlock() var epochs map[string]uint64 - if r != nil { + if r.instance != nil { epochs = r.instance.Epochs() } program, err := CompileFull(binding, plan, generation, epochs) if err != nil { return result, err } - if r == nil { + if r.instance == nil { catalog := NewFullVerdictCatalog(binding) if err := catalog.Stage(generation, plan, program); err != nil { return result, err } - lease, err := b.source.Prepare(ctx, binding) + var lease FullBindingLease + lease, err = b.source.Prepare(ctx, binding) if err != nil || lease == nil { if lease != nil { - r = &fullRecord{binding: sandboxpolicy.Binding{PodUID: binding.PodUID, Namespace: binding.Namespace, PodName: binding.PodName, ContainerName: binding.ContainerName, Kind: binding.Kind}, lease: lease, retiring: true} - b.records[k] = r + r.lease, r.retiring = lease, true err = errors.Join(err, b.stop(ctx, k, r)) } if lease == nil { @@ -203,8 +239,7 @@ func (b *FullBackend) Replace(ctx context.Context, binding sandboxpolicy.Binding } return result, err } - r = &fullRecord{binding: sandboxpolicy.Binding{PodUID: binding.PodUID, Namespace: binding.Namespace, PodName: binding.PodName, ContainerName: binding.ContainerName, Kind: binding.Kind}, lease: lease, catalog: catalog, pending: generation, pendingRevision: plan.Revision} - b.records[k] = r + r.lease, r.catalog, r.pending, r.pendingRevision = lease, catalog, generation, plan.Revision // Acquisition is synchronous during Start. Ownership transfers exactly once; // a failure before Acquire leaves the lease here for ordered cleanup. acquire := func(ctx context.Context) (fullruntime.BindingLease, error) { @@ -288,7 +323,7 @@ func (b *FullBackend) publication(r *fullRecord, p fullruntime.PublicationResult } return nil } -func (b *FullBackend) stop(ctx context.Context, k identity, r *fullRecord) error { +func (b *FullBackend) stop(ctx context.Context, _ identity, r *fullRecord) error { ctx, cancel := context.WithTimeout(ctx, b.operationTimeout) defer cancel() if r.instance != nil { @@ -304,15 +339,123 @@ func (b *FullBackend) stop(ctx context.Context, k identity, r *fullRecord) error } r.lease = nil } - delete(b.records, k) return nil } -func (b *FullBackend) Withdraw(ctx context.Context, binding sandboxpolicy.Binding) (sandboxpolicy.WithdrawResult, error) { + +// lockFullMutex permits canceled waiters to leave without spawning lock goroutines. +func lockFullMutex(ctx context.Context, mu *sync.Mutex) error { + if err := ctx.Err(); err != nil { + return err + } + if mu.TryLock() { + return nil + } + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + if mu.TryLock() { + return nil + } + } +} + +// lockRecord snapshots the drain sequence before waiting. Only a fresh Replace +// may reconcile a draining record; pre-drain waiters and Attest cannot reopen it. +func (b *FullBackend) lockRecord(ctx context.Context, binding sandboxpolicy.Binding, create bool) (*fullRecord, uint64, error) { + if err := ctx.Err(); err != nil { + return nil, 0, err + } + k := key(binding) b.mu.Lock() - defer b.mu.Unlock() + if b.closed { + b.mu.Unlock() + return nil, 0, fmt.Errorf("backend closed") + } + r := b.records[k] + if r == nil && create { + if len(b.records) >= FullBackendMaxBindings { + b.mu.Unlock() + return nil, 0, fmt.Errorf("full binding ownership capacity exceeded") + } + for other := range b.records { + if other != k && other.cgroup == k.cgroup { + b.mu.Unlock() + return nil, 0, fmt.Errorf("cgroup is still owned by another binding") + } + } + r = &fullRecord{binding: sandboxpolicy.Binding{PodUID: binding.PodUID, Namespace: binding.Namespace, PodName: binding.PodName, ContainerName: binding.ContainerName, Kind: binding.Kind}} + // Lock before publishing the reservation, so no waiter can overtake its creator. + r.mu.Lock() + b.records[k] = r + b.mu.Unlock() + return r, 0, nil + } + var drainSequence uint64 + if r != nil { + drainSequence = r.drainSequence + } + b.mu.Unlock() + if r == nil { + return nil, 0, nil + } + if err := lockFullMutex(ctx, &r.mu); err != nil { + return nil, 0, err + } + b.mu.Lock() + valid := !b.closed && b.records[k] == r && r.drainSequence == drainSequence && (create || !r.draining) + b.mu.Unlock() + if !valid { + r.mu.Unlock() + return nil, 0, fmt.Errorf("binding ownership changed or is draining") + } + return r, drainSequence, nil +} + +// Drop an empty reservation only after native cleanup; stale waiters retain the +// old pointer and cannot operate on or delete a newly admitted identity. +func (b *FullBackend) unlockRecord(k identity, r *fullRecord) { + b.mu.Lock() + r.cancel = nil + if b.records[k] == r && r.instance == nil && r.lease == nil { + delete(b.records, k) + } + b.mu.Unlock() + r.mu.Unlock() +} + +func (b *FullBackend) Withdraw(ctx context.Context, binding sandboxpolicy.Binding) (sandboxpolicy.WithdrawResult, error) { + ctx, cancel := context.WithTimeout(ctx, b.operationTimeout) + defer cancel() k := key(binding) + b.mu.Lock() r := b.records[k] if r == nil || !sameFullBindingMetadata(r.binding, binding) { + b.mu.Unlock() + return sandboxpolicy.WithdrawResult{Committed: true}, nil + } + if r.drainSequence == math.MaxUint64 { + b.mu.Unlock() + return sandboxpolicy.WithdrawResult{ProtectionUnconfirmed: true}, fmt.Errorf("binding withdrawal sequence exhausted") + } + r.drainSequence++ + r.draining = true + if r.cancel != nil { + r.cancel() + } + b.mu.Unlock() + if err := lockFullMutex(ctx, &r.mu); err != nil { + return sandboxpolicy.WithdrawResult{ProtectionUnconfirmed: true}, err + } + defer b.unlockRecord(k, r) + b.mu.Lock() + current := b.records[k] == r + b.mu.Unlock() + if !current { return sandboxpolicy.WithdrawResult{Committed: true}, nil } r.retiring = true @@ -321,16 +464,45 @@ func (b *FullBackend) Withdraw(ctx context.Context, binding sandboxpolicy.Bindin } return sandboxpolicy.WithdrawResult{Committed: true}, nil } + func (b *FullBackend) Close(ctx context.Context) error { + if err := lockFullMutex(ctx, &b.closeMu); err != nil { + return err + } + defer b.closeMu.Unlock() b.mu.Lock() - defer b.mu.Unlock() b.closed = true - var errs []error + records := make(map[identity]*fullRecord, len(b.records)) for k, r := range b.records { + records[k] = r + r.draining = true + if r.cancel != nil { + r.cancel() + } + } + b.mu.Unlock() + var errs []error + for k, r := range records { + if err := lockFullMutex(ctx, &r.mu); err != nil { + errs = append(errs, err) + continue + } r.retiring = true errs = append(errs, b.stop(ctx, k, r)) + b.unlockRecord(k, r) + } + if err := errors.Join(errs...); err != nil { + return err } - return errors.Join(errs...) + if !b.engineClosed { + if closer, ok := b.engine.(FullEngineCloser); ok { + if err := closer.Close(ctx); err != nil { + return err + } + } + b.engineClosed = true + } + return nil } func addKernelCounters(total *KernelEventTotals, counters fullruntime.KernelEventCounters) { @@ -350,8 +522,10 @@ func instanceKernelCounters(instance FullInstance) (fullruntime.KernelEventCount // Called exactly once before dropping an instance. Never accept a live sample // as a final lifetime total, and never let telemetry errors block withdrawal. func (b *FullBackend) retainKernelCounters(instance FullInstance) { - b.retiredInstances++ counters, err := instanceKernelCounters(instance) + b.mu.Lock() + defer b.mu.Unlock() + b.retiredInstances++ if err != nil || !counters.Final { b.retiredUnavailable++ } @@ -364,33 +538,50 @@ func (b *FullBackend) KernelEventStats(ctx context.Context) (KernelEventStats, e if err := ctx.Err(); err != nil { return KernelEventStats{}, err } - // Mutating backend operations may hold this mutex during native work. - // Poll only this read-only API so cancellation never leaves a lock waiter - // goroutine behind or delays sampler shutdown until enforcement completes. - if !b.mu.TryLock() { - ticker := time.NewTicker(time.Millisecond) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return KernelEventStats{}, ctx.Err() - case <-ticker.C: - } - if b.mu.TryLock() { + // Acquire a consistent registry/retirement snapshot without waiting for any + // record while holding the registry lock (record operations take reverse order). + var records []*fullRecord + var result KernelEventStats + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + for { + if err := lockFullMutex(ctx, &b.mu); err != nil { + return KernelEventStats{}, err + } + complete := true + for _, r := range b.records { + if !r.mu.TryLock() { + complete = false break } + records = append(records, r) + } + if complete { + result = KernelEventStats{Retired: b.retiredCounters, RetiredInstances: b.retiredInstances, RetiredUnavailable: b.retiredUnavailable} + b.mu.Unlock() + break + } + for _, r := range records { + r.mu.Unlock() + } + records = nil + b.mu.Unlock() + select { + case <-ctx.Done(): + return KernelEventStats{}, ctx.Err() + case <-ticker.C: } } - defer b.mu.Unlock() - if err := ctx.Err(); err != nil { - return KernelEventStats{}, err - } - result := KernelEventStats{Retired: b.retiredCounters, RetiredInstances: b.retiredInstances, RetiredUnavailable: b.retiredUnavailable} + defer func() { + for _, r := range records { + r.mu.Unlock() + } + }() var errs []error if result.RetiredUnavailable != 0 { errs = append(errs, fmt.Errorf("kernel counters unavailable for %d retired instances", result.RetiredUnavailable)) } - for _, record := range b.records { + for _, record := range records { if record.instance == nil { continue } diff --git a/pkg/sandboxpolicy/igbackend/fullbackend_concurrency_test.go b/pkg/sandboxpolicy/igbackend/fullbackend_concurrency_test.go new file mode 100644 index 0000000000..25adab2658 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/fullbackend_concurrency_test.go @@ -0,0 +1,459 @@ +package igbackend + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullpolicy" + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullruntime" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" +) + +type independentFullSource struct { + blocked uint64 + entered, release chan struct{} +} + +func (s *independentFullSource) Prepare(ctx context.Context, b sandboxpolicy.Binding) (FullBindingLease, error) { + if b.CgroupID == s.blocked { + close(s.entered) + select { + case <-s.release: + case <-ctx.Done(): + return nil, ctx.Err() + } + } + return &fullTestLease{}, nil +} + +type independentFullEngine struct{} + +func (*independentFullEngine) Start(ctx context.Context, name string, c FullStartConfig, emit func(RawFullVerdict)) (FullInstance, error) { + e := &fullTestEngine{instance: &fullTestInstance{}, transfer: true} + return e.Start(ctx, name, c, emit) +} +func TestFullBackendBlockedBindingDoesNotBlockNeighbor(t *testing.T) { + _, _, _, a := fullBackendFixture(t) + neighbor := a + neighbor.CgroupID++ + neighbor.ContainerID = "neighbor" + s := &independentFullSource{blocked: a.CgroupID, entered: make(chan struct{}), release: make(chan struct{})} + b, err := NewFull(&independentFullEngine{}, s, nil, nil) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + if _, err = b.Replace(ctx, neighbor, verdictPlan()); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { _, err := b.Replace(ctx, a, verdictPlan()); done <- err }() + <-s.entered + defer func() { + close(s.release) + if err := <-done; err != nil { + t.Error(err) + } + if err := b.Close(ctx); err != nil { + t.Error(err) + } + }() + neighborDone := make(chan error, 1) + go func() { + p := verdictPlan() + p.Revision = "neighbor-next" + _, err := b.Replace(ctx, neighbor, p) + if err == nil { + _, err = b.Withdraw(ctx, neighbor) + } + neighborDone <- err + }() + select { + case err := <-neighborDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Error("blocked binding prevented independent update/withdrawal") + } +} + +func TestFullBackendWithdrawCancelsPreparationAndReservesCgroup(t *testing.T) { + _, _, _, binding := fullBackendFixture(t) + s := &independentFullSource{blocked: binding.CgroupID, entered: make(chan struct{}), release: make(chan struct{})} + b, _ := NewFull(&independentFullEngine{}, s, nil, nil) + done := make(chan error, 1) + go func() { _, err := b.Replace(context.Background(), binding, verdictPlan()); done <- err }() + <-s.entered + other := binding + other.ContainerID = "reused-cgroup" + if _, err := b.Replace(context.Background(), other, verdictPlan()); err == nil { + t.Fatal("admitted cgroup reserved by in-flight preparation") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + result, err := b.Withdraw(ctx, binding) + if err != nil || !result.Committed { + t.Fatal(result, err) + } + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("preparation not canceled: %v", err) + } + s.blocked = 0 + if result, err := b.Replace(ctx, other, verdictPlan()); err != nil || !result.Committed { + t.Fatal(result, err) + } + if err := b.Close(ctx); err != nil { + t.Fatal(err) + } +} + +func TestFullBackendSameBindingWaitCancelsWithoutStoppingOwner(t *testing.T) { + _, _, _, binding := fullBackendFixture(t) + s := &independentFullSource{blocked: binding.CgroupID, entered: make(chan struct{}), release: make(chan struct{})} + b, _ := NewFull(&independentFullEngine{}, s, nil, nil) + done := make(chan error, 1) + go func() { _, err := b.Replace(context.Background(), binding, verdictPlan()); done <- err }() + <-s.entered + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if _, err := b.Replace(ctx, binding, verdictPlan()); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("wait did not honor deadline: %v", err) + } + close(s.release) + if err := <-done; err != nil { + t.Fatal(err) + } + if err := b.Attest(context.Background(), binding, verdictPlan().Revision); err != nil { + t.Fatal(err) + } + if err := b.Close(context.Background()); err != nil { + t.Fatal(err) + } +} + +type closingFullEngine struct { + fullTestEngine + closes int + closeErr error +} + +func (e *closingFullEngine) Close(context.Context) error { + e.closes++ + if e.instance.stops == 0 || e.instance.lease != nil { + return errors.New("engine closed before instance drained") + } + return e.closeErr +} +func TestFullBackendEngineCloseRetainsOwnershipUntilSuccess(t *testing.T) { + _, _, _, binding := fullBackendFixture(t) + lease := &fullTestLease{} + e := &closingFullEngine{fullTestEngine: fullTestEngine{instance: &fullTestInstance{}, transfer: true}} + b, _ := NewFull(e, &fullTestSource{lease: lease}, nil, nil) + ctx := context.Background() + if _, err := b.Replace(ctx, binding, verdictPlan()); err != nil { + t.Fatal(err) + } + e.instance.failStop = true + if err := b.Close(ctx); err == nil || e.closes != 0 || lease.closed != 0 { + t.Fatal("engine/source released before failed binding cleanup", err) + } + if _, err := b.Replace(ctx, binding, verdictPlan()); err == nil { + t.Fatal("closed backend admitted binding") + } + e.instance.failStop = false + e.closeErr = errors.New("shared collection drain") + if err := b.Close(ctx); err == nil || e.closes != 1 || lease.closed != 1 { + t.Fatal("failed engine cleanup lost ownership", err) + } + if _, err := b.Replace(ctx, binding, verdictPlan()); err == nil { + t.Fatal("engine cleanup failure reopened backend") + } + e.closeErr = nil + if err := b.Close(ctx); err != nil { + t.Fatal(err) + } + if err := b.Close(ctx); err != nil { + t.Fatal(err) + } + if e.closes != 2 || e.instance.stops != 2 || lease.closed != 1 { + t.Fatalf("duplicate successful cleanup: engine=%d stop=%d lease=%d", e.closes, e.instance.stops, lease.closed) + } +} + +func TestFullBackendCloseCancelsPreparation(t *testing.T) { + _, _, _, binding := fullBackendFixture(t) + s := &independentFullSource{blocked: binding.CgroupID, entered: make(chan struct{}), release: make(chan struct{})} + b, _ := NewFull(&independentFullEngine{}, s, nil, nil) + done := make(chan error, 1) + go func() { _, err := b.Replace(context.Background(), binding, verdictPlan()); done <- err }() + <-s.entered + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := b.Close(ctx); err != nil { + t.Fatal(err) + } + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + if len(b.records) != 0 { + t.Fatal("preparation reservation leaked") + } +} + +func TestFullBackendStaleRecordCannotDeleteReplacement(t *testing.T) { + b, _, _, binding := fullBackendFixture(t) + old, _, err := b.lockRecord(context.Background(), binding, true) + if err != nil { + t.Fatal(err) + } + replacement := &fullRecord{binding: old.binding} + b.mu.Lock() + b.records[key(binding)] = replacement + b.mu.Unlock() + b.unlockRecord(key(binding), old) + if b.records[key(binding)] != replacement { + t.Fatal("stale operation deleted replacement record") + } +} + +type activationErrorInstance struct{ FullInstance } + +func (i activationErrorInstance) Activate(ctx context.Context) (fullruntime.PublicationResult, error) { + result, err := i.FullInstance.Activate(ctx) + return result, errors.Join(err, errors.New("activation acknowledgement failed")) +} + +type activationErrorEngine struct{ independentFullEngine } + +func (e *activationErrorEngine) Start(ctx context.Context, name string, c FullStartConfig, emit func(RawFullVerdict)) (FullInstance, error) { + instance, err := e.independentFullEngine.Start(ctx, name, c, emit) + return activationErrorInstance{instance}, err +} + +// Preserve first-publication errors even if the generation committed and its +// read-only health check succeeds. Neither fact erases a native operation error. +func TestFullBackendInitialPublicationErrorIsNotLost(t *testing.T) { + _, _, _, binding := fullBackendFixture(t) + b, err := NewFull(&activationErrorEngine{}, &independentFullSource{}, nil, nil) + if err != nil { + t.Fatal(err) + } + result, err := b.Replace(context.Background(), binding, verdictPlan()) + if err == nil || !result.Committed { + t.Fatal(result, err) + } + if err := b.Close(context.Background()); err != nil { + t.Fatal(err) + } +} + +type blockingUpdateInstance struct { + *fullTestInstance + entered, release chan struct{} +} + +func (i *blockingUpdateInstance) Update(ctx context.Context, p *fullpolicy.KernelProgram) (fullruntime.PublicationResult, error) { + close(i.entered) + select { + case <-i.release: + case <-ctx.Done(): + return fullruntime.PublicationResult{}, ctx.Err() + } + return i.fullTestInstance.Update(ctx, p) +} + +type qualificationEngine struct { + blocked uint64 + instance *blockingUpdateInstance +} + +func (e *qualificationEngine) Start(ctx context.Context, name string, c FullStartConfig, emit func(RawFullVerdict)) (FullInstance, error) { + base, err := (&independentFullEngine{}).Start(ctx, name, c, emit) + if c.Binding.CgroupID != e.blocked { + return base, err + } + e.instance.fullTestInstance = base.(*fullTestInstance) + return e.instance, err +} +func TestFullBackendBlockedQualificationDoesNotBlockNeighbor(t *testing.T) { + _, _, _, a := fullBackendFixture(t) + neighbor := a + neighbor.CgroupID++ + neighbor.ContainerID = "neighbor" + i := &blockingUpdateInstance{entered: make(chan struct{}), release: make(chan struct{})} + e := &qualificationEngine{blocked: a.CgroupID, instance: i} + b, _ := NewFull(e, &independentFullSource{}, nil, nil) + ctx := context.Background() + for _, binding := range []sandboxpolicy.Binding{a, neighbor} { + if _, err := b.Replace(ctx, binding, verdictPlan()); err != nil { + t.Fatal(err) + } + } + original := i.epochs["uid-one"] + done := make(chan error, 1) + go func() { p := verdictPlan(); p.Revision = "blocked-update"; _, err := b.Replace(ctx, a, p); done <- err }() + <-i.entered + bounded, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + p := verdictPlan() + p.Revision = "neighbor-update" + if result, err := b.Replace(bounded, neighbor, p); err != nil || !result.Committed { + t.Fatal(result, err) + } + if result, err := b.Withdraw(bounded, neighbor); err != nil || !result.Committed { + t.Fatal(result, err) + } + if result, err := b.Withdraw(bounded, a); err != nil || !result.Committed { + t.Fatal(result, err) + } + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + if i.epochs["uid-one"] != original || i.updates != 0 { + t.Fatal("canceled qualification changed accepted history") + } + if err := b.Close(ctx); err != nil { + t.Fatal(err) + } +} + +func TestFullBackendStaleWithdrawDoesNotStopReplacement(t *testing.T) { + b, _, _, binding := fullBackendFixture(t) + old, _, err := b.lockRecord(context.Background(), binding, true) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + go func() { _, err := b.Withdraw(ctx, binding); done <- err }() + // Observe the withdrawal's captured old identity while it waits on old.mu. + for { + b.mu.Lock() + draining := old.draining + b.mu.Unlock() + if draining { + break + } + select { + case <-ctx.Done(): + old.mu.Unlock() + t.Fatal(ctx.Err()) + case <-time.After(time.Millisecond): + } + } + instance := &fullTestInstance{} + replacement := &fullRecord{binding: old.binding, instance: instance} + b.mu.Lock() + b.records[key(binding)] = replacement + b.mu.Unlock() + old.mu.Unlock() + if err := <-done; err != nil { + t.Fatal(err) + } + if b.records[key(binding)] != replacement || instance.stops != 0 { + t.Fatal("stale withdrawal consumed replacement") + } + if err := b.Close(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestFullBackendFreshReplaceRecoversFailedWithdrawal(t *testing.T) { + _, _, _, binding := fullBackendFixture(t) + e, s := &controllerIdentityEngine{}, &controllerIdentitySource{} + b, err := NewFull(e, s, nil, nil) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + if _, err := b.Replace(ctx, binding, verdictPlan()); err != nil { + t.Fatal(err) + } + old := b.records[key(binding)] + e.instances[0].failStop = true + if result, err := b.Withdraw(ctx, binding); err == nil || result.Committed { + t.Fatal(result, err) + } + if result, err := b.Replace(ctx, binding, verdictPlan()); err == nil || result.Committed || !result.ProtectionUnconfirmed || len(e.instances) != 1 || s.leases[0].closed != 0 { + t.Fatal("failed retirement was reopened", result, err) + } + e.instances[0].failStop = false + defer func() { + if err := b.Close(ctx); err != nil { + t.Error(err) + } + }() + result, err := b.Replace(ctx, binding, verdictPlan()) + if err != nil || !result.Committed || result.ProtectionUnconfirmed { + t.Fatal("fresh replacement cannot recover failed withdrawal", result, err) + } + if len(e.instances) != 2 || len(s.leases) != 2 || s.leases[0].closed != 1 || s.leases[1].closed != 0 || b.records[key(binding)] == old { + t.Fatal("recovery did not retire old ownership before creating new record") + } +} + +// Signal the lock wait after lockRecord has captured its registry identity and +// drain sequence. This makes the queued-before-withdraw ordering deterministic. +type fullRecordWaitContext struct { + context.Context + checks int + waiting chan struct{} +} + +func (c *fullRecordWaitContext) Err() error { + c.checks++ + if c.checks == 2 { + close(c.waiting) + } + return c.Context.Err() +} +func TestFullBackendPredrainWaiterCannotReopenRetirement(t *testing.T) { + _, _, _, binding := fullBackendFixture(t) + e, s := &controllerIdentityEngine{}, &controllerIdentitySource{} + b, err := NewFull(e, s, nil, nil) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + if _, err := b.Replace(ctx, binding, verdictPlan()); err != nil { + t.Fatal(err) + } + defer func() { + if err := b.Close(ctx); err != nil { + t.Error(err) + } + }() + old := b.records[key(binding)] + old.mu.Lock() + queued := &fullRecordWaitContext{Context: ctx, waiting: make(chan struct{})} + done := make(chan error, 1) + go func() { + r, _, err := b.lockRecord(queued, binding, true) + if err == nil { + b.unlockRecord(key(binding), r) + } + done <- err + }() + <-queued.waiting + bounded, cancel := context.WithTimeout(ctx, 20*time.Millisecond) + result, err := b.Withdraw(bounded, binding) + cancel() + old.mu.Unlock() + if !errors.Is(err, context.DeadlineExceeded) || result.Committed { + t.Fatal(result, err) + } + if err := <-done; err == nil { + t.Fatal("pre-drain waiter admitted after withdrawal") + } + if e.instances[0].stops != 0 { + t.Fatal("timed-out withdrawal stopped locked owner") + } + replacement, err := b.Replace(ctx, binding, verdictPlan()) + if err != nil || !replacement.Committed || replacement.ProtectionUnconfirmed || len(e.instances) != 2 || b.records[key(binding)] == old || s.leases[0].closed != 1 { + t.Fatal("fresh request failed to recover timed-out drain", replacement, err) + } +} diff --git a/pkg/sandboxpolicy/igbackend/fullengine.go b/pkg/sandboxpolicy/igbackend/fullengine.go index 3466e0671d..f055231a8a 100644 --- a/pkg/sandboxpolicy/igbackend/fullengine.go +++ b/pkg/sandboxpolicy/igbackend/fullengine.go @@ -22,11 +22,22 @@ type FullStartConfig struct { // FullEngine prepares one persistent owner without activating its first policy. // Install event provenance BEFORE Start and Activate. A non-nil instance returned // with an error still owns resources: retain it and retry Stop until successful. +// Start may run concurrently for distinct bindings. Instance operations are +// serialized per binding; Close runs only after all Start/instance work joins. // Event callbacks must not reenter lifecycle methods or block on their locks. type FullEngine interface { Start(context.Context, string, FullStartConfig, func(RawFullVerdict)) (FullInstance, error) } +// FullEngineCloser optionally owns node-wide resources shared by instances. +// The backend calls Close only after every instance and untransferred lease has +// drained. An error retains ownership for retry; success is called only once. +// Close must not release the borrowed runtime or binding source: their caller +// closes those only after backend Close succeeds. Legacy engines need no Close. +type FullEngineCloser interface { + Close(context.Context) error +} + // FullInstance reloads the same kernel owner; it never replaces the gadget. // A publication error may have Committed=true. Resume the same pending generation // rather than creating another owner or rolling back successful role epochs. From a7d2920a581feff889cf925db7014b6b14a77e23 Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Thu, 29 Oct 2026 17:59:39 +0100 Subject: [PATCH 14/23] test(sandboxpolicy): cover re-enable after failed withdrawal Bound backend ownership operations per binding while retaining drain and failed-withdrawal recovery. This is a concurrency/lifecycle prerequisite only: no shared native implementation, dependency pin change, default selection, or production shared BPF collection claim. Signed-off-by: Matthias Bertschy --- .../igbackend/controller_identity_test.go | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/pkg/sandboxpolicy/igbackend/controller_identity_test.go b/pkg/sandboxpolicy/igbackend/controller_identity_test.go index 7a36c48b59..ef0469ccbb 100644 --- a/pkg/sandboxpolicy/igbackend/controller_identity_test.go +++ b/pkg/sandboxpolicy/igbackend/controller_identity_test.go @@ -294,3 +294,41 @@ func TestControllerBindingFailureNeverResumesUncommittedCandidate(t *testing.T) t.Fatal("healthy read-only attestation changed owner or failed to confirm retained revision") } } + +func TestControllerFullBackendReenableAfterFailedWithdrawal(t *testing.T) { + c, b, e, s, w, client, binding := controllerIdentityFixture(t) + ctx := context.Background() + old := b.records[key(binding)] + owner := e.instances[0] + policy, err := client.Resource(sandboxpolicy.PolicyGVR).Namespace("ns").Get(ctx, "policy", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + updateMode := func(mode string) { + t.Helper() + policy.SetGeneration(policy.GetGeneration() + 1) + if err := unstructured.SetNestedField(policy.Object, mode, "spec", "mode"); err != nil { + t.Fatal(err) + } + if _, err := client.Resource(sandboxpolicy.PolicyGVR).Namespace("ns").Update(ctx, policy, metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + c.ModifyHandler(ctx, policy) + } + owner.failStop = true + updateMode("off") + if c.LastError() == nil || len(e.instances) != 1 || s.leases[0].closed != 0 || w.report.Status.Conditions[0].Status != metav1.ConditionFalse { + t.Fatal("failed withdrawal did not retain unconfirmed ownership", c.LastError()) + } + owner.failStop = false + updateMode("enforce") + if c.LastError() != nil { + t.Fatal("same-UID re-enable failed to recover retirement", c.LastError()) + } + if len(e.instances) != 2 || len(s.leases) != 2 || s.leases[0].closed != 1 || s.leases[1].closed != 0 || b.records[key(binding)] == old || w.report.Status.Conditions[0].Status != metav1.ConditionTrue { + t.Fatal("re-enabled policy did not acquire fresh, confirmed ownership") + } + if err := c.Reconcile(ctx); err != nil || len(e.instances) != 2 { + t.Fatal("recovered owner failed stable reconciliation", err) + } +} From 070e70f4e2b4b4365e62ec96217f393d76082407 Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Fri, 30 Oct 2026 18:44:53 +0100 Subject: [PATCH 15/23] fix(sandboxpolicy): isolate runtime evidence context ownership Signed-off-by: Matthias Bertschy --- .../igbackend/evidence_context.go | 83 +++++ .../igbackend/evidence_context_test.go | 310 ++++++++++++++++++ pkg/sandboxpolicy/igbackend/imageevidence.go | 2 + 3 files changed, 395 insertions(+) create mode 100644 pkg/sandboxpolicy/igbackend/evidence_context.go create mode 100644 pkg/sandboxpolicy/igbackend/evidence_context_test.go diff --git a/pkg/sandboxpolicy/igbackend/evidence_context.go b/pkg/sandboxpolicy/igbackend/evidence_context.go new file mode 100644 index 0000000000..ef4fd8ac95 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/evidence_context.go @@ -0,0 +1,83 @@ +package igbackend + +import ( + "context" + "sync" + "time" +) + +// evidenceContext is owned solely by the provider. Containerd readers and gRPC +// may retain it after Evidence returns, so it must not reference the SDK caller, +// its values, the bridge, or the finish closure. Each observation gets a new one. +type evidenceContext struct { + deadline time.Time + hasDeadline bool + done chan struct{} + terminal sync.Once + err error // immutable after done closes +} + +func (c *evidenceContext) Deadline() (time.Time, bool) { return c.deadline, c.hasDeadline } +func (c *evidenceContext) Done() <-chan struct{} { return c.done } +func (c *evidenceContext) Value(any) any { return nil } +func (c *evidenceContext) Err() error { + select { + case <-c.done: + return c.err + default: + return nil + } +} +func (c *evidenceContext) cancel(err error) { + // Context.Err has only these two terminal values. Do not retain an arbitrary + // caller error graph, even if a nonconforming Context supplies one. + terminal := context.Canceled + if err == context.DeadlineExceeded { + terminal = context.DeadlineExceeded + } + c.terminal.Do(func() { c.err = terminal; close(c.done) }) +} + +// No independent deadline timer races the caller's committed cancellation +// reason. Copy deadline metadata for downstream RPC deadlines, and relay the +// caller's authoritative Done/Err (including already-canceled callers). +// finish cancels the provider context and joins all bridge access to the caller; +// it does NOT join containerd/gRPC's asynchronous tails or bound their memory. +func newEvidenceContext(caller context.Context) (context.Context, func()) { + deadline, hasDeadline := caller.Deadline() + if hasDeadline { + // A shallow time.Time copy can retain an arbitrarily large caller-owned + // Location. UTC preserves the absolute instant without that reference. + // It strips monotonic metadata: downstream deadline calculations use + // wall time, not exact monotonic equivalence. Caller Done/Err remains + // authoritative through the joined bridge below. + deadline = deadline.UTC() + } else { + deadline = time.Time{} + } + owned := &evidenceContext{deadline: deadline, hasDeadline: hasDeadline, done: make(chan struct{})} + callerDone := caller.Done() + if err := caller.Err(); err != nil { + owned.cancel(err) + } + stop, joined := make(chan struct{}), make(chan struct{}) + go func() { + defer close(joined) + select { + case <-callerDone: + owned.cancel(caller.Err()) + case <-stop: + } + }() + var once sync.Once + finish := func() { + once.Do(func() { + // Observe an already-committed deadline/cancel even if the bridge has not + // run yet. Finishing a still-live observation is our own cancellation. + owned.cancel(caller.Err()) + close(stop) + }) + <-joined + } + return owned, finish +} diff --git a/pkg/sandboxpolicy/igbackend/evidence_context_test.go b/pkg/sandboxpolicy/igbackend/evidence_context_test.go new file mode 100644 index 0000000000..9d06e6236e --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/evidence_context_test.go @@ -0,0 +1,310 @@ +package igbackend + +import ( + "context" + "errors" + "fmt" + "go/ast" + "go/parser" + "go/token" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/containerd/containerd" + "github.com/containerd/containerd/containers" + "github.com/containerd/containerd/namespaces" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" +) + +type evidenceCaller struct { + context.Context + deadline time.Time + finished atomic.Bool + late atomic.Int32 +} + +func (c *evidenceCaller) touch() { + if c.finished.Load() { + c.late.Add(1) + } +} +func (c *evidenceCaller) Value(any) any { panic("caller Value must never be consulted") } +func (c *evidenceCaller) Deadline() (time.Time, bool) { + c.touch() + if !c.deadline.IsZero() { + return c.deadline, true + } + return c.Context.Deadline() +} +func (c *evidenceCaller) Done() <-chan struct{} { c.touch(); return c.Context.Done() } +func (c *evidenceCaller) Err() error { c.touch(); return c.Context.Err() } + +func TestEvidenceContextDetachedValuesAndFinish(t *testing.T) { + caller := &evidenceCaller{Context: context.Background(), deadline: time.Now().Add(time.Hour)} + owned, finish := newEvidenceContext(caller) + downstream := namespaces.WithNamespace(owned, "k8s.io") + if downstream.Value(struct{}{}) != nil { + t.Fatal("caller values retained") + } + if ns, ok := namespaces.Namespace(downstream); !ok || ns != "k8s.io" { + t.Fatal("trusted namespace missing") + } + if d, ok := downstream.Deadline(); !ok || !d.Equal(caller.deadline) { + t.Fatal("deadline metadata lost") + } + finish() + caller.finished.Store(true) + finish() + if downstream.Err() != context.Canceled { + t.Fatal("retained downstream context not canceled") + } + _ = downstream.Value(struct{}{}) + _, _ = downstream.Deadline() + <-downstream.Done() + if caller.late.Load() != 0 { + t.Fatal("caller accessed after finish") + } +} + +func TestEvidenceContextCancellationReasons(t *testing.T) { + for _, kind := range []string{"canceled", "deadline", "canceled-before-expired-deadline"} { + t.Run(kind, func(t *testing.T) { + parent, cancel := context.WithCancel(context.Background()) + defer cancel() + expected := context.Canceled + caller := &evidenceCaller{Context: parent} + if kind == "deadline" { + expired, stop := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer stop() + caller.Context = expired + expected = context.DeadlineExceeded + } else { + cancel() + if kind == "canceled-before-expired-deadline" { + caller.deadline = time.Now().Add(-time.Second) + } + } + owned, finish := newEvidenceContext(caller) + defer finish() + if owned.Err() != expected { + t.Fatalf("reason changed: %v", owned.Err()) + } + finish() + if owned.Err() != expected { + t.Fatal("finish replaced cancellation reason") + } + }) + } + for range 100 { + parent, cancel := context.WithCancel(context.Background()) + caller := &evidenceCaller{Context: parent} + owned, finish := newEvidenceContext(caller) + cancel() + finish() + caller.finished.Store(true) + if owned.Err() != context.Canceled || caller.late.Load() != 0 { + t.Fatal("cancellation/finish race") + } + } + parent, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + owned, finish := newEvidenceContext(&evidenceCaller{Context: parent}) + defer finish() + select { + case <-owned.Done(): + case <-time.After(time.Second): + t.Fatal("deadline did not propagate") + } + if owned.Err() != context.DeadlineExceeded { + t.Fatal("deadline reason lost") + } +} + +type evidenceRetainingStore struct { + containers.Store + retained context.Context + err error +} + +func (s *evidenceRetainingStore) Get(ctx context.Context, _ string) (containers.Container, error) { + s.retained = ctx + return containers.Container{}, s.err +} +func TestEvidenceRealEntryDetachesAcquisitionAndReattestation(t *testing.T) { + store := &evidenceRetainingStore{err: errors.New("observation unavailable")} + client, err := containerd.New("", containerd.WithServices(containerd.WithContainerStore(store))) + if err != nil { + t.Fatal(err) + } + source := &containerdImageSource{client: client} + process := &linuxLease{identity: sandboxpolicy.Binding{Runtime: "containerd", ContainerID: strings.Repeat("a", 64), PID: 42}} + for _, reattest := range []bool{false, true} { + caller := &evidenceCaller{Context: context.Background()} + store.retained = nil + if reattest { + lease := &trustedImageLease{source: source, process: process} + err = lease.CheckContext(caller) + } else { + _, err = acquireImageEvidence(caller, source, process, "/", ImageIndexLimits{}) + } + if err == nil || store.retained == nil { + t.Fatal("actual source boundary not exercised") + } + caller.finished.Store(true) + if store.retained.Err() != context.Canceled || store.retained.Value(struct{}{}) != nil { + t.Fatal("real Evidence leaked caller context") + } + if namespace, ok := namespaces.Namespace(store.retained); !ok || namespace != "k8s.io" { + t.Fatal("trusted namespace missing") + } + if caller.late.Load() != 0 { + t.Fatal("bridge accessed caller after Evidence returned") + } + } + caller := &evidenceCaller{Context: context.Background()} + if _, err := source.Evidence(caller, "invalid"); err == nil { + t.Fatal("invalid ID accepted") + } + caller.finished.Store(true) + if caller.late.Load() != 0 { + t.Fatal("early-return bridge not joined") + } +} + +type evidenceBlockingCaller struct { + context.Context + calls atomic.Int32 + entered, release chan struct{} + finished atomic.Bool + late atomic.Int32 +} + +func (c *evidenceBlockingCaller) Err() error { + if c.finished.Load() { + c.late.Add(1) + } + if c.calls.Add(1) == 2 { + close(c.entered) + <-c.release + } + return c.Context.Err() +} +func TestEvidenceContextFinishJoinsCallerAccess(t *testing.T) { + parent, cancel := context.WithCancel(context.Background()) + defer cancel() + caller := &evidenceBlockingCaller{Context: parent, entered: make(chan struct{}), release: make(chan struct{})} + owned, finish := newEvidenceContext(caller) + cancel() + <-caller.entered + finished := make(chan struct{}) + go func() { finish(); close(finished) }() + select { + case <-finished: + t.Fatal("finish returned while bridge still accesses caller") + case <-time.After(20 * time.Millisecond): + } + close(caller.release) + select { + case <-finished: + case <-time.After(time.Second): + t.Fatal("bridge did not join") + } + caller.finished.Store(true) + finish() + if owned.Err() != context.Canceled || caller.late.Load() != 0 { + t.Fatal("caller access after joined return") + } +} + +func TestEvidenceContextHasNoCallerReferenceFields(t *testing.T) { + typ := reflect.TypeFor[evidenceContext]() + for i := 0; i < typ.NumField(); i++ { + f := typ.Field(i) + if f.Type.Implements(reflect.TypeFor[context.Context]()) || f.Type.Kind() == reflect.Func || f.Type.Kind() == reflect.Pointer { + t.Fatalf("provider context retains caller/bridge-capable field %s", f.Name) + } + } +} + +func TestEvidenceContextRealEntrySourceGuard(t *testing.T) { + file, err := parser.ParseFile(token.NewFileSet(), "imageevidence.go", nil, 0) + if err != nil { + t.Fatal(err) + } + var evidence *ast.FuncDecl + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == "Evidence" { + evidence = fn + } + } + if evidence == nil || len(evidence.Body.List) < 3 { + t.Fatal("missing real Evidence entry") + } + assignment, ok := evidence.Body.List[0].(*ast.AssignStmt) + if !ok || len(assignment.Lhs) != 2 || len(assignment.Rhs) != 1 { + t.Fatal("Evidence does not detach on entry") + } + call, ok := assignment.Rhs[0].(*ast.CallExpr) + if !ok { + t.Fatal("missing context constructor") + } + name, ok := call.Fun.(*ast.Ident) + if !ok || name.Name != "newEvidenceContext" { + t.Fatal("wrong context constructor") + } + left, ok := assignment.Lhs[0].(*ast.Ident) + if !ok || left.Name != "ctx" { + t.Fatal("downstream context not replaced") + } + finish, ok := assignment.Lhs[1].(*ast.Ident) + if !ok { + t.Fatal("missing finish owner") + } + deferred, ok := evidence.Body.List[1].(*ast.DeferStmt) + if !ok { + t.Fatal("early returns can escape without join") + } + joined, ok := deferred.Call.Fun.(*ast.Ident) + if !ok || joined.Name != finish.Name { + t.Fatal("wrong deferred owner") + } +} + +type evidenceForeignDeadline struct { + context.Context + deadline time.Time + present bool +} + +func (c evidenceForeignDeadline) Deadline() (time.Time, bool) { return c.deadline, c.present } +func TestEvidenceContextDeadlineDoesNotRetainForeignLocation(t *testing.T) { + location := time.FixedZone(strings.Repeat("caller-owned-zone-", 1<<16), 3600) + deadline := time.Now().Add(time.Hour).In(location) + for _, present := range []bool{true, false} { + t.Run(fmt.Sprint(present), func(t *testing.T) { + owned, finish := newEvidenceContext(evidenceForeignDeadline{Context: context.Background(), deadline: deadline, present: present}) + defer finish() + got, ok := owned.Deadline() + if ok != present { + t.Fatal("deadline presence changed") + } + if got.Location() == location { + t.Fatal("provider deadline retains caller-owned location") + } + if present { + if !got.Equal(deadline) || got.Location() != time.UTC { + t.Fatal("normalized deadline changed instant or retained zone") + } + } else if !got.IsZero() { + t.Fatal("absent deadline retains unused metadata") + } + finish() + if owned.Err() != context.Canceled { + t.Fatal("normalization changed finish cancellation") + } + }) + } +} diff --git a/pkg/sandboxpolicy/igbackend/imageevidence.go b/pkg/sandboxpolicy/igbackend/imageevidence.go index fb017399e2..85731733fc 100644 --- a/pkg/sandboxpolicy/igbackend/imageevidence.go +++ b/pkg/sandboxpolicy/igbackend/imageevidence.go @@ -79,6 +79,8 @@ func newContainerdImageSource(socket string) (*containerdImageSource, error) { } func (s *containerdImageSource) Close() error { return s.client.Close() } func (s *containerdImageSource) Evidence(ctx context.Context, id string) (containerdImageEvidence, error) { + ctx, finish := newEvidenceContext(ctx) + defer finish() var result containerdImageEvidence if !fullContainerID(id) { return result, fmt.Errorf("exact 64-character containerd container ID required") From 335919aa3d8fa4fab393a7438ef0f65f2c25c235 Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Fri, 30 Oct 2026 18:45:04 +0100 Subject: [PATCH 16/23] fix(sandboxpolicy): bound runtime evidence before decoding Signed-off-by: Matthias Bertschy --- .../igbackend/evidence_limits.go | 214 +++++++++ .../igbackend/evidence_limits_test.go | 412 ++++++++++++++++++ pkg/sandboxpolicy/igbackend/imageevidence.go | 6 +- 3 files changed, 629 insertions(+), 3 deletions(-) create mode 100644 pkg/sandboxpolicy/igbackend/evidence_limits.go create mode 100644 pkg/sandboxpolicy/igbackend/evidence_limits_test.go diff --git a/pkg/sandboxpolicy/igbackend/evidence_limits.go b/pkg/sandboxpolicy/igbackend/evidence_limits.go new file mode 100644 index 0000000000..083deb8e1f --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/evidence_limits.go @@ -0,0 +1,214 @@ +package igbackend + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "sort" + + "github.com/containerd/containerd" + "github.com/containerd/containerd/content" + "github.com/containerd/containerd/images" + "github.com/containerd/errdefs" + "github.com/containerd/platforms" + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + specs "github.com/opencontainers/runtime-spec/specs-go" +) + +// These are supported-input limits, not a heap or compiler-memory allowance. +// Raw OCI specs now have the same 1MiB ceiling as the existing encoded checks. +// Image metadata admits at most 16MiB per blob AND in aggregate per observation, +// counting repeated reads and inspected inline data, including failed attempts. +const ( + evidenceSpecLimit = 1 << 20 + evidenceMetadataLimit = 16 << 20 +) + +var errEvidenceMetadataLimit = errors.New("runtime evidence metadata budget exceeded") + +func boundedContainerSpec(ctx context.Context, c containerd.Container) (*specs.Spec, error) { + // Refresh at the same point as containerd.Container.Spec; earlier Info is not + // an interchangeable snapshot. Admission precedes JSON decoding, not the RPC. + info, err := c.Info(ctx) + if err != nil { + return nil, err + } + raw := info.Spec.GetValue() + if len(raw) > evidenceSpecLimit { + return nil, errEvidenceMetadataLimit + } + var spec specs.Spec + if err := json.Unmarshal(raw, &spec); err != nil { + return nil, err + } + return &spec, nil +} + +type evidenceMetadataBudget struct{ used int64 } + +func (b *evidenceMetadataBudget) admit(size int64) error { + if size < 0 || size > evidenceMetadataLimit || size > evidenceMetadataLimit-b.used { + return errEvidenceMetadataLimit + } + b.used += size + return nil +} +func (b *evidenceMetadataBudget) read(ctx context.Context, p content.Provider, d ocispec.Descriptor) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + // Nested Data was allocated by decoding its admitted parent. This check is + // before inspection/use, not before that parent decoder's allocation. + if err := b.admit(int64(len(d.Data))); err != nil { + return nil, err + } + if int64(len(d.Data)) == d.Size && digest.FromBytes(d.Data) == d.Digest { + return d.Data, nil + } + ra, err := p.ReaderAt(ctx, d) + if err != nil { + return nil, err + } + defer func() { _ = ra.Close() }() + size := ra.Size() + // The actual content-store size controls allocation, not the descriptor. + // The admitted maximum fits int on every supported Go architecture. + if err := b.admit(size); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + raw := make([]byte, int(size)) + n, err := ra.ReadAt(raw, 0) + if err == io.EOF { + if int64(n) != size { + err = io.ErrUnexpectedEOF + } else { + err = nil + } + } + return raw, err +} + +// Reuse containerd's media-type validation and decoders without allowing them +// to fetch unadmitted content. This replay may make a bounded additional copy; +// the input budget is not a claim about total decoder allocation. +type admittedEvidenceBlob struct { + digest digest.Digest + raw []byte +} + +func (p admittedEvidenceBlob) ReaderAt(_ context.Context, d ocispec.Descriptor) (content.ReaderAt, error) { + if d.Digest != p.digest { + return nil, errdefs.ErrNotFound + } + return admittedEvidenceReader{Reader: bytes.NewReader(p.raw)}, nil +} + +type admittedEvidenceReader struct{ *bytes.Reader } + +func (r admittedEvidenceReader) Size() int64 { return r.Reader.Size() } +func (admittedEvidenceReader) Close() error { return nil } +func admittedEvidenceDescriptor(d ocispec.Descriptor) ocispec.Descriptor { + // Force decoder reads through the exact admitted bytes, including descriptors + // whose inline content was invalid and therefore fell back to the store. + d.Data = nil + d.Size = -1 + return d +} +func boundedImageRootFS(ctx context.Context, image containerd.Image) ([]digest.Digest, error) { + return boundedRootFS(ctx, image.ContentStore(), image.Target(), image.Platform()) +} + +// Selection follows containerd v1.7 images.Manifest. Keep differential fixtures +// in step with dependency upgrades; Walk supplies its 10,000-reference bound. +func boundedRootFS(ctx context.Context, p content.Provider, target ocispec.Descriptor, matcher platforms.MatchComparer) ([]digest.Digest, error) { + budget := evidenceMetadataBudget{} + var config ocispec.Descriptor + found, wasIndex := false, false + err := images.Walk(ctx, images.HandlerFunc(func(ctx context.Context, d ocispec.Descriptor) ([]ocispec.Descriptor, error) { + switch d.MediaType { + case images.MediaTypeDockerSchema2Manifest, ocispec.MediaTypeImageManifest, images.MediaTypeDockerSchema2ManifestList, ocispec.MediaTypeImageIndex: + default: + return nil, fmt.Errorf("unexpected media type %v for %v: %w", d.MediaType, d.Digest, errdefs.ErrNotFound) + } + raw, err := budget.read(ctx, p, d) + if err != nil { + return nil, err + } + children, err := images.Children(ctx, admittedEvidenceBlob{digest: d.Digest, raw: raw}, admittedEvidenceDescriptor(d)) + if err != nil { + return nil, err + } + switch d.MediaType { + case images.MediaTypeDockerSchema2Manifest, ocispec.MediaTypeImageManifest: + candidate := children[0] // Children always returns the config first, even if absent. + if d.Digest != target.Digest && matcher != nil { + if d.Platform != nil && !matcher.Match(*d.Platform) { + return nil, nil + } + if d.Platform == nil { + raw, err := budget.read(ctx, p, candidate) + if err != nil { + return nil, err + } + var image ocispec.Image + if err := json.Unmarshal(raw, &image); err != nil { + return nil, err + } + if !matcher.Match(platforms.Normalize(ocispec.Platform{OS: image.OS, Architecture: image.Architecture})) { + return nil, nil + } + } + } + if !found { + config = candidate + found = true + } + return nil, nil + default: + if matcher == nil { + return children, nil + } + selected := children[:0] + for _, d := range children { + if d.Platform == nil || matcher.Match(*d.Platform) { + selected = append(selected, d) + } + } + sort.SliceStable(selected, func(i, j int) bool { + if selected[i].Platform == nil { + return false + } + if selected[j].Platform == nil { + return true + } + return matcher.Less(*selected[i].Platform, *selected[j].Platform) + }) + wasIndex = true + if len(selected) > 1 { + selected = selected[:1] + } + return selected, nil + } + }), target) + if err != nil { + return nil, err + } + if !found { + if wasIndex { + return nil, fmt.Errorf("no match for platform in manifest %v: %w", target.Digest, errdefs.ErrNotFound) + } + return nil, fmt.Errorf("manifest %v: %w", target.Digest, errdefs.ErrNotFound) + } + raw, err := budget.read(ctx, p, config) + if err != nil { + return nil, err + } + return images.RootFS(ctx, admittedEvidenceBlob{digest: config.Digest, raw: raw}, admittedEvidenceDescriptor(config)) +} diff --git a/pkg/sandboxpolicy/igbackend/evidence_limits_test.go b/pkg/sandboxpolicy/igbackend/evidence_limits_test.go new file mode 100644 index 0000000000..d459dc2b3d --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/evidence_limits_test.go @@ -0,0 +1,412 @@ +package igbackend + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "go/ast" + "go/parser" + "go/token" + "io" + "strings" + "testing" + + "github.com/containerd/containerd" + tasks "github.com/containerd/containerd/api/services/tasks/v1" + tasktypes "github.com/containerd/containerd/api/types/task" + "github.com/containerd/containerd/containers" + "github.com/containerd/containerd/content" + "github.com/containerd/containerd/images" + "github.com/containerd/errdefs" + "github.com/containerd/platforms" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" + "github.com/opencontainers/go-digest" + specs "github.com/opencontainers/image-spec/specs-go" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/anypb" +) + +type limitReader struct { + data []byte + size int64 + reads, closes int + readErr error +} + +func (r *limitReader) Size() int64 { return r.size } +func (r *limitReader) Close() error { r.closes++; return nil } +func (r *limitReader) ReadAt(p []byte, off int64) (int, error) { + r.reads++ + n, e := bytes.NewReader(r.data).ReadAt(p, off) + if r.readErr != nil { + return n, r.readErr + } + return n, e +} + +type limitProvider struct { + reader *limitReader + opens int +} + +func (p *limitProvider) ReaderAt(context.Context, ocispec.Descriptor) (content.ReaderAt, error) { + p.opens++ + return p.reader, nil +} + +func TestEvidenceMetadataReaderAdmission(t *testing.T) { + ctx := context.Background() + for _, size := range []int64{-1, evidenceMetadataLimit + 1, 1 << 62} { + p := &limitProvider{reader: &limitReader{size: size}} + b := evidenceMetadataBudget{} + _, err := b.read(ctx, p, ocispec.Descriptor{}) + require.ErrorIs(t, err, errEvidenceMetadataLimit) + require.Zero(t, p.reader.reads) + require.Equal(t, 1, p.reader.closes) + } + p := &limitProvider{reader: &limitReader{data: make([]byte, evidenceMetadataLimit), size: evidenceMetadataLimit}} + b := evidenceMetadataBudget{} + got, err := b.read(ctx, p, ocispec.Descriptor{Size: 1}) + require.NoError(t, err) + require.Len(t, got, evidenceMetadataLimit) + require.Equal(t, int64(evidenceMetadataLimit), b.used) + p.reader = &limitReader{data: []byte("x"), size: 1} + _, err = b.read(ctx, p, ocispec.Descriptor{}) + require.ErrorIs(t, err, errEvidenceMetadataLimit) + require.Zero(t, p.reader.reads) + require.Equal(t, 1, p.reader.closes) + for _, tc := range []struct { + name string + data string + size int64 + readErr, want error + }{ + {"short", "x", 2, nil, io.ErrUnexpectedEOF}, {"exact-eof", "xx", 2, io.EOF, nil}, {"failed", "xx", 2, io.ErrClosedPipe, io.ErrClosedPipe}, + } { + t.Run(tc.name, func(t *testing.T) { + p := &limitProvider{reader: &limitReader{data: []byte(tc.data), size: tc.size, readErr: tc.readErr}} + b := evidenceMetadataBudget{} + _, err := b.read(ctx, p, ocispec.Descriptor{Size: 999}) + require.ErrorIs(t, err, tc.want) + require.Equal(t, tc.size, b.used) + require.Equal(t, 1, p.reader.closes) + }) + } + canceled, cancel := context.WithCancel(ctx) + cancel() + p = &limitProvider{reader: &limitReader{size: 1}} + _, err = (&evidenceMetadataBudget{}).read(canceled, p, ocispec.Descriptor{}) + require.ErrorIs(t, err, context.Canceled) + require.Zero(t, p.opens) +} +func TestEvidenceMetadataInlineAdmission(t *testing.T) { + data := []byte("config") + desc := ocispec.Descriptor{Data: data, Size: int64(len(data)), Digest: digest.FromBytes(data)} + p := &limitProvider{reader: &limitReader{data: []byte("store"), size: 5}} + b := evidenceMetadataBudget{} + got, err := b.read(context.Background(), p, desc) + require.NoError(t, err) + require.Equal(t, data, got) + require.Zero(t, p.opens) + desc.Size++ // Invalid inline still falls back, charging both inspected attempts. + got, err = b.read(context.Background(), p, desc) + require.NoError(t, err) + require.Equal(t, []byte("store"), got) + require.Equal(t, int64(17), b.used) + b.used = evidenceMetadataLimit - int64(len(data)) + 1 + _, err = b.read(context.Background(), p, desc) + require.ErrorIs(t, err, errEvidenceMetadataLimit) + require.Equal(t, 1, p.opens) + huge := make([]byte, evidenceMetadataLimit+1) + _, err = (&evidenceMetadataBudget{}).read(context.Background(), p, ocispec.Descriptor{Data: huge}) + require.ErrorIs(t, err, errEvidenceMetadataLimit) + require.Equal(t, 1, p.opens) +} + +type metadataFixture struct { + blobs map[digest.Digest][]byte + reads map[digest.Digest]int +} + +func newMetadataFixture() *metadataFixture { + return &metadataFixture{blobs: map[digest.Digest][]byte{}, reads: map[digest.Digest]int{}} +} +func (f *metadataFixture) ReaderAt(_ context.Context, d ocispec.Descriptor) (content.ReaderAt, error) { + b, ok := f.blobs[d.Digest] + if !ok { + return nil, errdefs.ErrNotFound + } + f.reads[d.Digest]++ + return &limitReader{data: b, size: int64(len(b))}, nil +} +func (f *metadataFixture) blob(mt string, b []byte) ocispec.Descriptor { + d := ocispec.Descriptor{MediaType: mt, Digest: digest.FromBytes(b), Size: int64(len(b))} + f.blobs[d.Digest] = b + return d +} +func (f *metadataFixture) document(t *testing.T, mt string, v any) ocispec.Descriptor { + t.Helper() + b, e := json.Marshal(v) + require.NoError(t, e) + return f.blob(mt, b) +} +func (f *metadataFixture) manifest(t *testing.T, arch, tag, mt string) ocispec.Descriptor { + c := f.document(t, ocispec.MediaTypeImageConfig, ocispec.Image{Platform: ocispec.Platform{OS: "linux", Architecture: arch}, RootFS: ocispec.RootFS{Type: "layers", DiffIDs: []digest.Digest{digest.FromString(tag)}}}) + return f.document(t, mt, ocispec.Manifest{Versioned: specs.Versioned{SchemaVersion: 2}, MediaType: mt, Config: c}) +} +func (f *metadataFixture) index(t *testing.T, ds ...ocispec.Descriptor) ocispec.Descriptor { + return f.document(t, ocispec.MediaTypeImageIndex, ocispec.Index{Versioned: specs.Versioned{SchemaVersion: 2}, Manifests: ds}) +} +func compareBoundedRootFS(t *testing.T, f *metadataFixture, d ocispec.Descriptor, m platforms.MatchComparer) { + t.Helper() + old, oldErr := (&images.Image{Target: d}).RootFS(context.Background(), f, m) + got, err := boundedRootFS(context.Background(), f, d, m) + if oldErr == nil { + require.NoError(t, err) + require.Equal(t, old, got) + } else { + require.Error(t, err) + require.Equal(t, errdefs.IsNotFound(oldErr), errdefs.IsNotFound(err)) + require.Equal(t, errdefs.IsResourceExhausted(oldErr), errdefs.IsResourceExhausted(err)) + } +} +func TestEvidenceRootFSDifferential(t *testing.T) { + for _, kind := range []string{"direct-oci", "direct-docker", "root-platform-ignored", "explicit-match", "no-match", "config-fallback", "config-mismatch", "stable-order", "nested", "nil-matcher", "nested-inline", "schema1", "manifest-index-confusion", "unknown", "malformed", "missing-config", "empty-index", "docker-index", "index-manifest-confusion", "invalid-inline-fallback", "nil-matcher-later-error", "inline-config"} { + t.Run(kind, func(t *testing.T) { + f := newMetadataFixture() + a := f.manifest(t, "amd64", "a", ocispec.MediaTypeImageManifest) + b := f.manifest(t, "amd64", "b", ocispec.MediaTypeImageManifest) + m := platforms.Only(ocispec.Platform{OS: "linux", Architecture: "amd64"}) + d := a + amd := &ocispec.Platform{OS: "linux", Architecture: "amd64"} + arm := &ocispec.Platform{OS: "linux", Architecture: "arm64"} + switch kind { + case "direct-docker": + d = f.manifest(t, "amd64", "docker", images.MediaTypeDockerSchema2Manifest) + case "root-platform-ignored": + d.Platform = arm + case "explicit-match": + a.Platform = amd + d = f.index(t, a) + case "no-match": + a.Platform = arm + d = f.index(t, a) + case "config-fallback": + d = f.index(t, a) + case "config-mismatch": + a = f.manifest(t, "arm64", "arm", ocispec.MediaTypeImageManifest) + d = f.index(t, a) + case "stable-order": + a.Platform = amd + b.Platform = amd + d = f.index(t, b, a) + case "nested": + d = f.index(t, f.index(t, a)) + case "nil-matcher": + m = nil + d = f.index(t, b, a) + case "nested-inline": + a.Data = f.blobs[a.Digest] + delete(f.blobs, a.Digest) + d = f.index(t, a) + case "schema1": + d = f.blob(ocispec.MediaTypeImageManifest, []byte(`{"fsLayers":[]}`)) + case "manifest-index-confusion": + d = f.blob(ocispec.MediaTypeImageManifest, []byte(`{"manifests":[]}`)) + case "unknown": + d.MediaType = "unknown" + case "malformed": + d = f.blob(ocispec.MediaTypeImageManifest, []byte(`{`)) + case "missing-config": + d = f.document(t, ocispec.MediaTypeImageManifest, ocispec.Manifest{}) + case "empty-index": + d = f.index(t) + case "docker-index": + d = f.document(t, images.MediaTypeDockerSchema2ManifestList, ocispec.Index{Versioned: specs.Versioned{SchemaVersion: 2}, Manifests: []ocispec.Descriptor{a}}) + case "index-manifest-confusion": + d = f.blob(ocispec.MediaTypeImageIndex, []byte(`{"config":{}}`)) + case "invalid-inline-fallback": + a.Data = []byte("invalid") + d = f.index(t, a) + case "nil-matcher-later-error": + m = nil + bad := f.blob(ocispec.MediaTypeImageManifest, []byte(`{`)) + d = f.index(t, a, bad) + case "inline-config": + c := f.document(t, ocispec.MediaTypeImageConfig, ocispec.Image{Platform: ocispec.Platform{OS: "linux", Architecture: "amd64"}, RootFS: ocispec.RootFS{DiffIDs: []digest.Digest{digest.FromString("inline")}}}) + c.Data = f.blobs[c.Digest] + delete(f.blobs, c.Digest) + a = f.document(t, ocispec.MediaTypeImageManifest, ocispec.Manifest{Config: c}) + d = f.index(t, a) + } + compareBoundedRootFS(t, f, d, m) + }) + } +} + +type specContainer struct { + containerd.Container + raw []byte + calls int +} + +func (c *specContainer) Info(context.Context, ...containerd.InfoOpts) (containers.Container, error) { + c.calls++ + return containers.Container{Spec: &anypb.Any{Value: c.raw}}, nil +} +func TestEvidenceSpecPredecode(t *testing.T) { + for _, n := range []int{evidenceSpecLimit, evidenceSpecLimit + 1} { + c := &specContainer{raw: []byte(`{}` + strings.Repeat(" ", n-2))} + got, err := boundedContainerSpec(context.Background(), c) + require.Equal(t, 1, c.calls) + if n == evidenceSpecLimit { + require.NoError(t, err) + require.NotNil(t, got) + } else { + require.ErrorIs(t, err, errEvidenceMetadataLimit) + require.Nil(t, got) + } + } + c := &specContainer{raw: bytes.Repeat([]byte("!"), evidenceSpecLimit+1)} + _, err := boundedContainerSpec(context.Background(), c) + require.ErrorIs(t, err, errEvidenceMetadataLimit) // Not JSON syntax error: refusal precedes decoding. + require.False(t, transientImageEvidenceRetrieval(err)) + var unavailable *imageEvidenceUnavailable + require.False(t, errors.As(err, &unavailable)) +} + +func TestEvidenceRootFSRepeatedConfigAndTraversalLimits(t *testing.T) { + f := newMetadataFixture() + raw := []byte(`{"architecture":"amd64","os":"linux","rootfs":{"type":"layers","diff_ids":[]}}` + strings.Repeat(" ", evidenceMetadataLimit/2)) + c := f.blob(ocispec.MediaTypeImageConfig, raw) + m := f.document(t, ocispec.MediaTypeImageManifest, ocispec.Manifest{Config: c}) + target := f.index(t, m) + _, err := boundedRootFS(context.Background(), f, target, platforms.Only(ocispec.Platform{OS: "linux", Architecture: "amd64"})) + require.ErrorIs(t, err, errEvidenceMetadataLimit) + require.Equal(t, 2, f.reads[c.Digest], "second reader is opened to obtain actual size, then refused before reading") + // This is the selected dependency's reference bound, not an added limit. + repeated := make([]ocispec.Descriptor, 10000) + for i := range repeated { + repeated[i] = m + } + target = f.index(t, repeated...) + compareBoundedRootFS(t, f, target, nil) + _, err = boundedRootFS(context.Background(), f, target, nil) + require.True(t, errdefs.IsResourceExhausted(err)) +} + +func TestEvidenceAdmittedBlobCannotFetchOtherContent(t *testing.T) { + p := admittedEvidenceBlob{digest: digest.FromString("one"), raw: []byte("one")} + _, err := p.ReaderAt(context.Background(), ocispec.Descriptor{Digest: digest.FromString("two")}) + require.ErrorIs(t, err, errdefs.ErrNotFound) +} + +// Real containerd services are substituted only at the transport boundary. +// Evidence, acquisition and re-attestation all execute their production code. +type limitContainerStore struct { + containers.Store + spec []byte + gets int +} + +func (s *limitContainerStore) Get(_ context.Context, id string) (containers.Container, error) { + s.gets++ + return containers.Container{ID: id, Image: "fixture", Spec: &anypb.Any{Value: s.spec}}, nil +} + +type limitTaskClient struct{ tasks.TasksClient } + +func (limitTaskClient) Get(_ context.Context, r *tasks.GetRequest, _ ...grpc.CallOption) (*tasks.GetResponse, error) { + return &tasks.GetResponse{Process: &tasktypes.Process{ID: r.ContainerID, Pid: 42, Status: tasktypes.Status_RUNNING}}, nil +} + +type limitImageStore struct { + images.Store + target ocispec.Descriptor +} + +func (s limitImageStore) Get(context.Context, string) (images.Image, error) { + return images.Image{Name: "fixture", Target: s.target}, nil +} + +type limitContentStore struct { + content.Store + fixture *metadataFixture + oversized digest.Digest + rejected *limitReader +} + +func (s *limitContentStore) ReaderAt(ctx context.Context, d ocispec.Descriptor) (content.ReaderAt, error) { + if d.Digest == s.oversized { + s.rejected = &limitReader{size: evidenceMetadataLimit + 1} + return s.rejected, nil + } + return s.fixture.ReaderAt(ctx, d) +} +func TestEvidenceLimitsRealEntryAcquisitionAndReattestation(t *testing.T) { + for _, kind := range []string{"spec", "config"} { + t.Run(kind, func(t *testing.T) { + fixture := newMetadataFixture() + config := fixture.blob(ocispec.MediaTypeImageConfig, []byte(`{}`)) + target := fixture.document(t, ocispec.MediaTypeImageManifest, ocispec.Manifest{Config: config}) + store := &limitContainerStore{spec: []byte(`{}`)} + cs := &limitContentStore{fixture: fixture} + if kind == "spec" { + store.spec = bytes.Repeat([]byte("!"), evidenceSpecLimit+1) + } else { + cs.oversized = config.Digest + } + client, err := containerd.New("", containerd.WithServices(containerd.WithContainerStore(store), containerd.WithTaskClient(limitTaskClient{}), containerd.WithImageStore(limitImageStore{target: target}), containerd.WithContentStore(cs))) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, client.Close()) }) + source := &containerdImageSource{client: client} + process := &linuxLease{identity: sandboxpolicy.Binding{Runtime: "containerd", ContainerID: strings.Repeat("a", 64), PID: 42}} + _, err = acquireImageEvidence(context.Background(), source, process, "/", ImageIndexLimits{}) + require.ErrorIs(t, err, errEvidenceMetadataLimit) + require.GreaterOrEqual(t, store.gets, 3, "Spec must refresh metadata at its original call point") + lease := &trustedImageLease{source: source, process: process, Complete: true} + err = lease.CheckContext(context.Background()) + require.ErrorIs(t, err, errEvidenceMetadataLimit) + require.False(t, lease.Complete) + require.Same(t, err, lease.failure) + before := store.gets + require.ErrorIs(t, lease.CheckContext(context.Background()), errEvidenceMetadataLimit) + require.Equal(t, before, store.gets, "unsupported metadata must be sticky, not transport-unavailable") + if kind == "config" { + require.NotNil(t, cs.rejected) + require.Zero(t, cs.rejected.reads) + require.Equal(t, 1, cs.rejected.closes) + } + }) + } +} + +func TestEvidenceLimitsRealCallSites(t *testing.T) { + f, err := parser.ParseFile(token.NewFileSet(), "imageevidence.go", nil, 0) + require.NoError(t, err) + seen := map[string]int{} + for _, decl := range f.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Name.Name != "Evidence" { + continue + } + ast.Inspect(fn.Body, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + if id, ok := call.Fun.(*ast.Ident); ok { + seen[id.Name]++ + } + if sel, ok := call.Fun.(*ast.SelectorExpr); ok && (sel.Sel.Name == "Spec" || sel.Sel.Name == "RootFS") { + t.Errorf("unbounded decoder in actual Evidence: %s", sel.Sel.Name) + } + return true + }) + } + require.Equal(t, 2, seen["boundedContainerSpec"]) + require.Equal(t, 1, seen["boundedImageRootFS"]) +} diff --git a/pkg/sandboxpolicy/igbackend/imageevidence.go b/pkg/sandboxpolicy/igbackend/imageevidence.go index 85731733fc..816259fdc5 100644 --- a/pkg/sandboxpolicy/igbackend/imageevidence.go +++ b/pkg/sandboxpolicy/igbackend/imageevidence.go @@ -102,7 +102,7 @@ func (s *containerdImageSource) Evidence(ctx context.Context, id string) (contai if err != nil { return result, err } - spec, err := container.Spec(ctx) + spec, err := boundedContainerSpec(ctx, container) if err != nil { return result, err } @@ -110,7 +110,7 @@ func (s *containerdImageSource) Evidence(ctx context.Context, id string) (contai if err != nil { return result, err } - diffs, err := image.RootFS(ctx) + diffs, err := boundedImageRootFS(ctx, image) if err != nil { return result, err } @@ -155,7 +155,7 @@ func (s *containerdImageSource) Evidence(ctx context.Context, id string) (contai if e != nil { return containerdImageEvidence{}, e } - sandboxSpec, e := sandbox.Spec(ctx) + sandboxSpec, e := boundedContainerSpec(ctx, sandbox) if e != nil { return containerdImageEvidence{}, e } From 90be9f5f6c2fecf796bcab660c64197c9b40662e Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Fri, 30 Oct 2026 18:45:15 +0100 Subject: [PATCH 17/23] fix(sandboxpolicy): bound overlay components before splitting Signed-off-by: Matthias Bertschy --- pkg/sandboxpolicy/igbackend/imageevidence.go | 5 +- .../igbackend/imageevidence_test.go | 49 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/pkg/sandboxpolicy/igbackend/imageevidence.go b/pkg/sandboxpolicy/igbackend/imageevidence.go index 816259fdc5..0cd82842ab 100644 --- a/pkg/sandboxpolicy/igbackend/imageevidence.go +++ b/pkg/sandboxpolicy/igbackend/imageevidence.go @@ -597,10 +597,11 @@ func parseOverlayEvidence(options []string) (overlayEvidence, error) { if !hasValue { return result, fmt.Errorf("missing lowerdir") } - result.Lower = strings.Split(value, ":") - if len(result.Lower) == 0 || len(result.Lower) > 128 { + // Bound the component slice before Split allocates it. + if strings.Count(value, ":") >= 128 { return result, fmt.Errorf("lowerdir budget exceeded") } + result.Lower = strings.Split(value, ":") for _, p := range result.Lower { if !simpleEvidencePath(p) { return result, fmt.Errorf("unsupported encoded/relative/data-only lowerdir") diff --git a/pkg/sandboxpolicy/igbackend/imageevidence_test.go b/pkg/sandboxpolicy/igbackend/imageevidence_test.go index 42e48bbe09..c9ebc2e7e1 100644 --- a/pkg/sandboxpolicy/igbackend/imageevidence_test.go +++ b/pkg/sandboxpolicy/igbackend/imageevidence_test.go @@ -618,3 +618,52 @@ func TestImageEvidenceSemanticSourceFailuresRemainSticky(t *testing.T) { }) } } + +func TestOverlayEvidenceLowerdirBoundaryAndErrorOrder(t *testing.T) { + paths := make([]string, 129) + for i := range paths { + paths[i] = fmt.Sprintf("/lower/%03d", i) + } + for _, count := range []int{1, 127, 128} { + t.Run(fmt.Sprintf("accepted-%d", count), func(t *testing.T) { + got, err := parseOverlayEvidence([]string{"lowerdir=" + strings.Join(paths[:count], ":"), "upperdir=/upper", "workdir=/work"}) + require.NoError(t, err) + require.Equal(t, paths[:count], got.Lower) + }) + } + oversized := "lowerdir=" + strings.Join(paths, ":") + tests := []struct { + name string + options []string + want string + }{ + {"129", []string{oversized}, "lowerdir budget exceeded"}, + {"empty", []string{"lowerdir="}, "unsupported encoded/relative/data-only lowerdir"}, + {"missing-value", []string{"lowerdir"}, "missing lowerdir"}, + {"malformed-128", []string{"lowerdir=relative:" + strings.Join(paths[:127], ":")}, "unsupported encoded/relative/data-only lowerdir"}, + {"malformed-129", []string{"lowerdir=relative:" + strings.Join(paths[:128], ":")}, "lowerdir budget exceeded"}, + {"empty-components-128", []string{"lowerdir=" + strings.Repeat(":", 127)}, "unsupported encoded/relative/data-only lowerdir"}, + {"empty-components-129", []string{"lowerdir=" + strings.Repeat(":", 128)}, "lowerdir budget exceeded"}, + {"duplicate-first", []string{"lowerdir=/valid", oversized}, "duplicate overlay option \"lowerdir\""}, + {"budget-first", []string{oversized, "lowerdir=/valid"}, "lowerdir budget exceeded"}, + {"earlier-upper", []string{"upperdir=relative", oversized}, "invalid upperdir"}, + {"earlier-unknown", []string{"unknown", oversized}, "unsupported overlay option \"unknown\""}, + {"overlap-128", []string{"lowerdir=" + strings.TrimSuffix(strings.Repeat("/lower:", 128), ":"), "upperdir=/upper", "workdir=/work"}, "overlapping overlay directories"}, + {"option-budget-first", append([]string{oversized}, make([]string, 32)...), "overlay option budget exceeded"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseOverlayEvidence(tt.options) + require.EqualError(t, err, tt.want) + }) + } +} + +func TestOverlayEvidenceOversizedLowerdirNotMaterialized(t *testing.T) { + for _, separators := range []int{128, 65536} { + got, err := parseOverlayEvidence([]string{"upperdir=/upper", "lowerdir=" + strings.Repeat(":", separators)}) + require.EqualError(t, err, "lowerdir budget exceeded") + require.Equal(t, "/upper", got.Upper) + require.Nil(t, got.Lower, "rejected lowerdir must not be split into a slice") + } +} From 091150137841edd8cdffdcdb75971ee360d757de Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Fri, 30 Oct 2026 18:45:27 +0100 Subject: [PATCH 18/23] feat(sandboxpolicy): expose retained shared container identity Signed-off-by: Matthias Bertschy --- .../igbackend/fulllease_linux.go | 22 ++++ .../igbackend/fulllease_linux_test.go | 108 ++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/pkg/sandboxpolicy/igbackend/fulllease_linux.go b/pkg/sandboxpolicy/igbackend/fulllease_linux.go index a39fd77777..4affbe97a1 100644 --- a/pkg/sandboxpolicy/igbackend/fulllease_linux.go +++ b/pkg/sandboxpolicy/igbackend/fulllease_linux.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" "sync" + "unicode/utf8" execruntime "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/runtime" "github.com/kubescape/node-agent/pkg/sandboxpolicy" @@ -171,6 +172,27 @@ func (l *containerdFullLease) Check(ctx context.Context) error { } return nil } + +// SharedContainerName returns the immutable name retained from the validated +// runtime/Kubernetes binding. The caller must first successfully Check the lease; +// this getter is not attestation and makes no runtime or filesystem observations. +// Its built-in signature permits the shared IG consumer to select this optional +// contract without changing the provider's dependency pin. +func (l *containerdFullLease) SharedContainerName() (string, error) { + l.mu.Lock() + defer l.mu.Unlock() + if l.closing || l.closed || l.process == nil || l.image == nil || l.host == nil { + return "", fmt.Errorf("full binding lease unavailable") + } + name := l.process.identity.ContainerName + // Match the shared compiler's 255-byte string domain, not a caller override + // or a separately decoded, potentially inconsistent CRI metadata name. + if name == "" || len(name) > 255 || !utf8.ValidString(name) || strings.IndexByte(name, 0) >= 0 { + return "", fmt.Errorf("bounded valid shared container name required") + } + return name, nil +} + func (l *containerdFullLease) InitProc() *os.File { l.mu.Lock() defer l.mu.Unlock() diff --git a/pkg/sandboxpolicy/igbackend/fulllease_linux_test.go b/pkg/sandboxpolicy/igbackend/fulllease_linux_test.go index 92029f9ab4..11fa7b7075 100644 --- a/pkg/sandboxpolicy/igbackend/fulllease_linux_test.go +++ b/pkg/sandboxpolicy/igbackend/fulllease_linux_test.go @@ -193,3 +193,111 @@ func TestFullLeaseIndexCheckPreservesCallerDeadline(t *testing.T) { index := &lowerIndexLease{} require.ErrorIs(t, index.CheckContext(ctx), context.Canceled) } + +// Built-in types let the IG consumer select this additive contract without a pin +// update merely to expose the provider method. +var _ interface{ SharedContainerName() (string, error) } = (*containerdFullLease)(nil) + +type sharedNameNoRPCSource struct{} + +func (sharedNameNoRPCSource) Evidence(context.Context, string) (containerdImageEvidence, error) { + panic("SharedContainerName must not observe the runtime") +} + +func sharedNameLease(binding sandboxpolicy.Binding) *containerdFullLease { + return &containerdFullLease{ + process: &linuxLease{identity: binding}, + image: &trustedImageLease{source: sharedNameNoRPCSource{}}, + host: &hostNamespacePins{}, + } +} + +func TestFullLeaseSharedContainerNameDomain(t *testing.T) { + for _, tc := range []struct { + name, value string + valid bool + }{ + {"ordinary", "application", true}, + {"exact-255", strings.Repeat("a", 255), true}, + {"over-256", strings.Repeat("a", 256), false}, + {"utf8-exact", strings.Repeat("é", 127) + "a", true}, + {"utf8-over", strings.Repeat("é", 128), false}, + {"empty", "", false}, + {"nul", "app\x00name", false}, + {"invalid-utf8", "app\xff", false}, + } { + t.Run(tc.name, func(t *testing.T) { + lease := sharedNameLease(sandboxpolicy.Binding{ContainerName: tc.value}) + got, err := lease.SharedContainerName() + if tc.valid { + require.NoError(t, err) + require.Equal(t, tc.value, got) + } else { + require.Error(t, err) + require.Empty(t, got) + } + }) + } +} + +func TestFullLeaseSharedContainerNameAvailability(t *testing.T) { + for _, kind := range []string{"closing", "closed", "process", "image", "host"} { + t.Run(kind, func(t *testing.T) { + lease := sharedNameLease(sandboxpolicy.Binding{ContainerName: "application"}) + switch kind { + case "closing": + lease.closing = true + case "closed": + lease.closed = true + case "process": + lease.process = nil + case "image": + lease.image = nil + case "host": + lease.host = nil + } + got, err := lease.SharedContainerName() + require.ErrorContains(t, err, "unavailable") + require.Empty(t, got) + }) + } +} + +func TestFullLeaseSharedContainerNameRetainsOriginalBinding(t *testing.T) { + binding := sandboxpolicy.Binding{ContainerName: "original"} + lease := sharedNameLease(binding) + binding.ContainerName = "replacement" + got, err := lease.SharedContainerName() + require.NoError(t, err) + require.Equal(t, "original", got) + require.NoError(t, lease.Close()) + require.Equal(t, "original", got, "borrowed immutable string survives caller retention") + got, err = lease.SharedContainerName() + require.ErrorContains(t, err, "unavailable") + require.Empty(t, got) +} + +func TestFullLeaseSharedContainerNameJoinsClose(t *testing.T) { + lease := sharedNameLease(sandboxpolicy.Binding{ContainerName: "original"}) + var wg sync.WaitGroup + var inconsistent atomic.Bool + start := make(chan struct{}) + for range 8 { + wg.Go(func() { + <-start + for range 100 { + got, err := lease.SharedContainerName() + if (err == nil && got != "original") || (err != nil && got != "") { + inconsistent.Store(true) + } + } + }) + } + close(start) + require.NoError(t, lease.Close()) + wg.Wait() + require.False(t, inconsistent.Load()) + got, err := lease.SharedContainerName() + require.Error(t, err) + require.Empty(t, got) +} From 6c8405a55ce3826bb11e6a3ec4201311c8922045 Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Fri, 30 Oct 2026 18:45:38 +0100 Subject: [PATCH 19/23] test(sandboxpolicy): cover canonical identity rejection boundaries Signed-off-by: Matthias Bertschy --- .../igbackend/identity_linux_test.go | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/pkg/sandboxpolicy/igbackend/identity_linux_test.go b/pkg/sandboxpolicy/igbackend/identity_linux_test.go index 1a22dc0d01..b3d5d0eebd 100644 --- a/pkg/sandboxpolicy/igbackend/identity_linux_test.go +++ b/pkg/sandboxpolicy/igbackend/identity_linux_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/kubescape/node-agent/pkg/sandboxpolicy" ) func TestLinuxValidatorRejectsIncompleteAndNonProcIdentity(t *testing.T) { @@ -56,3 +58,84 @@ func TestPinnedProcessStatParsing(t *testing.T) { t.Fatal("malformed stat accepted") } } + +func TestLinuxValidatorRejectsNonCanonicalCgroupBeforeProcAccess(t *testing.T) { + binding := sandboxpolicy.Binding{ + Runtime: "containerd", ContainerID: strings.Repeat("a", 64), PID: 123, + CgroupID: 1, MountNamespace: 1, CgroupPath: "/parent/container", + } + missing := filepath.Join(t.TempDir(), "missing") + validator := LinuxValidator{ProcRoot: missing, CgroupRoot: missing} + // A complete canonical identity gets past the early guard and fails only + // on the deliberately absent ordinary proc fixture path. + lease, err := validator.Acquire(context.Background(), binding) + if lease != nil || !os.IsNotExist(err) { + t.Fatalf("canonical control: lease=%v err=%v", lease, err) + } + for _, tc := range []struct{ name, path string }{ + {"root", "/"}, + {"relative", "parent/container"}, + {"dot", "/parent/./container"}, + {"dotdot", "/parent/child/../container"}, + {"repeated-separator", "/parent//container"}, + {"leading-separator-alias", "//parent/container"}, + {"trailing-separator", "/parent/container/"}, + {"root-dot-alias", "/."}, + } { + t.Run(tc.name, func(t *testing.T) { + candidate := binding + candidate.CgroupPath = tc.path + lease, err := validator.Acquire(context.Background(), candidate) + if lease != nil || err == nil || err.Error() != "complete non-root live container identity required" { + t.Fatalf("expected early canonical identity rejection, lease=%v err=%v", lease, err) + } + }) + } +} + +func TestLinuxLeaseRejectsCgroupMovementBeforeNamespaceAccess(t *testing.T) { + const start = "123456" + const expected = "/parent/container" + for _, tc := range []struct{ name, cgroup, want string }{ + {"moved-child", "0::" + expected + "/child\n", "process cgroup changed"}, + {"moved-sibling", "0::/parent/sibling\n", "process cgroup changed"}, + {"duplicate-unified", "0::" + expected + "\n0::" + expected + "\n", "process cgroup changed"}, + {"missing-unified", "2:cpu:" + expected + "\n3:memory:" + expected + "\n", "missing unified process cgroup"}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + fields := append([]string{"S"}, make([]string, 19)...) + for i := 1; i < len(fields); i++ { + fields[i] = "0" + } + fields[19] = start + if err := os.WriteFile(filepath.Join(root, "stat"), []byte("123 (fixture process) "+strings.Join(fields, " ")), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "cgroup"), []byte(tc.cgroup), 0600); err != nil { + t.Fatal(err) + } + proc, err := os.Open(root) + if err != nil { + t.Fatal(err) + } + lease := &linuxLease{proc: proc, identity: sandboxpolicy.Binding{CgroupPath: expected}, start: start} + t.Cleanup(func() { + if err := lease.Close(); err != nil { + t.Error(err) + } + }) + if got, err := lease.readStart(); err != nil || got != start { + t.Fatalf("fixture must pass the earlier starttime check: start=%q err=%v", got, err) + } + // Deliberately omit ns/mnt: reaching the later namespace open + // would return ENOENT, not the exact cgroup rejection required here. + if _, err := os.Stat(filepath.Join(root, "ns", "mnt")); !os.IsNotExist(err) { + t.Fatalf("namespace fixture unexpectedly exists: %v", err) + } + if err := lease.Check(); err == nil || err.Error() != tc.want { + t.Fatalf("expected cgroup rejection before namespace access: got %v, want %q", err, tc.want) + } + }) + } +} From 0d58256ca50549d07df5a23c46d074741112d674 Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Fri, 30 Oct 2026 18:45:52 +0100 Subject: [PATCH 20/23] docs(sandboxpolicy): clarify role grants and evidence limits Signed-off-by: Matthias Bertschy --- docs/features/sandbox-policy.md | 52 +++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/docs/features/sandbox-policy.md b/docs/features/sandbox-policy.md index ede9f39922..686ef235b2 100644 --- a/docs/features/sandbox-policy.md +++ b/docs/features/sandbox-policy.md @@ -114,6 +114,58 @@ coverage, an unambiguous selected container and literal paths. Exact argv-length argument-wildcard and environment constraints cannot be represented faithfully by the CRD exec vocabulary and are refused rather than broadened. +## Role-entry authoring + +Treat `setRole` on an allow rule as an **authorization grant**, not just an event +label. An omitted or empty actor, or a wildcard-only actor constraint, can grant +the role broadly to matching successful execs. The current compiler accepts +such broad grants; an empty actor object is not a mitigation or a validation +workaround. + +Constrain entry using a meaningful trusted actor, the intended target and the +policy-local authorization boundary. Review how the launcher's trusted role +history was established; missing history is not authority, and an executable +path or image origin alone does not establish trust in its inputs. A constrained +native Go IR example and its assumptions are documented in IG's +[role-entry authoring and isolation guidance](https://github.com/kubescape/inspektor-gadget/blob/8224dd6e694ad43b83ca1dc85de8d2b954d5f98e/gadgets/enforce_exec/fullruntime/README.md#role-entry-authoring-and-isolation). +That example is not CRD YAML or a complete deployment policy. + +Roles are **not hostile-code isolation boundaries**. Untrusted code may invoke a +trusted launcher as a confused deputy or delegate work through IPC, shared files +or services. Review that reachable authority, including pod-shared resources, +rather than assuming a role label prevents delegation. + +## Runtime evidence input limits + +The containerd evidence path refuses oversized metadata before its relevant +content read or JSON decode. These are supported-input limits, not a total heap, +process-memory or IG `CompileBytes` bound: + +- Raw container and sandbox OCI specs: **1 MiB each** before JSON decoding; + existing encoded-spec size checks still apply. CRI extensions retain their + existing **1 MiB each** predecode limit. +- Image indexes, manifests and configs: **16 MiB per blob and 16 MiB aggregate** + per evidence observation. The actual content-store reader size is checked, + not merely the descriptor's claimed size. Repeated reads and inspected inline + descriptor data count again; an invalid inline attempt followed by a store + read charges both. Admitted failed reads are not refunded. +- Descriptor traversal retains containerd's **10,000 references**, including + duplicates. Existing limits of **128 image diff IDs** and **128 snapshot + ancestors** remain in force. + +The raw-spec and streamed/aggregate metadata ceilings are stricter input-domain +restrictions: whitespace-heavy specs or large multi-platform metadata may now be +refused even if their eventual selected representation was previously accepted. +Nested inline data is allocated while decoding its size-admitted parent; its +later byte check does not claim to prevent that earlier allocation. Decoder +copies, RPC buffers and provider-owned asynchronous state are not covered by a +16 MiB heap claim. + +Excess is unsupported evidence and fails closed. On re-attestation it follows the +existing sticky evidence-failure path, not the retryable transport-unavailable +exception. This does not remove or weaken any image, mount, identity or native +readiness check, and does not expand the supported runtime/kernel matrix. + ## Public event hooks Register receivers in `ThirdPartyTracers.ThirdPartyEventReceivers` under From 9b6ab9acee7158d2f1e821e21a5a8c7156c08b66 Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Fri, 30 Oct 2026 19:11:22 +0100 Subject: [PATCH 21/23] test(sandboxpolicy): isolate kernel fixture initialization Signed-off-by: Matthias Bertschy --- .../fullbackend_kernel_disabled_test.go | 16 +++++++ .../fullbackend_kernel_plan_linux_test.go | 47 +++++++++++++++++++ .../igbackend/fullbackend_kernel_test.go | 37 +-------------- 3 files changed, 65 insertions(+), 35 deletions(-) create mode 100644 pkg/sandboxpolicy/igbackend/fullbackend_kernel_disabled_test.go create mode 100644 pkg/sandboxpolicy/igbackend/fullbackend_kernel_plan_linux_test.go diff --git a/pkg/sandboxpolicy/igbackend/fullbackend_kernel_disabled_test.go b/pkg/sandboxpolicy/igbackend/fullbackend_kernel_disabled_test.go new file mode 100644 index 0000000000..7c108261d2 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/fullbackend_kernel_disabled_test.go @@ -0,0 +1,16 @@ +//go:build !linux || !sandboxpolicy_kernel + +package igbackend + +import ( + "os" + "testing" +) + +// Keep the opt-in test discoverable when kernel support is not compiled in. +func TestFullBackendContainerdKernel(t *testing.T) { + if os.Getenv("IG_FULL_BACKEND_KERNEL_TEST") == "1" { + t.Fatal("kernel qualification requires Linux and -tags=sandboxpolicy_kernel; the build tag admits kernel initialization even without the opt-in environment variable") + } + t.Skip("kernel qualification unavailable: requires Linux, -tags=sandboxpolicy_kernel and IG_FULL_BACKEND_KERNEL_TEST=1") +} diff --git a/pkg/sandboxpolicy/igbackend/fullbackend_kernel_plan_linux_test.go b/pkg/sandboxpolicy/igbackend/fullbackend_kernel_plan_linux_test.go new file mode 100644 index 0000000000..d6376f5183 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/fullbackend_kernel_plan_linux_test.go @@ -0,0 +1,47 @@ +//go:build linux + +package igbackend + +import ( + "fmt" + "github.com/inspektor-gadget/inspektor-gadget/gadgets/enforce_exec/fullpolicy" + "github.com/kubescape/node-agent/pkg/sandboxpolicy" + api "github.com/kubescape/storage/pkg/apis/sandbox/v1alpha1" + "github.com/stretchr/testify/require" + "testing" +) + +func fullBackendKernelPlan(revision, rootRole string, denied, audited int, container string) sandboxpolicy.Plan { + yes := true + actor := func(role string) *api.Actor { + return &api.Actor{Path: []string{"/probe/init", "/probe/role"}, FromImage: &yes, Ancestor: &api.Ancestor{Path: []string{"/probe/init", "/probe/role"}, Depth: 2}, Role: []string{role}, UID: []int64{0}, Container: []string{container}} + } + target := func(paths []string) api.ExecTarget { + return api.ExecTarget{PathTarget: api.PathTarget{Path: paths, FromImage: &yes, FSType: []string{"overlay"}}, Argv: &api.ArgvPredicate{Contains: []string{"--token=ok"}, Prefix: []string{"--token="}, StdinIsPipe: &yes}} + } + paths := make([]string, 13) + for i := range paths { + paths[i] = fmt.Sprintf("/probe/target-%d", i) + } + stage := target([]string{"/probe/role"}) + stage.SetRole = "worker" + return sandboxpolicy.Plan{Revision: revision, Policies: []sandboxpolicy.CompiledPolicy{{Identity: sandboxpolicy.PolicyIdentity{Kind: "SandboxPolicy", Name: "fixture", UID: "fixture-policy-uid"}, Revision: "policy-" + revision, Mode: "enforce", RootRole: rootRole, Exec: api.ExecSection{Default: api.ActionDeny, Rules: []api.ExecRule{ + {Rule: api.Rule{Name: "root-paths", Origin: "fixture/full-predicates", Action: api.ActionAllow, Actor: actor("agent")}, Target: target(paths)}, + {Rule: api.Rule{Name: "deny-control", Action: api.ActionDeny}, Target: api.ExecTarget{PathTarget: api.PathTarget{Path: []string{fmt.Sprintf("/probe/target-%d", denied)}}}}, + {Rule: api.Rule{Name: "audit-control", Action: api.ActionDeny, Mode: api.RuleAudit}, Target: api.ExecTarget{PathTarget: api.PathTarget{Path: []string{fmt.Sprintf("/probe/target-%d", audited)}}}}, + {Rule: api.Rule{Name: "root-role-control", Action: api.ActionDeny, Actor: &api.Actor{Role: []string{"agent"}}}, Target: api.ExecTarget{PathTarget: api.PathTarget{Path: []string{"/probe/target-12"}}}}, + {Rule: api.Rule{Name: "grant-worker", Action: api.ActionAllow, Actor: actor("agent")}, Target: stage}, + {Rule: api.Rule{Name: "inherited-worker", Action: api.ActionAllow, Actor: actor("worker")}, Target: target([]string{"/probe/target-12"})}, + }}}}} +} + +func TestFullBackendKernelFixturePlan(t *testing.T) { + for _, revision := range []string{"plan-1", "plan-2"} { + p := fullBackendKernelPlan(revision, "agent", 0, 1, "app") + program, err := CompileFull(sandboxpolicy.Binding{ContainerName: "app"}, p, 1, nil) + require.NoError(t, err) + rule, err := fullpolicy.Decode[fullpolicy.KernelRule](program.Entries[fullpolicy.KernelRuleBase]) + require.NoError(t, err) + require.Equal(t, uint32(13), rule.TargetPath.Count) + } +} diff --git a/pkg/sandboxpolicy/igbackend/fullbackend_kernel_test.go b/pkg/sandboxpolicy/igbackend/fullbackend_kernel_test.go index bbfc6e472b..686b81d6e0 100644 --- a/pkg/sandboxpolicy/igbackend/fullbackend_kernel_test.go +++ b/pkg/sandboxpolicy/igbackend/fullbackend_kernel_test.go @@ -1,3 +1,5 @@ +//go:build linux && sandboxpolicy_kernel + package igbackend import ( @@ -402,30 +404,6 @@ func expectFullBackendEvent(t *testing.T, ctx context.Context, receiver *fullBac } } -func fullBackendKernelPlan(revision, rootRole string, denied, audited int, container string) sandboxpolicy.Plan { - yes := true - actor := func(role string) *api.Actor { - return &api.Actor{Path: []string{"/probe/init", "/probe/role"}, FromImage: &yes, Ancestor: &api.Ancestor{Path: []string{"/probe/init", "/probe/role"}, Depth: 2}, Role: []string{role}, UID: []int64{0}, Container: []string{container}} - } - target := func(paths []string) api.ExecTarget { - return api.ExecTarget{PathTarget: api.PathTarget{Path: paths, FromImage: &yes, FSType: []string{"overlay"}}, Argv: &api.ArgvPredicate{Contains: []string{"--token=ok"}, Prefix: []string{"--token="}, StdinIsPipe: &yes}} - } - paths := make([]string, 13) - for i := range paths { - paths[i] = fmt.Sprintf("/probe/target-%d", i) - } - stage := target([]string{"/probe/role"}) - stage.SetRole = "worker" - return sandboxpolicy.Plan{Revision: revision, Policies: []sandboxpolicy.CompiledPolicy{{Identity: sandboxpolicy.PolicyIdentity{Kind: "SandboxPolicy", Name: "fixture", UID: "fixture-policy-uid"}, Revision: "policy-" + revision, Mode: "enforce", RootRole: rootRole, Exec: api.ExecSection{Default: api.ActionDeny, Rules: []api.ExecRule{ - {Rule: api.Rule{Name: "root-paths", Origin: "fixture/full-predicates", Action: api.ActionAllow, Actor: actor("agent")}, Target: target(paths)}, - {Rule: api.Rule{Name: "deny-control", Action: api.ActionDeny}, Target: api.ExecTarget{PathTarget: api.PathTarget{Path: []string{fmt.Sprintf("/probe/target-%d", denied)}}}}, - {Rule: api.Rule{Name: "audit-control", Action: api.ActionDeny, Mode: api.RuleAudit}, Target: api.ExecTarget{PathTarget: api.PathTarget{Path: []string{fmt.Sprintf("/probe/target-%d", audited)}}}}, - {Rule: api.Rule{Name: "root-role-control", Action: api.ActionDeny, Actor: &api.Actor{Role: []string{"agent"}}}, Target: api.ExecTarget{PathTarget: api.PathTarget{Path: []string{"/probe/target-12"}}}}, - {Rule: api.Rule{Name: "grant-worker", Action: api.ActionAllow, Actor: actor("agent")}, Target: stage}, - {Rule: api.Rule{Name: "inherited-worker", Action: api.ActionAllow, Actor: actor("worker")}, Target: target([]string{"/probe/target-12"})}, - }}}}} -} - func fullBackendKernelImage(t *testing.T) (oras.ReadOnlyTarget, string) { t.Helper() if archive := os.Getenv("IG_FULL_BACKEND_ARCHIVE"); archive != "" { @@ -558,17 +536,6 @@ func bytesIndexNewline(b []byte) int { return -1 } -func TestFullBackendKernelFixturePlan(t *testing.T) { - for _, revision := range []string{"plan-1", "plan-2"} { - p := fullBackendKernelPlan(revision, "agent", 0, 1, "app") - program, err := CompileFull(sandboxpolicy.Binding{ContainerName: "app"}, p, 1, nil) - require.NoError(t, err) - rule, err := fullpolicy.Decode[fullpolicy.KernelRule](program.Entries[fullpolicy.KernelRuleBase]) - require.NoError(t, err) - require.Equal(t, uint32(13), rule.TargetPath.Count) - } -} - func proveFullBackendDeviceBarrier(t *testing.T, ctx context.Context, control *fullBackendControl, pid uint32) { t.Helper() registered, err := os.ReadFile("/proc/devices") From 55faa8704fd92c0480ae9febc10e36dbb96280dd Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Fri, 30 Oct 2026 19:11:30 +0100 Subject: [PATCH 22/23] docs(sandboxpolicy): require explicit kernel-test build tag Signed-off-by: Matthias Bertschy --- docs/features/sandbox-policy.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/features/sandbox-policy.md b/docs/features/sandbox-policy.md index 686ef235b2..6eb356b012 100644 --- a/docs/features/sandbox-policy.md +++ b/docs/features/sandbox-policy.md @@ -282,6 +282,21 @@ receiver copies isolate the added slices from producer/receiver mutation. ## Pinned CRI kernel validation +The live kernel fixture is excluded from ordinary test builds. On an approved, +isolated Linux qualification host, its explicit invocation is: + +```sh +IG_FULL_BACKEND_KERNEL_TEST=1 go test -count=1 -tags=sandboxpolicy_kernel \ + ./pkg/sandboxpolicy/igbackend -run '^TestFullBackendContainerdKernel$' +``` + +**The build tag itself admits kernel-related dependency initialization, including +BPF map creation attempts, even when the opt-in environment variable is unset.** +Do not use the tag for ordinary unit tests or run the tagged binary outside the +approved kernel-qualification procedure. Setting the environment variable without +the tag fails explicitly rather than silently passing with no matching test. +The pure `TestFullBackendKernelFixturePlan` remains in ordinary Linux test builds. + The isolated combined fixture uses real containerd 1.7.30 / runc 1.3.4 CRI RunPodSandbox/CreateContainer/StartContainer, not forged ctr metadata. It retains the generated hosts, hostname, resolv.conf, sandbox shared-memory bind and From d7834e44de0fec24683ed1b82ec4d9107e497625 Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Sat, 31 Oct 2026 00:08:50 +0100 Subject: [PATCH 23/23] docs(sandboxpolicy): clarify pinned backend and shared preparation Document the already-pinned per-binding persistent-owner backend separately from the newer unqualified, unintegrated shared Go preparation foundation. No dependency, source, image trust, or Ready qualification change. Signed-off-by: Matthias Bertschy --- docs/features/sandbox-policy.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/features/sandbox-policy.md b/docs/features/sandbox-policy.md index 6eb356b012..95200b0f3f 100644 --- a/docs/features/sandbox-policy.md +++ b/docs/features/sandbox-policy.md @@ -19,10 +19,13 @@ Disabled by default: Enabling requires an explicit, trusted mutator image reference in `tracers.tar`, compiled from the **same IG revision as the Go runtime owner**. Do not substitute a -trace gadget or reuse an older policy-map layout. The public dependency currently -pins the prototype revision; publishing/re-pinning the expanded backend remains -outstanding. No mutator image is implicitly downloaded, trusted or published by -this change. IG treats an explicit local OCI target as pretrusted and skips its signature +trace gadget or reuse an older policy-map layout. The public dependency already +pins the published per-binding persistent-owner backend at +`87a796fe97803ec6788e985e6570efea46124fba`, as detailed below. The newer shared Go +preparation foundation at `dabee7d08345752cada90c326e25cd7b6855d62d` is not the +consumer pin and remains unqualified and unintegrated as a shared runtime; it +does not establish shared enforcement or Ready. No mutator image is implicitly +downloaded, trusted or published by this change. IG treats an explicit local OCI target as pretrusted and skips its signature verification. The archive must therefore be authenticated by the administrator as part of the agent-image supply chain; the verification flag alone does not authenticate it. Policy authors cannot choose the image/archive.