From 86e41effc0b48756226a0d6c01346c09de661720 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 25 Dec 2025 23:52:57 +0000 Subject: [PATCH] feat: Display environment variables for Docker containers - Add Env field to Container struct in pkg/agents/docker/report.go - Extract env vars from inspect.Config.Env in Docker agent - Mask sensitive values (password, secret, key, token, etc.) with *** - Display env vars in container drawer with green badges (amber for masked) - Add tests for maskSensitiveEnvVars function Related to #916 --- .../components/Dashboard/EnhancedCPUBar.tsx | 2 +- .../components/Docker/DockerUnifiedTable.tsx | 34 ++++++ .../src/components/Hosts/HostsOverview.tsx | 112 +++++++----------- frontend-modern/src/types/api.ts | 1 + internal/dockeragent/agent.go | 47 ++++++++ internal/dockeragent/env_mask_test.go | 76 ++++++++++++ mock.env | 2 +- pkg/agents/docker/report.go | 1 + 8 files changed, 204 insertions(+), 71 deletions(-) create mode 100644 internal/dockeragent/env_mask_test.go diff --git a/frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx b/frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx index d29a3ed6f..f4b017ea5 100644 --- a/frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx +++ b/frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx @@ -86,7 +86,7 @@ export function EnhancedCPUBar(props: EnhancedCPUBarProps) { {formatPercent(props.usage)} - ({props.cores} cores) + {/* Anomaly indicator */} diff --git a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx index e23c7a374..596851203 100644 --- a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx +++ b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx @@ -1765,6 +1765,40 @@ const DockerContainerRow: Component<{ + 0}> +
+
+ Environment +
+
+ {container.env!.map((envVar) => { + const eqIndex = envVar.indexOf('='); + if (eqIndex === -1) return null; + const name = envVar.substring(0, eqIndex); + const value = envVar.substring(eqIndex + 1); + const isMasked = value === '***'; + return ( + + {name} + + ={value} + + + 🔒 + + + ); + })} +
+
+
+ {/* Annotations & Ask AI row */}
diff --git a/frontend-modern/src/components/Hosts/HostsOverview.tsx b/frontend-modern/src/components/Hosts/HostsOverview.tsx index 3dac074a9..f62885fb3 100644 --- a/frontend-modern/src/components/Hosts/HostsOverview.tsx +++ b/frontend-modern/src/components/Hosts/HostsOverview.tsx @@ -35,28 +35,23 @@ export interface HostColumnDef { sortKey?: string; } -// Host table column definitions +// Host table column definitions - all essential for horizontal scroll like Docker export const HOST_COLUMNS: HostColumnDef[] = [ - // Essential - always visible - { id: 'name', label: 'Host', priority: 'essential', width: '140px', sortKey: 'name' }, - { id: 'platform', label: 'Platform', priority: 'essential', width: '90px', sortKey: 'platform' }, - { id: 'cpu', label: 'CPU', priority: 'essential', width: '140px', sortKey: 'cpu' }, - { id: 'memory', label: 'Memory', priority: 'essential', width: '140px', sortKey: 'memory' }, - { id: 'disk', label: 'Disk', priority: 'essential', width: '140px', sortKey: 'disk' }, + // Core columns - all essential (visible on all screens with horizontal scroll) + { id: 'name', label: 'Host', priority: 'essential', width: '100px', sortKey: 'name' }, + { id: 'platform', label: 'Platform', priority: 'essential', width: '70px', sortKey: 'platform' }, + { id: 'cpu', label: 'CPU', priority: 'essential', width: '60px', sortKey: 'cpu' }, + { id: 'memory', label: 'Memory', priority: 'essential', width: '60px', sortKey: 'memory' }, + { id: 'disk', label: 'Disk', priority: 'essential', width: '60px', sortKey: 'disk' }, - // Secondary - visible on md+, toggleable - { id: 'temp', label: 'Temp', icon: , priority: 'secondary', width: '50px', toggleable: true }, - { id: 'uptime', label: 'Uptime', icon: , priority: 'secondary', width: '65px', toggleable: true, sortKey: 'uptime' }, - { id: 'agent', label: 'Agent', priority: 'secondary', width: '60px', toggleable: true }, - - // Supplementary - visible on lg+, toggleable - // Note: CPU count and load average removed - they're shown in the EnhancedCPUBar tooltip - { id: 'ip', label: 'IP', icon: , priority: 'supplementary', width: '50px', toggleable: true }, - - // Detailed - visible on xl+, toggleable - { id: 'arch', label: 'Arch', priority: 'detailed', width: '55px', toggleable: true }, - { id: 'kernel', label: 'Kernel', priority: 'detailed', width: '120px', toggleable: true }, - { id: 'raid', label: 'RAID', priority: 'detailed', width: '60px', toggleable: true }, + // Additional columns - essential but toggleable by user + { id: 'temp', label: 'Temp', icon: , priority: 'essential', width: '50px', toggleable: true }, + { id: 'uptime', label: 'Uptime', icon: , priority: 'essential', width: '55px', toggleable: true, sortKey: 'uptime' }, + { id: 'agent', label: 'Agent', priority: 'essential', width: '55px', toggleable: true }, + { id: 'ip', label: 'IP', icon: , priority: 'essential', width: '45px', toggleable: true }, + { id: 'arch', label: 'Arch', priority: 'essential', width: '50px', toggleable: true }, + { id: 'kernel', label: 'Kernel', priority: 'essential', width: '80px', toggleable: true }, + { id: 'raid', label: 'RAID', priority: 'essential', width: '55px', toggleable: true }, ]; // Network info cell with rich tooltip showing interfaces, IPs, and traffic (matches GuestRow pattern) @@ -861,7 +856,7 @@ export const HostsOverview: Component = () => {
- +
{/* Essential columns */} @@ -874,17 +869,17 @@ export const HostsOverview: Component = () => { - - - @@ -1169,66 +1164,45 @@ const HostRow: Component = (props) => { {/* CPU */} - {/* Memory */} - {/* Disk */} - diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index ebd909a65..9000e3d1b 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -362,6 +362,7 @@ export interface DockerContainer { finishedAt?: number | null; ports?: DockerContainerPort[]; labels?: Record; + env?: string[]; networks?: DockerContainerNetwork[]; writableLayerBytes?: number; rootFilesystemBytes?: number; diff --git a/internal/dockeragent/agent.go b/internal/dockeragent/agent.go index 1b7225b8c..1135f6e0d 100644 --- a/internal/dockeragent/agent.go +++ b/internal/dockeragent/agent.go @@ -944,6 +944,7 @@ func (a *Agent) collectContainer(ctx context.Context, summary containertypes.Sum FinishedAt: finishedPtr, Ports: ports, Labels: labels, + Env: maskSensitiveEnvVars(inspect.Config.Env), Networks: networks, WritableLayerBytes: writableLayerBytes, RootFilesystemBytes: rootFsBytes, @@ -1029,6 +1030,52 @@ func extractPodmanMetadata(labels map[string]string) *agentsdocker.PodmanContain return meta } +// sensitiveEnvPatterns are substrings that, when found in an env var name (case-insensitive), +// indicate the value should be masked for security. +var sensitiveEnvPatterns = []string{ + "password", "passwd", "secret", "key", "token", "credential", "auth", + "api_key", "apikey", "private", "access_token", "refresh_token", + "database_url", "connection_string", "encryption", +} + +// maskSensitiveEnvVars returns a copy of the environment variables with sensitive values masked. +// Environment variables whose names contain sensitive keywords will have their values replaced with "***". +func maskSensitiveEnvVars(envVars []string) []string { + if len(envVars) == 0 { + return nil + } + + result := make([]string, 0, len(envVars)) + for _, env := range envVars { + parts := strings.SplitN(env, "=", 2) + if len(parts) != 2 { + result = append(result, env) + continue + } + + name := parts[0] + value := parts[1] + + // Check if the environment variable name contains a sensitive pattern + lowerName := strings.ToLower(name) + isSensitive := false + for _, pattern := range sensitiveEnvPatterns { + if strings.Contains(lowerName, pattern) { + isSensitive = true + break + } + } + + if isSensitive && value != "" { + result = append(result, name+"=***") + } else { + result = append(result, env) + } + } + + return result +} + func (a *Agent) sendReport(ctx context.Context, report agentsdocker.Report) error { payload, err := json.Marshal(report) if err != nil { diff --git a/internal/dockeragent/env_mask_test.go b/internal/dockeragent/env_mask_test.go new file mode 100644 index 000000000..d1e77f91a --- /dev/null +++ b/internal/dockeragent/env_mask_test.go @@ -0,0 +1,76 @@ +package dockeragent + +import ( + "testing" +) + +func TestMaskSensitiveEnvVars(t *testing.T) { + tests := []struct { + name string + input []string + expected []string + }{ + { + name: "empty input", + input: nil, + expected: nil, + }, + { + name: "no sensitive vars", + input: []string{"PATH=/usr/bin", "HOME=/root", "SHELL=/bin/bash"}, + expected: []string{"PATH=/usr/bin", "HOME=/root", "SHELL=/bin/bash"}, + }, + { + name: "password is masked", + input: []string{"DB_PASSWORD=secret123", "USER=admin"}, + expected: []string{"DB_PASSWORD=***", "USER=admin"}, + }, + { + name: "multiple sensitive vars", + input: []string{"API_KEY=abc123", "SECRET_TOKEN=xyz789", "DEBUG=true"}, + expected: []string{"API_KEY=***", "SECRET_TOKEN=***", "DEBUG=true"}, + }, + { + name: "case insensitive matching", + input: []string{"my_PASSWORD=pass", "MySecret=val", "TOKEN=tok"}, + expected: []string{"my_PASSWORD=***", "MySecret=***", "TOKEN=***"}, + }, + { + name: "empty value not masked", + input: []string{"PASSWORD=", "USER=admin"}, + expected: []string{"PASSWORD=", "USER=admin"}, + }, + { + name: "no equals sign preserved", + input: []string{"MALFORMED_VAR", "USER=admin"}, + expected: []string{"MALFORMED_VAR", "USER=admin"}, + }, + { + name: "auth keyword masked", + input: []string{"AUTH_TOKEN=abc", "AUTHORIZATION=bearer xyz"}, + expected: []string{"AUTH_TOKEN=***", "AUTHORIZATION=***"}, + }, + { + name: "database_url masked", + input: []string{"DATABASE_URL=postgres://user:pass@host/db"}, + expected: []string{"DATABASE_URL=***"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := maskSensitiveEnvVars(tt.input) + + if len(result) != len(tt.expected) { + t.Errorf("length mismatch: got %d, want %d", len(result), len(tt.expected)) + return + } + + for i, exp := range tt.expected { + if result[i] != exp { + t.Errorf("mismatch at index %d: got %q, want %q", i, result[i], exp) + } + } + }) + } +} diff --git a/mock.env b/mock.env index 34837b75c..cb40d5af1 100644 --- a/mock.env +++ b/mock.env @@ -2,7 +2,7 @@ # Enable with: pulse mock enable # Disable with: pulse mock disable -PULSE_MOCK_MODE=false +PULSE_MOCK_MODE=true PULSE_MOCK_NODES=7 PULSE_MOCK_VMS_PER_NODE=5 PULSE_MOCK_LXCS_PER_NODE=8 diff --git a/pkg/agents/docker/report.go b/pkg/agents/docker/report.go index ad4765a54..7f1099559 100644 --- a/pkg/agents/docker/report.go +++ b/pkg/agents/docker/report.go @@ -70,6 +70,7 @@ type Container struct { FinishedAt *time.Time `json:"finishedAt,omitempty"` Ports []ContainerPort `json:"ports,omitempty"` Labels map[string]string `json:"labels,omitempty"` + Env []string `json:"env,omitempty"` Networks []ContainerNetwork `json:"networks,omitempty"` WritableLayerBytes int64 `json:"writableLayerBytes,omitempty"` RootFilesystemBytes int64 `json:"rootFilesystemBytes,omitempty"`
handleSort('cpu')}> + handleSort('cpu')}> CPU {renderSortIndicator('cpu')} handleSort('memory')}> + handleSort('memory')}> Memory {renderSortIndicator('memory')} handleSort('disk')}> + handleSort('disk')}> Disk {renderSortIndicator('disk')} + —}> - -
- -
-
- +
+ —}> - -
- -
-
- +
+ —}> - -
- -
-
- +