Skip to content

Commit 4b42dbe

Browse files
Sanitizes OpenTelemetry trace attribute sandbox.command (#895)
* #### What this PR does / why we need it: Sanitizes OpenTelemetry trace attribute `sandbox.command` to prevent sensitive data exposure. Instead of logging the raw unsanitized command string containing potentially sensitive arguments (like tokens, passwords, or API keys), it now extracts and logs only the base executable name of the command under the new attribute key `sandbox.command.executable`. This fix is applied to both the Python SDK (sync and async command executors) and the Go SDK clients. #### Which issue(s) this PR is related to: Fixes b/511323142 #### Release Note ```release-note Security fix: Sanitize raw command trace logging to log only the executable name under `sandbox.command.executable` instead of the full command string, avoiding potential sensitive data exposure. ``` * address comments on tightening security posture and adding test coverage * fix go lint and modernize formatting
1 parent e442ec1 commit 4b42dbe

6 files changed

Lines changed: 180 additions & 14 deletions

File tree

clients/go/sandbox/commands.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"fmt"
2222
"io"
2323
"net/http"
24+
"strings"
2425

2526
"github.com/go-logr/logr"
2627
"go.opentelemetry.io/otel/trace"
@@ -56,7 +57,7 @@ func (c *Commands) Run(ctx context.Context, command string, opts ...CallOption)
5657
maxAttempts = 1 // safe default: no retries for non-idempotent commands
5758
}
5859
ctx = withLifecycleSpan(ctx, c.lifecycleCtx())
59-
ctx, span := startSpan(ctx, c.tracer, c.svcName, "run", AttrCommand.String(command))
60+
ctx, span := startSpan(ctx, c.tracer, c.svcName, "run", AttrCommandExecutable.String(commandExecutable(command)))
6061
defer func() { span.End() }()
6162

6263
payload, err := json.Marshal(map[string]string{"command": command})
@@ -95,3 +96,17 @@ func (c *Commands) Run(ctx context.Context, command string, opts ...CallOption)
9596
c.log.V(1).Info("run completed", "exitCode", result.ExitCode)
9697
return &result, nil
9798
}
99+
100+
func commandExecutable(command string) string {
101+
fields := strings.FieldsSeq(command)
102+
for field := range fields {
103+
// Skip leading inline environment variables (e.g., KEY=VALUE)
104+
if strings.Contains(field, "=") {
105+
continue
106+
}
107+
// Extract base executable name (strip directory paths)
108+
parts := strings.Split(field, "/")
109+
return parts[len(parts)-1]
110+
}
111+
return ""
112+
}

clients/go/sandbox/tracing.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,16 @@ import (
3232

3333
// Span attribute keys in the sandbox.* namespace.
3434
var (
35-
AttrClaimName = attribute.Key("sandbox.claim.name")
36-
AttrCommand = attribute.Key("sandbox.command")
37-
AttrExitCode = attribute.Key("sandbox.exit_code")
38-
AttrFilePath = attribute.Key("sandbox.file.path")
39-
AttrFileSize = attribute.Key("sandbox.file.size")
40-
AttrFileCount = attribute.Key("sandbox.file.count")
41-
AttrFileExists = attribute.Key("sandbox.file.exists")
42-
AttrGatewayName = attribute.Key("sandbox.gateway.name")
43-
AttrGatewayNamespace = attribute.Key("sandbox.gateway.namespace")
44-
AttrRequestID = attribute.Key("sandbox.request_id")
35+
AttrClaimName = attribute.Key("sandbox.claim.name")
36+
AttrCommandExecutable = attribute.Key("sandbox.command.executable")
37+
AttrExitCode = attribute.Key("sandbox.exit_code")
38+
AttrFilePath = attribute.Key("sandbox.file.path")
39+
AttrFileSize = attribute.Key("sandbox.file.size")
40+
AttrFileCount = attribute.Key("sandbox.file.count")
41+
AttrFileExists = attribute.Key("sandbox.file.exists")
42+
AttrGatewayName = attribute.Key("sandbox.gateway.name")
43+
AttrGatewayNamespace = attribute.Key("sandbox.gateway.namespace")
44+
AttrRequestID = attribute.Key("sandbox.request_id")
4545
)
4646

4747
// NewTracerProvider creates a TracerProvider with an OTLP/gRPC exporter.

clients/go/sandbox/tracing_test.go

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ func TestTracingLifecycleAndOperations(t *testing.T) {
253253
}
254254

255255
// Verify Run attributes
256-
assertSpanAttr(t, spanByName["test-svc.run"], "sandbox.command", "echo hello")
256+
assertSpanAttr(t, spanByName["test-svc.run"], "sandbox.command.executable", "echo")
257257
assertSpanAttrInt(t, spanByName["test-svc.run"], "sandbox.exit_code", 0)
258258

259259
// Verify Write attributes
@@ -484,3 +484,39 @@ func assertSpanAttrBool(t *testing.T, span tracetest.SpanStub, key string, want
484484
}
485485
t.Errorf("span %s: missing attribute %s", span.Name, key)
486486
}
487+
488+
func TestCommandExecutable(t *testing.T) {
489+
tests := []struct {
490+
name string
491+
command string
492+
want string
493+
}{
494+
{
495+
name: "simple command",
496+
command: "echo hello",
497+
want: "echo",
498+
},
499+
{
500+
name: "command with path",
501+
command: "/usr/bin/python3 -c 'print()'",
502+
want: "python3",
503+
},
504+
{
505+
name: "command with leading env vars",
506+
command: "API_KEY=secret_token TOKEN=xyz ./run.sh --arg",
507+
want: "run.sh",
508+
},
509+
{
510+
name: "empty command",
511+
command: " ",
512+
want: "",
513+
},
514+
}
515+
for _, tt := range tests {
516+
t.Run(tt.name, func(t *testing.T) {
517+
if got := commandExecutable(tt.command); got != tt.want {
518+
t.Errorf("commandExecutable(%q) = %q, want %q", tt.command, got, tt.want)
519+
}
520+
})
521+
}
522+
}

clients/python/agentic-sandbox-client/k8s_agent_sandbox/commands/async_command_executor.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@
1717
from k8s_agent_sandbox.trace_manager import async_trace_span, trace
1818

1919

20+
def _extract_executable(command: str) -> str:
21+
if not command:
22+
return ""
23+
for field in command.split():
24+
# Skip leading inline environment variables (e.g., KEY=VALUE)
25+
if "=" in field:
26+
continue
27+
# Extract base executable name (strip directory paths)
28+
return field.split("/")[-1]
29+
return ""
30+
31+
2032
class AsyncCommandExecutor:
2133
"""
2234
Handles async execution of commands within the sandbox.
@@ -31,7 +43,8 @@ def __init__(self, connector: AsyncSandboxConnector, tracer, trace_service_name:
3143
async def run(self, command: str, timeout: int = 60) -> ExecutionResult:
3244
span = trace.get_current_span()
3345
if span.is_recording():
34-
span.set_attribute("sandbox.command", command)
46+
executable = _extract_executable(command)
47+
span.set_attribute("sandbox.command.executable", executable)
3548

3649
payload = {"command": command}
3750
response = await self.connector.send_request(

clients/python/agentic-sandbox-client/k8s_agent_sandbox/commands/command_executor.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@
1717
from k8s_agent_sandbox.models import ExecutionResult
1818
from k8s_agent_sandbox.trace_manager import trace_span, trace
1919

20+
def _extract_executable(command: str) -> str:
21+
if not command:
22+
return ""
23+
for field in command.split():
24+
# Skip leading inline environment variables (e.g., KEY=VALUE)
25+
if "=" in field:
26+
continue
27+
# Extract base executable name (strip directory paths)
28+
return field.split("/")[-1]
29+
return ""
30+
31+
2032
class CommandExecutor:
2133
"""
2234
Handles execution of commands within the sandbox.
@@ -30,7 +42,8 @@ def __init__(self, connector: SandboxConnector, tracer, trace_service_name: str)
3042
def run(self, command: str, timeout: int = 60) -> ExecutionResult:
3143
span = trace.get_current_span()
3244
if span.is_recording():
33-
span.set_attribute("sandbox.command", command)
45+
executable = _extract_executable(command)
46+
span.set_attribute("sandbox.command.executable", executable)
3447

3548
payload = {"command": command}
3649
response = self.connector.send_request(
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Copyright 2026 The Kubernetes Authors.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import unittest
16+
from unittest.mock import MagicMock, patch
17+
18+
from k8s_agent_sandbox.commands.command_executor import CommandExecutor, _extract_executable
19+
from k8s_agent_sandbox.commands.async_command_executor import AsyncCommandExecutor
20+
from k8s_agent_sandbox.models import ExecutionResult
21+
22+
23+
class TestCommandExecutor(unittest.TestCase):
24+
25+
def test_extract_executable(self):
26+
tests = [
27+
("echo hello", "echo"),
28+
("/usr/bin/python3 -c 'print()'", "python3"),
29+
("API_KEY=secret_token TOKEN=xyz ./run.sh --arg", "run.sh"),
30+
(" ", ""),
31+
("", ""),
32+
]
33+
for command, expected in tests:
34+
with self.subTest(command=command):
35+
self.assertEqual(_extract_executable(command), expected)
36+
37+
@patch("k8s_agent_sandbox.commands.command_executor.trace")
38+
def test_sync_executor_logs_executable(self, mock_trace):
39+
mock_span = MagicMock()
40+
mock_span.is_recording.return_value = True
41+
mock_trace.get_current_span.return_value = mock_span
42+
43+
mock_connector = MagicMock()
44+
mock_response = MagicMock()
45+
mock_response.json.return_value = {
46+
"stdout": "hello",
47+
"stderr": "",
48+
"exit_code": 0
49+
}
50+
mock_connector.send_request.return_value = mock_response
51+
52+
executor = CommandExecutor(mock_connector, MagicMock(), "sandbox-client")
53+
result = executor.run("API_KEY=123 /usr/bin/python3 my_script.py")
54+
55+
mock_span.set_attribute.assert_any_call("sandbox.command.executable", "python3")
56+
mock_span.set_attribute.assert_any_call("sandbox.exit_code", 0)
57+
self.assertEqual(result.stdout, "hello")
58+
59+
60+
class TestAsyncCommandExecutor(unittest.IsolatedAsyncioTestCase):
61+
62+
@patch("k8s_agent_sandbox.commands.async_command_executor.trace")
63+
async def test_async_executor_logs_executable(self, mock_trace):
64+
mock_span = MagicMock()
65+
mock_span.is_recording.return_value = True
66+
mock_trace.get_current_span.return_value = mock_span
67+
68+
mock_connector = MagicMock()
69+
mock_response = MagicMock()
70+
mock_response.json.return_value = {
71+
"stdout": "hello_async",
72+
"stderr": "",
73+
"exit_code": 0
74+
}
75+
76+
async def async_send(*args, **kwargs):
77+
return mock_response
78+
mock_connector.send_request = async_send
79+
80+
executor = AsyncCommandExecutor(mock_connector, MagicMock(), "sandbox-client")
81+
result = await executor.run("API_KEY=123 /usr/bin/python3 my_script.py")
82+
83+
mock_span.set_attribute.assert_any_call("sandbox.command.executable", "python3")
84+
mock_span.set_attribute.assert_any_call("sandbox.exit_code", 0)
85+
self.assertEqual(result.stdout, "hello_async")
86+
87+
88+
if __name__ == "__main__":
89+
unittest.main()

0 commit comments

Comments
 (0)