diff --git a/cmd/main.go b/cmd/main.go index 2d1395b9c0..3886794e10 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 service diagnostic", 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..95200b0f3f --- /dev/null +++ b/docs/features/sandbox-policy.md @@ -0,0 +1,353 @@ +# 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 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. + +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. + +## 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 +`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. + +`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. +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. + +### 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, +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`, 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. Follow-up review fixes through +`f9467312a221885cf144aa3289df3b0260af25e9` reject noncanonical exec prefixes +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. + +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 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 +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..7296dfab87 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-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 fd86cf4927..d9cd14fcc0 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,30 @@ 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/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/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/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/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 +1247,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 +1297,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 +1319,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 +1333,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 +1374,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 +1391,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 +1432,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 +1444,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 +1457,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 +1493,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 +1508,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 +1518,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 +1540,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 +1551,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 +1566,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 +1580,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 +1623,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 +1632,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 +1646,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 +1655,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 +1681,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 +1713,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 +1741,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 +1758,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 +1770,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 +1809,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 +1843,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 +1858,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 +1897,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 +1953,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 +2196,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 +2292,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 +2353,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 +2425,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 +2461,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 +2485,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 +2525,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 +2540,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 +2591,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 +2625,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..473ecf268f 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,11 @@ func LoadConfigOptional(path string, errNotFound bool) (Config, error) { viper.SetConfigType("json") viper.SetOptions(viper.KeyDelimiter("::")) + 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) viper.SetDefault("nodeProfileInterval", 10*time.Minute) @@ -336,6 +347,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..eb0904ae86 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,58 @@ 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) + } + }) + } +} + +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/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..dfb5c7e46d --- /dev/null +++ b/pkg/sandboxpolicy/exporter.go @@ -0,0 +1,298 @@ +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" +) + +// 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. +// 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 = (*ExecVerdictEvent)(nil) + +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...) + 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 &ExecVerdictEvent{Verdict: verdict, Binding: binding} +} +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 (*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 (*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() + 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 *ExecVerdictEvent + 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 *ExecVerdictEvent, 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 := NewExecVerdictEvent(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 *ExecVerdictEvent) { + 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 *ExecVerdictEvent) { + if e.options.Receivers == nil { + e.dropped.Add(1) + return + } + receivers, ok := e.options.Receivers.Load(utils.ExecAuthorizationEventType) + if !ok || receivers.Cardinality() == 0 { + e.dropped.Add(1) + return + } + for _, receiver := range receivers.ToSlice() { + copy := NewExecVerdictEvent(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.ExecAuthorizationEventType, 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..04514c2d90 --- /dev/null +++ b/pkg/sandboxpolicy/exporter_test.go @@ -0,0 +1,294 @@ +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.ExecAuthorizationEventType, 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 TestExecVerdictEventRetainsKnownMetadataWithoutInventingExec(t *testing.T) { + v := sampleVerdict() + b := binding() + b.MountNamespace = 123 + 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 { + 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") + } +} + +// 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)} + 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.(*ExecVerdictEvent).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.(*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): + 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(), 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).(*ExecVerdictEvent); !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(), 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.(*ExecVerdictEvent).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, 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()) + } +} + +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).(*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 { + 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..2133579ef9 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/README.md @@ -0,0 +1,69 @@ +# 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-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, +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 +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..dfeaf16ac2 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/backend.go @@ -0,0 +1,327 @@ +// 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 { + binding sandboxpolicy.Binding + 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 && !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. + 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{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{binding: binding} + 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) (result sandboxpolicy.WithdrawResult, resultErr 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 + } + // 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 { + 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 + 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 _, 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 new file mode 100644 index 0000000000..bb7bf431c5 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/backend_test.go @@ -0,0 +1,576 @@ +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{binding: bindingAt(i), 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) + } +} + +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{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) + } +} diff --git a/pkg/sandboxpolicy/igbackend/controller_identity_test.go b/pkg/sandboxpolicy/igbackend/controller_identity_test.go new file mode 100644 index 0000000000..ef0469ccbb --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/controller_identity_test.go @@ -0,0 +1,334 @@ +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") + } +} + +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) + } +} 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/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/fullbackend.go b/pkg/sandboxpolicy/igbackend/fullbackend.go new file mode 100644 index 0000000000..d712648aba --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/fullbackend.go @@ -0,0 +1,605 @@ +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. +// 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 + 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 + closeMu sync.Mutex + engineClosed bool + 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 { + ctx, cancel := context.WithTimeout(ctx, b.operationTimeout) + defer cancel() + 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 { + 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) { + k := key(binding) + // 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() { + if 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 !sameFullBindingMetadata(r.binding, binding) { + return result, fmt.Errorf("binding metadata changed for persistent identity") + } + if r.retiring || draining { + r.retiring = true + if err := b.stop(ctx, k, r); err != nil { + return result, err + } + // 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()) + } + 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) + 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.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.instance != nil && r.revision == plan.Revision { + return sandboxpolicy.ReplaceResult{Committed: true, ActiveRevision: r.revision}, nil + } + 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.instance != nil { + epochs = r.instance.Epochs() + } + program, err := CompileFull(binding, plan, generation, epochs) + if err != nil { + return result, err + } + if r.instance == nil { + catalog := NewFullVerdictCatalog(binding) + if err := catalog.Stage(generation, plan, program); err != nil { + return result, err + } + var lease FullBindingLease + lease, err = b.source.Prepare(ctx, binding) + if err != nil || lease == nil { + if lease != nil { + r.lease, r.retiring = lease, true + 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.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) { + 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, _ 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 + } + return nil +} + +// 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() + 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 + 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 { + if err := lockFullMutex(ctx, &b.closeMu); err != nil { + return err + } + defer b.closeMu.Unlock() + b.mu.Lock() + b.closed = true + 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 + } + 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) { + 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) { + counters, err := instanceKernelCounters(instance) + b.mu.Lock() + defer b.mu.Unlock() + b.retiredInstances++ + 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 + } + // 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 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 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_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/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 new file mode 100644 index 0000000000..686b81d6e0 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/fullbackend_kernel_test.go @@ -0,0 +1,582 @@ +//go:build linux && sandboxpolicy_kernel + +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 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 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..f055231a8a --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/fullengine.go @@ -0,0 +1,85 @@ +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. +// 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. +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..4affbe97a1 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/fulllease_linux.go @@ -0,0 +1,426 @@ +package igbackend + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "unicode/utf8" + + 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 +} + +// 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() + 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..11fa7b7075 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/fulllease_linux_test.go @@ -0,0 +1,303 @@ +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) +} + +// 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) +} 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..b3d5d0eebd --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/identity_linux_test.go @@ -0,0 +1,141 @@ +package igbackend + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kubescape/node-agent/pkg/sandboxpolicy" +) + +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") + } +} + +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) + } + }) + } +} diff --git a/pkg/sandboxpolicy/igbackend/imageevidence.go b/pkg/sandboxpolicy/igbackend/imageevidence.go new file mode 100644 index 0000000000..0cd82842ab --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/imageevidence.go @@ -0,0 +1,1426 @@ +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) { + ctx, finish := newEvidenceContext(ctx) + defer finish() + 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 := boundedContainerSpec(ctx, container) + if err != nil { + return result, err + } + image, err := container.Image(ctx) + if err != nil { + return result, err + } + diffs, err := boundedImageRootFS(ctx, image) + 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 := boundedContainerSpec(ctx, sandbox) + 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") + } + // 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") + } + } + 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..c9ebc2e7e1 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/imageevidence_test.go @@ -0,0 +1,669 @@ +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..35acac8824 --- /dev/null +++ b/pkg/sandboxpolicy/igbackend/kernelstats_test.go @@ -0,0 +1,177 @@ +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") + } +} + +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") + } + }) + } +} 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..8efcf4c8bb --- /dev/null +++ b/pkg/sandboxpolicy/integration/bindings.go @@ -0,0 +1,211 @@ +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.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 { + return nil, fmt.Errorf("cgroup ID shared by %s and %s", previous, key) + } + cgroups[container.CgroupID] = key + binding.CgroupID = container.CgroupID + // 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) + } + 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..203472deeb --- /dev/null +++ b/pkg/sandboxpolicy/integration/bindings_test.go @@ -0,0 +1,259 @@ +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: "/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) + 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].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) + } + } + 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].CgroupV2 = "/" + 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) + } +} + +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) + } + }) + } +} 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..d39bc8520c 100644 --- a/pkg/utils/events.go +++ b/pkg/utils/events.go @@ -276,6 +276,10 @@ const ( UnshareEventType EventType = "unshare" ) +// 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) { switch event.GetEventType() { 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")) }