Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ TAG?=test
# TAG?=v0.0.1

binary:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o $(BINARY_NAME) ./cmd/main.go
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o $(BINARY_NAME) ./cmd

.PHONY: check-legacy-packages
check-legacy-packages:
Expand Down
2 changes: 1 addition & 1 deletion build/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ ARG TARGETOS TARGETARCH
RUN --mount=target=. \
--mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
GOOS=$TARGETOS GOARCH=$TARGETARCH GOEXPERIMENT=greenteagc go build -o /out/node-agent -ldflags="-s -w" ./cmd/main.go
GOOS=$TARGETOS GOARCH=$TARGETARCH GOEXPERIMENT=greenteagc go build -o /out/node-agent -ldflags="-s -w" ./cmd

RUN --mount=target=. \
--mount=type=cache,target=/root/.cache/go-build \
Expand Down
2 changes: 1 addition & 1 deletion build/Dockerfile.debug
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ RUN go install github.com/go-delve/delve/cmd/dlv@latest
RUN --mount=target=. \
--mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
GOOS=$TARGETOS GOARCH=$TARGETARCH GOEXPERIMENT=greenteagc go build -o /out/node-agent ./cmd/main.go
GOOS=$TARGETOS GOARCH=$TARGETARCH GOEXPERIMENT=greenteagc go build -o /out/node-agent ./cmd

FROM gcr.io/distroless/static-debian13:debug

Expand Down
96 changes: 96 additions & 0 deletions cmd/gvisor_start_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//go:build linux

package main

import (
"context"
"errors"
"time"

"github.com/kubescape/go-logger"
"github.com/kubescape/node-agent/pkg/config"
"github.com/kubescape/node-agent/pkg/gvisor"
gvcri "github.com/kubescape/node-agent/pkg/gvisor/cri"
"github.com/kubescape/node-agent/pkg/otelsetup"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
coreclient "k8s.io/client-go/kubernetes/typed/core/v1"
)

// startGVisorCollection is independent of the host PID/mount-namespace inventory.
// In particular it does not inject a sandbox start into an exec/profile path.
func startGVisorCollection(ctx context.Context, cfg config.Config, runtimeEndpoint string, core coreclient.CoreV1Interface, logsReady bool) (func(), error) {
if !cfg.GVisorStart.Enabled {
return func() {}, nil
}
if err := cfg.GVisorStart.Validate(); err != nil {
return nil, err
}
if !logsReady {
return nil, errors.New("gVisor observations require an initialized OTLP log exporter")
}
if cfg.GVisorStart.RuntimeEndpoint != "" {
runtimeEndpoint = cfg.GVisorStart.RuntimeEndpoint
}
inventory, err := gvcri.New(runtimeEndpoint, cfg.GVisorStart.RuntimeHandler)
if err != nil {
return nil, err
}
checkCtx, cancelCheck := context.WithTimeout(ctx, 2*time.Second)
err = inventory.Check(checkCtx)
cancelCheck()
if err != nil {
inventory.Close()
return nil, err
}
sourceCtx, cancel := context.WithCancel(ctx)
done := make(chan struct{})
lookup := func(ctx context.Context, id string) (gvisor.Identity, error) {
identity, err := inventory.Resolve(ctx, id)
if err != nil {
return gvisor.Identity{}, err
}
pod, err := core.Pods(identity.Namespace).Get(ctx, identity.PodName, metav1.GetOptions{})
if err != nil || !gVisorPodMatches(identity, pod, cfg) {
return gvisor.Identity{}, errors.New("gVisor Pod identity is unresolved or excluded")
}
return identity, nil
}
go func() {
defer close(done)
if err := gvisor.RunIntegration(sourceCtx, cfg.GVisorStart, cfg.NodeName, lookup, otelsetup.EmitRuntimeStart); err != nil {
// Never log CRI bodies or Sentry bytes. A source failure is degraded
// telemetry, not evidence that no sandbox started.
logger.L().Warning("gVisor start source stopped; runtime visibility is degraded")
}
}()
return func() {
cancel()
<-done
_ = inventory.Close()
}, nil
}

func gVisorPodMatches(identity gvisor.Identity, pod *corev1.Pod, cfg config.Config) bool {
if pod == nil || pod.Namespace != identity.Namespace || pod.Name != identity.PodName ||
string(pod.UID) != identity.PodUID || pod.Spec.NodeName != cfg.NodeName || pod.DeletionTimestamp != nil ||
cfg.IgnoreContainer(pod.Namespace, pod.Name, pod.Labels) {
return false
}
for _, c := range pod.Spec.Containers {
if c.Name == identity.ContainerName {
return true
}
}
for _, c := range pod.Spec.InitContainers {
if c.Name == identity.ContainerName {
return true
}
}
for _, c := range pod.Spec.EphemeralContainers {
if c.Name == identity.ContainerName {
return true
}
}
return false
}
55 changes: 55 additions & 0 deletions cmd/gvisor_start_linux_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//go:build linux

package main

import (
"context"
"testing"

"github.com/kubescape/node-agent/pkg/config"
"github.com/kubescape/node-agent/pkg/gvisor"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func TestGVisorPodIdentityAndNamespacePolicy(t *testing.T) {
identity := gvisor.Identity{Namespace: "apps", PodName: "sandbox", PodUID: "uid", ContainerName: "app"}
for _, tc := range []string{"verified", "wrong_uid", "wrong_node", "wrong_namespace", "missing_container", "excluded_namespace", "excluded_label"} {
t.Run(tc, func(t *testing.T) {
pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Namespace: "apps", Name: "sandbox", UID: "uid", Labels: map[string]string{"ignore": "yes"}}, Spec: corev1.PodSpec{NodeName: "node", Containers: []corev1.Container{{Name: "app"}}}}
cfg := config.Config{NodeName: "node"}
switch tc {
case "wrong_uid":
pod.UID = "new-uid"
case "wrong_node":
pod.Spec.NodeName = "other-node"
case "wrong_namespace":
pod.Namespace = "other"
case "missing_container":
pod.Spec.Containers = nil
case "excluded_namespace":
cfg.ExcludeNamespaces = []string{"apps"}
case "excluded_label":
cfg.ExcludeLabels = map[string][]string{"ignore": {"yes"}}
}
if got := gVisorPodMatches(identity, pod, cfg); got != (tc == "verified") {
t.Fatalf("identity/policy accepted=%v", got)
}
})
}
}

func TestGVisorDisabledStartupDoesNoIO(t *testing.T) {
stop, err := startGVisorCollection(context.Background(), config.Config{}, "", nil, false)
if err != nil {
t.Fatal(err)
}
stop()
}

func TestGVisorRequiresLogExport(t *testing.T) {
cfg := config.Config{GVisorStart: gvisor.IntegrationConfig{Enabled: true, SocketPath: "/run/kubescape/starts.sock"}}
if _, err := startGVisorCollection(context.Background(), cfg, "", nil, false); err == nil {
t.Fatal("enabled source silently used a no-op exporter")
}
}
8 changes: 8 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ func main() {
if err != nil {
logger.L().Warning("OTEL init failed, running without telemetry", helpers.Error(err))
}
otelLogsReady := err == nil && (os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") != "" || os.Getenv("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT") != "")
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
Expand Down Expand Up @@ -570,6 +571,13 @@ func main() {
}

// start watching
stopGVisor, gvisorErr := startGVisorCollection(ctx, cfg, igK8sClient.RuntimeConfig.SocketPath, k8sClient.GetKubernetesClient().CoreV1(), otelLogsReady)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if gvisorErr != nil {
logger.L().Warning("gVisor start source unavailable; runtime visibility is degraded", helpers.Error(gvisorErr))
} else {
defer stopGVisor()
}

dWatcher.Start(ctx)
defer dWatcher.Stop(ctx)

Expand Down
7 changes: 7 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -734,3 +734,10 @@ For more information, see:
- [Main README](../README.md)
- [Alert Bulking](ALERT_BULKING.md)
- [Kubescape Documentation](https://kubescape.io/docs/)

## Experimental gVisor start source

`gVisorStart.enabled` is disabled by default. See the [source configuration and
identity/export contract](features/gvisor-start-source.md) before enabling it on
a self-managed Kubernetes test node. It requires a local CRI socket, a configured
runsc trace sink and an initialized OTLP log exporter.
141 changes: 141 additions & 0 deletions docs/experiments/gvisor-start-integration-20261102/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Node-agent gVisor start integration, 2 November 2026

This is a live trial of the rebuilt Kubernetes node-agent, following the
[profile comparison in #1053](https://github.com/kubescape/node-agent/pull/1053).
The receiver, CRI/Kubernetes identity join and existing OTLP log exporter run
inside the actual node-agent process. The standalone start probe is not the
producer for this evidence.

## Observed results

| Case | Expected observations | Received |
|---|---:|---:|
| Repeated application-container starts | 20 | 20 |
| Two overlapping gVisor workloads | 2 | 2 |
| gVisor workload in an excluded namespace | 0 | 0 |
| Valid synthetic frame claiming an unknown ID | 0 | 0 |
| Ordinary-runtime control | 0 | 0 |

All 24 workloads (22 accepted gVisor, one excluded gVisor and one ordinary)
completed with exit code 0. Each accepted container ID, sandbox ID, Pod UID,
Pod name/namespace, node and container name matched the independently queried
CRI/Kubernetes inventory. The concurrent workloads' execution intervals overlap.
There was exactly one retained observation per accepted container, and each
reported zero sender drops. These are bounded results, not delivery guarantees.

See the [verification](evidence/verification.json),
[identity ledger](evidence/runtime-ledger.json),
[received observations](evidence/observations.jsonl) and
[environment](evidence/environment.json). All records are marked
`source=gvisor_trace`, `kind=container_started`, `identity_status=verified`,
with `identity_origin=local_cri_and_kubernetes`.

The ordinary control produced no trace input naturally; runtime-handler
rejection is additionally covered by the CRI unit tests. The unknown-ID control
sends a valid remote-sink frame but does not establish a real runtime start.
The trial does not authenticate the sender or prove application execution from
the start record. Workload completion is separate runtime evidence.

## Privacy validation

The fixture puts the harmless marker `GVS_INTEGRATION_CANARY_2026` in both
argv and the OCI working directory. A mounted empty directory supplies that
working directory. The marker in fixture source is intentional.

The collector scans the complete incoming OTLP request for the literal marker
**before** filtering retained fields. It records a violation flag without
copying the offending payload. Every retained observation has
`wire_canary_found=false` and `unexpected_attribute_count=0`. A collector
regression test injects an extra sensitive attribute and proves that filtering
cannot falsely clear the wire check. The node-agent's ordinary logs were also
scanned locally and contained no literal marker; those raw logs are not included.
This checks the literal synthetic marker and the fixed export contract, not
every possible encoding of arbitrary credentials.

The published run repeats an initial successful 20-plus-two trial with the
stronger pre-filter wire check. The earlier run remains in the VM for audit;
only the final run is used in the table above.

## Environment

- Dedicated Ubuntu ARM64 Multipass VM, four vCPUs, 6 GiB RAM, kernel
6.8.0-147-generic.
- K3s v1.37.1+k3s1, local CRI endpoint
`/run/k3s/containerd/containerd.sock`, runsc release-20261019.0 with systrap.
- Node-agent built from this PR's working tree on base `0ad6adb2` using the
repository's Go 1.27.0 toolchain and pinned dependencies, with CGO disabled.
The actual binary SHA-256 is retained in the environment summary.
- The rebuilt binary was mounted into the existing official v0.3.293 test
image and selected as its command. This is not a published image release.
- BusyBox is pinned by image-index digest in the fixture. Runtime image IDs
are recorded per workload.
- Runtime detection, application-profile learning and host sensing were
disabled for this source/export trial. The earlier eBPF comparison remains
separate evidence; no guest-syscall comparison is inferred here.

The test retains the earlier experimental storage/cluster setup, including
its documented root storage workaround. It is not a production chart recipe.
No managed GKE or x86_64 claim is made.

## Reproduction

Use only the dedicated self-managed test VM and preserve the original
DaemonSet, ConfigMap and runsc runtime settings privately before changing them.
Do not export kubeconfig, service-account tokens, cloud credentials or raw OCI
specifications into a PR.

1. Build `./cmd` and `./docs/experiments/gvisor-start-integration-20261102/otlp-capture`
for Linux ARM64 with Go 1.27.0. Install the test binary into the experimental
node-agent Pod using a read-only hostPath mount and command override. Keep
the existing image's gadget data and host mounts. Record the binary hash.
2. Run the capture utility on a trusted VM-local address with a new output
file. Set `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` on node-agent to that collector.
The plaintext transport used for this isolated local experiment is not a
public collector deployment.
3. Enable the [source configuration](../../features/gvisor-start-source.md),
with the K3s CRI endpoint, `runtimeHandler=runsc` and
`/run/kubescape/gvisor-events.sock`. Keep namespace selection limited to
`gvisor-comparison`. Create the root-owned socket directory with mode 0700.
4. For newly created gVisor Pods only, configure runsc's pod-init configuration
with the existing `container/start` remote sink pointing to that same
socket. Do not overwrite another monitor's Default session. This experiment
owns its runtime settings and newly created sandboxes.
5. Put the fixtures and binaries in
`/home/ubuntu/gvisor-trial/integration-20261102`. Start the collector with
`--output` pointing to `observations.jsonl` there. Wait for agent startup
and a successful version-1 seqpacket handshake, then run `trial.py --output`
with the `evidence` subdirectory as its new output path. It performs 20
repeated starts and the unknown-ID/ordinary controls.
6. Run `parallel-trial.py`, then `verify.py` before deleting Pods. Verification
checks every export against retained Pod and CRI identities, exact counts,
concurrent execution overlap, the pre-filter privacy flags and local agent
log scan. Checks remain active under `python -O` and `PYTHONOPTIMIZE`;
overlap and privacy verdicts are derived from the checked inputs. An error
is a failed experiment, not evidence of no starts.
7. Export only the sanitized observation/verification files. Restore the
original ConfigMap, DaemonSet and runsc settings, remove only the experiment
workloads, allow the OTEL shutdown flush, then stop the collector and VM.
Do not delete the broader `kubescape` namespace.

The fixtures use fixed trial names and refuse to overwrite result directories.
Use fresh names/directories or explicitly archive and clean up a previous
experiment before repeating. Kubernetes/CRI errors and OCI specifications can
contain the marker; the fixtures do not retain those raw responses as evidence.

## Validation and remaining limits

The Linux ARM64 node-agent built successfully. Focused Linux tests passed for
receiver/integration behavior, CRI verification, Kubernetes UID/node/filter
checks, opt-in config and the distinct OTEL record contract. Linux-targeted
vet passed for the changed packages. Portable CRI and OTEL tests also passed
with race detection on macOS. The capture privacy regression and the existing
22 Python gVisor-tool tests passed. This was not the full repository suite or
a Linux race run.

The previously unexplained 19/20 start run is not retrospectively explained.
This source remains experimental and disabled by default. It can miss very
short-lived containers during identity rechecks, discard queued starts on
cancellation, or lose records through the bounded OTEL batch exporter. Detailed
health metrics, fair buffering, reconnect/recovery policy, overhead benchmarks,
stop semantics and network signals remain follow-up work. There is no actor or
session attribution and no claim of in-sandbox behavioral enforcement.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"environment.json": "c6e016c7c689f0b3e8098817324524a416e627e9231968cb1bddc7a136f3263e",
"observations.jsonl": "c74d21a207fefc2275b08fbe4752ead692545922b93f6596efdad1c595d7ed36",
"runtime-ledger.json": "68e86ff45e6715404eeff75e871fd49438444046acea04791642c12f7382848c",
"verification.json": "cd45cf843f4dc04c4b76ac7725ee9881c867690418c1e3e38c44f3b67c08ec41"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"base_revision": "0ad6adb2",
"source": "working tree of this PR; test binary mounted into released base image",
"compiler": "go1.27.0",
"target": "linux/arm64, CGO_ENABLED=0",
"node_agent_binary_sha256": "8b3125d5acaca3ef3e45339590ab7e45d9ccdd91c0bdcb521dccef2f6cc610cb",
"base_image": "quay.io/kubescape/node-agent:v0.3.293",
"base_image_id": "sha256:0f31e6cfb343bfa2e486d59fe064c25a02c70bbc3fef4321dc31923fbe090577",
"node_agent_ready": true,
"kernel": "6.8.0-147-generic",
"k3s": "k3s version v1.37.1+k3s1 (356c0254)",
"runsc": "runsc version release-20261019.0",
"platform": "systrap",
"source_config": {
"enabled": true,
"socketPath": "/run/kubescape/gvisor-events.sock",
"runtimeEndpoint": "/run/k3s/containerd/containerd.sock",
"runtimeHandler": "runsc"
},
"application_profiles_enabled_during_trial": false,
"namespace_allowlist": "gvisor-comparison",
"otlp_destination": "local disposable VM collector"
}
Loading
Loading