seccheck: Trace sibling pipe processes in a client lib

PiperOrigin-RevId: 964771164
This commit is contained in:
Xin Zhong
2026-08-14 10:39:17 -07:00
committed by gVisor bot
parent b2ac6144a3
commit 488d530e97
15 changed files with 515 additions and 2 deletions
@@ -8,13 +8,16 @@ go_library(
name = "scsdk",
srcs = [
"client.go",
"events.go",
"fs.go",
"tracker.go",
],
visibility = ["//:sandbox"],
deps = [
"//pkg/abi/linux",
"//pkg/control/client",
"//pkg/sentry/control",
"//pkg/sentry/seccheck/points:points_go_proto",
"//pkg/urpc",
"@org_golang_x_sys//unix:go_default_library",
],
@@ -32,3 +35,13 @@ go_test(
"@org_golang_x_sys//unix:go_default_library",
],
)
go_test(
name = "tracker_test",
size = "small",
srcs = ["tracker_test.go"],
library = ":scsdk",
deps = [
"//pkg/sentry/seccheck/points:points_go_proto",
],
)
+111
View File
@@ -0,0 +1,111 @@
// Copyright 2026 The gVisor Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package scsdk
import (
pointspb "gvisor.dev/gvisor/pkg/sentry/seccheck/points/points_go_proto"
)
// ProcessContext uniquely identifies a thread-group/process and its environment
// at the time of an event.
type ProcessContext struct {
ThreadGroupID int32
ThreadGroupStartTimeNs int64
ContainerID string
ProcessName string
ParentThreadGroupID int32
}
// ExecutionEvent provides a Go presentation of the ExecveInfo protobuf.
type ExecutionEvent struct {
Context ProcessContext
BinaryPath string
Argv []string
Env []string
// Sibling pipes for enriching the event.
PipeInputSiblingContext *ProcessContext
PipeOutputSiblingContext *ProcessContext
}
// ToProcessContext translates a raw ContextData protobuf into the ProcessContext struct.
func ToProcessContext(ctx *pointspb.ContextData) ProcessContext {
if ctx == nil {
return ProcessContext{}
}
return ProcessContext{
ThreadGroupID: ctx.GetThreadGroupId(),
ThreadGroupStartTimeNs: ctx.GetThreadGroupStartTimeNs(),
ContainerID: ctx.GetContainerId(),
ProcessName: ctx.GetProcessName(),
ParentThreadGroupID: ctx.GetParentThreadGroupId(),
}
}
// ToProcessContextPtr translates a raw ContextData protobuf into the ProcessContext struct,
// returning nil if the input is nil.
func ToProcessContextPtr(ctx *pointspb.ContextData) *ProcessContext {
if ctx == nil {
return nil
}
c := ToProcessContext(ctx)
return &c
}
// ToExecutionEvent translates a raw ExecveInfo protobuf into the ExecutionEvent struct.
// It handles all the defensive nil-checking so SDK consumers don't have to.
func ToExecutionEvent(execve *pointspb.ExecveInfo) *ExecutionEvent {
if execve == nil {
return nil
}
ev := &ExecutionEvent{
Context: ToProcessContext(execve.GetContextData()),
BinaryPath: execve.GetBinaryPath(),
}
if len(execve.GetArgv()) > 0 {
ev.Argv = append([]string(nil), execve.GetArgv()...)
}
if len(execve.GetEnv()) > 0 {
ev.Env = append([]string(nil), execve.GetEnv()...)
}
if pipeIn := execve.GetPipeInputProc(); pipeIn != nil {
ev.PipeInputSiblingContext = ToProcessContextPtr(pipeIn.GetContextData())
}
if pipeOut := execve.GetPipeOutputProc(); pipeOut != nil {
ev.PipeOutputSiblingContext = ToProcessContextPtr(pipeOut.GetContextData())
}
return ev
}
// TaskExitEvent provides a Go presentation of the TaskExit protobuf.
type TaskExitEvent struct {
Context ProcessContext
ExitStatus int32
}
// ToTaskExitEvent translates a raw TaskExit protobuf into the SDK struct.
func ToTaskExitEvent(exit *pointspb.TaskExit) *TaskExitEvent {
if exit == nil {
return nil
}
return &TaskExitEvent{
Context: ToProcessContext(exit.GetContextData()),
ExitStatus: exit.GetExitStatus(),
}
}
+186
View File
@@ -0,0 +1,186 @@
// Copyright 2026 The gVisor Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ProcessTracker can be used by a trace consumer to track historical process metadata (PID, start
// time, binary path, argv, and env) and automatically enriches ExecveInfo events with sibling
// process information (PipeInputSibling and PipeOutputSibling) when processes are connected via
// stdin or stdout pipes.
//
// Usage:
//
// // Initialize a ProcessTracker in your monitoring daemon:
// pt := scsdk.NewProcessTracker()
//
// // Whenever a protobuf message arrives (from Unix socket, gRPC, or file):
// switch msg := protoMsg.(type) {
// case *pb.ExecveInfo:
// // Map the raw protobuf to a SDK event:
// execEvent := scsdk.ToExecutionEvent(msg)
// // ProcessExecve updates the tracker table and returns an EnrichedExecveEvent
// // where PipeInputSibling and PipeOutputSibling are automatically resolved.
// enriched := pt.ProcessExecve(execEvent)
// fmt.Printf("Process executed: %s (PID %d, Argv: %v)\n",
// enriched.Process.BinaryPath, enriched.Process.Key.ThreadGroupID, enriched.Process.Argv)
//
// if sib := enriched.PipeInputSibling; sib != nil {
// fmt.Printf(" <- Piped input from sibling: %s (PID %d, Argv: %v)\n",
// sib.BinaryPath, sib.Key.ThreadGroupID, sib.Argv)
// }
// if sib := enriched.PipeOutputSibling; sib != nil {
// fmt.Printf(" -> Piped output to sibling: %s (PID %d, Argv: %v)\n",
// sib.BinaryPath, sib.Key.ThreadGroupID, sib.Argv)
// }
//
// case *pb.TaskExit:
// exitEvent := scsdk.ToTaskExitEvent(msg)
// // ProcessTaskExit cleans up tracked state when a thread group exits.
// pt.ProcessTaskExit(exitEvent)
// }
package scsdk
import (
"sync"
)
// ProcessKey uniquely identifies a task/thread-group across time, safe against PID recycling.
type ProcessKey struct {
ThreadGroupID int32
ThreadGroupStartTimeNs int64
}
// ProcessInfo contains tracked state for a process inside a gVisor sandbox.
type ProcessInfo struct {
Key ProcessKey
ContainerID string
ProcessName string
BinaryPath string
Argv []string
Env []string
ParentThreadGroupID int32
}
// EnrichedExecveEvent contains the ExecveInfo protobuf event and automatically resolved
// sibling process information for stdin/stdout pipes.
//
// By decoupling string storage from the PipeProcInfo proto message,
// PipeInputSibling and PipeOutputSibling provide full binary/argv details without
// kernel-side string copying overhead.
type EnrichedExecveEvent struct {
Event *ExecutionEvent
Process *ProcessInfo
PipeInputSibling *ProcessInfo
PipeOutputSibling *ProcessInfo
}
// ProcessTracker manages the lifecycle of process metadata across Seccheck events.
type ProcessTracker struct {
mu sync.RWMutex
processes map[ProcessKey]*ProcessInfo
}
// NewProcessTracker creates an initialized ProcessTracker.
func NewProcessTracker() *ProcessTracker {
return &ProcessTracker{
processes: make(map[ProcessKey]*ProcessInfo),
}
}
// Lookup returns the ProcessInfo matching key, or nil if untracked.
func (pt *ProcessTracker) Lookup(key ProcessKey) *ProcessInfo {
pt.mu.RLock()
defer pt.mu.RUnlock()
return pt.processes[key]
}
// LookupFromContext creates a ProcessKey from a ProcessContext and returns the tracked process.
func (pt *ProcessTracker) LookupFromContext(ctx ProcessContext) *ProcessInfo {
key := ProcessKey{
ThreadGroupID: ctx.ThreadGroupID,
ThreadGroupStartTimeNs: ctx.ThreadGroupStartTimeNs,
}
return pt.Lookup(key)
}
// RegisterOrUpdate records process metadata from an ExecutionEvent.
func (pt *ProcessTracker) RegisterOrUpdate(execve *ExecutionEvent) *ProcessInfo {
key := ProcessKey{
ThreadGroupID: execve.Context.ThreadGroupID,
ThreadGroupStartTimeNs: execve.Context.ThreadGroupStartTimeNs,
}
pt.mu.Lock()
defer pt.mu.Unlock()
info, exists := pt.processes[key]
if !exists {
info = &ProcessInfo{Key: key}
pt.processes[key] = info
}
info.ContainerID = execve.Context.ContainerID
info.ProcessName = execve.Context.ProcessName
info.ParentThreadGroupID = execve.Context.ParentThreadGroupID
info.BinaryPath = execve.BinaryPath
info.Argv = append([]string(nil), execve.Argv...)
info.Env = append([]string(nil), execve.Env...)
return info
}
// Unregister deletes process metadata upon task exit given its context.
func (pt *ProcessTracker) Unregister(ctx ProcessContext) {
key := ProcessKey{
ThreadGroupID: ctx.ThreadGroupID,
ThreadGroupStartTimeNs: ctx.ThreadGroupStartTimeNs,
}
pt.mu.Lock()
defer pt.mu.Unlock()
delete(pt.processes, key)
}
// ProcessExecve processes an ExecutionEvent, updates state, and returns an
// enriched event.
//
// Because PipeProcInfo only transports ContextData, we resolve full sibling
// process info (binary, argv) by querying our ProcessTracker using the TGID and StartTime.
func (pt *ProcessTracker) ProcessExecve(execve *ExecutionEvent) *EnrichedExecveEvent {
if execve == nil {
return nil
}
// Update our tracker with the current process's binary, argv, and env.
currentProc := pt.RegisterOrUpdate(execve)
// Resolve sibling process information via ContextData keys from PipeInputProc/PipeOutputProc.
var inSibling, outSibling *ProcessInfo
if execve.PipeInputSiblingContext != nil {
inSibling = pt.LookupFromContext(*execve.PipeInputSiblingContext)
}
if execve.PipeOutputSiblingContext != nil {
outSibling = pt.LookupFromContext(*execve.PipeOutputSiblingContext)
}
return &EnrichedExecveEvent{
Event: execve,
Process: currentProc,
PipeInputSibling: inSibling,
PipeOutputSibling: outSibling,
}
}
// ProcessTaskExit processes an TaskExitEvent and cleans up tracker state.
func (pt *ProcessTracker) ProcessTaskExit(exit *TaskExitEvent) {
if exit != nil {
pt.Unregister(exit.Context)
}
}
+89
View File
@@ -0,0 +1,89 @@
// Copyright 2026 The gVisor Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package scsdk
import (
"testing"
pb "gvisor.dev/gvisor/pkg/sentry/seccheck/points/points_go_proto"
)
func TestProcessTrackerAndPipeEnrichment(t *testing.T) {
pt := NewProcessTracker()
// Simulate an ExecveInfo event for process A ("cat foo").
// Design Decision: Only standard ExecveInfo events carry the full strings (BinaryPath, Argv).
execA := &pb.ExecveInfo{
ContextData: &pb.ContextData{
ThreadGroupId: 100,
ThreadGroupStartTimeNs: 1000,
ContainerId: "test-container",
ProcessName: "cat",
},
BinaryPath: "/bin/cat",
Argv: []string{"cat", "foo"},
}
eventA := pt.ProcessExecve(ToExecutionEvent(execA))
if eventA.Process == nil {
t.Fatalf("got nil, want ProcessInfo for cat")
}
if eventA.Process.BinaryPath != "/bin/cat" {
t.Errorf("got binary %q, want %q", eventA.Process.BinaryPath, "/bin/cat")
}
// Simulate an ExecveInfo event for process B ("grep bar") which receives piped input from A.
// Design Decision: Notice that PipeInputProc ONLY contains ContextData (TGID=100, StartTime=1000)
// without any string fields, eliminating kernel string copy overhead.
execB := &pb.ExecveInfo{
ContextData: &pb.ContextData{
ThreadGroupId: 101,
ThreadGroupStartTimeNs: 1010,
ContainerId: "test-container",
ProcessName: "grep",
},
BinaryPath: "/bin/grep",
Argv: []string{"grep", "bar"},
PipeInputProc: &pb.PipeProcInfo{
ContextData: &pb.ContextData{
ThreadGroupId: 100,
ThreadGroupStartTimeNs: 1000,
},
},
}
eventB := pt.ProcessExecve(ToExecutionEvent(execB))
if eventB.PipeInputSibling == nil {
t.Fatalf("got nil, want PipeInputSibling to be resolved from tracker")
}
if eventB.PipeInputSibling.BinaryPath != "/bin/cat" {
t.Errorf("got sibling binary %q, want %q", eventB.PipeInputSibling.BinaryPath, "/bin/cat")
}
if len(eventB.PipeInputSibling.Argv) != 2 || eventB.PipeInputSibling.Argv[1] != "foo" {
t.Errorf("got sibling argv %v, want [cat foo]", eventB.PipeInputSibling.Argv)
}
// Simulate TaskExit for process A and verify cleanup.
pt.ProcessTaskExit(ToTaskExitEvent(&pb.TaskExit{
ContextData: &pb.ContextData{
ThreadGroupId: 100,
ThreadGroupStartTimeNs: 1000,
},
}))
keyA := ProcessKey{ThreadGroupID: 100, ThreadGroupStartTimeNs: 1000}
if info := pt.Lookup(keyA); info != nil {
t.Errorf("got %v, want process A to be unregistered after exit", info)
}
}
+1
View File
@@ -402,6 +402,7 @@ go_library(
"//pkg/sentry/kernel/ipc",
"//pkg/sentry/kernel/mq",
"//pkg/sentry/kernel/msgqueue",
"//pkg/sentry/kernel/pipe",
"//pkg/sentry/kernel/sched",
"//pkg/sentry/kernel/semaphore",
"//pkg/sentry/kernel/shm",
+10
View File
@@ -47,6 +47,11 @@ func NewVFSPipe(isNamed bool, sizeBytes int64) *VFSPipe {
return &vp
}
// Pipe returns the underlying Pipe object.
func (vp *VFSPipe) Pipe() *Pipe {
return &vp.pipe
}
// ReaderWriterPair returns read-only and write-only FDs for vp.
//
// Preconditions: statusFlags should not contain an open access mode.
@@ -175,6 +180,11 @@ type VFSPipeFD struct {
lastAddr hostarch.Addr
}
// Pipe returns the underlying Pipe object.
func (fd *VFSPipeFD) Pipe() *Pipe {
return fd.pipe
}
// Release implements vfs.FileDescriptionImpl.Release.
func (fd *VFSPipeFD) Release(context.Context) {
var event waiter.EventMask
+80
View File
@@ -71,6 +71,7 @@ import (
"gvisor.dev/gvisor/pkg/sentry/fsimpl/overlay"
"gvisor.dev/gvisor/pkg/sentry/fsimpl/tmpfs"
"gvisor.dev/gvisor/pkg/sentry/kernel/auth"
"gvisor.dev/gvisor/pkg/sentry/kernel/pipe"
"gvisor.dev/gvisor/pkg/sentry/loader"
"gvisor.dev/gvisor/pkg/sentry/mm"
"gvisor.dev/gvisor/pkg/sentry/seccheck"
@@ -536,6 +537,70 @@ func execveSeccheckInfo(t *Task, argv, env []string, executable *vfs.FileDescrip
info.Stderr = execveFdInfo(t, 2)
}
if fields.Local.Contains(seccheck.FieldSentryExecvePipeProcInfo) {
// Check whether standard input (fd 0) or standard output (fd 1)
// is connected to a pipe by extracting the underlying Pipe object.
var pipeIn, pipeOut *pipe.Pipe
if fileIn := t.GetFile(0); fileIn != nil {
if pfd, ok := fileIn.Impl().(*pipe.VFSPipeFD); ok {
pipeIn = pfd.Pipe()
}
fileIn.DecRef(t)
}
if fileOut := t.GetFile(1); fileOut != nil {
if pfd, ok := fileOut.Impl().(*pipe.VFSPipeFD); ok {
pipeOut = pfd.Pipe()
}
fileOut.DecRef(t)
}
// Inspect sibling tasks under the same parent to find processes connected to the
// opposite end of stdin (pipeIn) or stdout (pipeOut).
if pipeIn != nil || pipeOut != nil {
// Only inspect the parent if pipeIn or pipeOut are present. Calling
// t.Parent() grabs a read lock on the PID namespace owner, which we want to avoid
// on the fast path if no pipes are connected.
if parent := t.Parent(); parent != nil {
for child := range parent.Children() {
if child == t {
continue
}
// Early termination: stop scanning siblings as soon as all applicable
// pipe peers (stdin and/or stdout) have been found or are not pipes.
if (pipeIn == nil || info.PipeInputProc != nil) && (pipeOut == nil || info.PipeOutputProc != nil) {
break
}
fdt := child.FDTable()
if fdt == nil {
continue
}
// Check if the sibling writes to t's stdin pipe via stdout (fd 1).
if pipeIn != nil && info.PipeInputProc == nil {
if childOut, _ := fdt.Get(1); childOut != nil {
pfd, ok := childOut.Impl().(*pipe.VFSPipeFD)
if ok && pfd.Pipe() == pipeIn {
info.PipeInputProc = pipeProcInfo(child, fields.Context)
}
childOut.DecRef(t)
}
}
// Check if the sibling reads from t's stdout pipe via stdin (fd 0).
if pipeOut != nil && info.PipeOutputProc == nil {
if childIn, _ := fdt.Get(0); childIn != nil {
pfd, ok := childIn.Impl().(*pipe.VFSPipeFD)
if ok && pfd.Pipe() == pipeOut {
info.PipeOutputProc = pipeProcInfo(child, fields.Context)
}
childIn.DecRef(t)
}
}
}
}
}
}
if !fields.Context.Empty() {
info.ContextData = &pb.ContextData{}
LoadSeccheckData(t, fields.Context, info.ContextData)
@@ -719,3 +784,18 @@ func (t *Task) releaseExecveCredsLocks() {
t.execveCredsMutexUnlock()
t.FSContext().allowSharing()
}
// pipeProcInfo collects Seccheck process information for a sibling task connected via a pipe.
//
// To avoid returning heavy strings (like argv or binary path) on every execve trace point, we only
// populate the lightweight ContextData. A stateful seccheck client library can
// correlate the sibling's thread_group_id and thread_group_start_time_ns with prior Execve/Clone
// events to enrich the trace with full binary_path and argv without kernel overhead.
func pipeProcInfo(child *Task, contextMask seccheck.FieldMask) *pb.PipeProcInfo {
res := &pb.PipeProcInfo{}
if !contextMask.Empty() {
res.ContextData = &pb.ContextData{}
LoadSeccheckData(child, contextMask, res.ContextData)
}
return res
}
+8
View File
@@ -75,6 +75,10 @@ const (
// FieldSentryExecveFdInfo is an optional field to collect information
// about standard file descriptors (stdin, stdout, stderr).
FieldSentryExecveFdInfo
// FieldSentryExecvePipeProcInfo is an optional field to collect lightweight
// information about sibling processes connected via pipes.
FieldSentryExecvePipeProcInfo
)
// Points is a map with all the trace points registered in the system.
@@ -275,6 +279,10 @@ func genericInit() {
ID: FieldSentryExecveFdInfo,
Name: "fd_info",
},
{
ID: FieldSentryExecvePipeProcInfo,
Name: "pipe_proc_info",
},
},
ContextFields: defaultContextFields,
})
+15
View File
@@ -110,6 +110,21 @@ message ExecveInfo {
// AT_EXECFN). Unlike BinaryPath, this preserves the original relative or
// unresolved path argument before symlink or interpreter script resolution.
string execfn = 17;
// PipeProcInfo contains lightweight process information of a sibling process
// connected via pipe on stdin/stdout.
PipeProcInfo pipe_input_proc = 21;
PipeProcInfo pipe_output_proc = 22;
}
// PipeProcInfo contains lightweight process information of a sibling process
// connected via pipe.
//
// Only ContextData is provided here so that trace consumers can uniquely
// correlate the sibling process via thread_group_id and
// thread_group_start_time_ns without passing repetitive strings over the wire.
message PipeProcInfo {
gvisor.common.ContextData context_data = 1;
}
message ExitNotifyParentInfo {
+1 -1
View File
@@ -79,6 +79,7 @@ go_test(
"//pkg/cleanup",
"//pkg/hostos",
"//pkg/log",
"//pkg/seccheck/scsdk",
"//pkg/sentry/checkpoint",
"//pkg/sentry/control",
"//pkg/sentry/fscheckpoint",
@@ -90,7 +91,6 @@ go_test(
"//pkg/sentry/platform",
"//pkg/sentry/seccheck",
"//pkg/sentry/seccheck/points:points_go_proto",
"//pkg/sentry/seccheck/scsdk",
"//pkg/sentry/seccheck/sinks/remote/test",
"//pkg/sentry/state/checkpointfiles",
"//pkg/state/statefile",
+1 -1
View File
@@ -24,11 +24,11 @@ import (
specs "github.com/opencontainers/runtime-spec/specs-go"
"golang.org/x/sys/unix"
"google.golang.org/protobuf/proto"
"gvisor.dev/gvisor/pkg/seccheck/scsdk"
"gvisor.dev/gvisor/pkg/sentry/kernel"
"gvisor.dev/gvisor/pkg/sentry/limits"
"gvisor.dev/gvisor/pkg/sentry/seccheck"
pb "gvisor.dev/gvisor/pkg/sentry/seccheck/points/points_go_proto"
"gvisor.dev/gvisor/pkg/sentry/seccheck/scsdk"
"gvisor.dev/gvisor/pkg/sentry/seccheck/sinks/remote/test"
"gvisor.dev/gvisor/pkg/test/testutil"
"gvisor.dev/gvisor/runsc/boot"