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
This commit is contained in:
rcourtman 2025-12-25 23:52:57 +00:00
parent 4f4fdcf81b
commit 86e41effc0
8 changed files with 204 additions and 71 deletions

View file

@ -86,7 +86,7 @@ export function EnhancedCPUBar(props: EnhancedCPUBarProps) {
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-gray-700 dark:text-gray-100 leading-none pointer-events-none">
{formatPercent(props.usage)}
<Show when={props.cores}>
<span class="font-normal text-gray-500 dark:text-gray-300 ml-1">({props.cores} cores)</span>
<span class="hidden sm:inline font-normal text-gray-500 dark:text-gray-300 ml-1">({props.cores})</span>
</Show>
{/* Anomaly indicator */}
<Show when={props.anomaly && anomalyRatio()}>

View file

@ -1765,6 +1765,40 @@ const DockerContainerRow: Component<{
</div>
</Show>
<Show when={container.env && container.env.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
Environment
</div>
<div class="mt-1 flex flex-wrap gap-1 text-[11px] text-gray-600 dark:text-gray-300">
{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 (
<span
class={`max-w-full truncate rounded px-1.5 py-0.5 ${isMasked
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200'
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-200'
}`}
title={isMasked ? `${name} (masked for security)` : `${name}=${value}`}
>
{name}
<Show when={!isMasked && value}>
<span class="text-green-600 dark:text-green-300">={value}</span>
</Show>
<Show when={isMasked}>
<span class="text-amber-500 dark:text-amber-400 ml-0.5">🔒</span>
</Show>
</span>
);
})}
</div>
</div>
</Show>
{/* Annotations & Ask AI row */}
<Show when={aiEnabled()}>
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 w-full space-y-2">

View file

@ -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: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>, priority: 'secondary', width: '50px', toggleable: true },
{ id: 'uptime', label: 'Uptime', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>, 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: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>, 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: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>, priority: 'essential', width: '50px', toggleable: true },
{ id: 'uptime', label: 'Uptime', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>, priority: 'essential', width: '55px', toggleable: true, sortKey: 'uptime' },
{ id: 'agent', label: 'Agent', priority: 'essential', width: '55px', toggleable: true },
{ id: 'ip', label: 'IP', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>, 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 = () => {
<Card padding="none" tone="glass" class="overflow-hidden">
<div class="overflow-x-auto" style="scrollbar-width: none; -ms-overflow-style: none;">
<style>{`.overflow-x-auto::-webkit-scrollbar { display: none; }`}</style>
<table class="w-full border-collapse whitespace-nowrap" style={{ "min-width": "900px" }}>
<table class="w-full border-collapse whitespace-nowrap" style={{ "min-width": isMobile() ? "100%" : "900px" }}>
<thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
{/* Essential columns */}
@ -874,17 +869,17 @@ export const HostsOverview: Component = () => {
</th>
</Show>
<Show when={isColVisible('cpu')}>
<th class={thClass} onClick={() => handleSort('cpu')}>
<th class={thClass} style={{ "min-width": isMobile() ? "60px" : "140px" }} onClick={() => handleSort('cpu')}>
CPU {renderSortIndicator('cpu')}
</th>
</Show>
<Show when={isColVisible('memory')}>
<th class={thClass} onClick={() => handleSort('memory')}>
<th class={thClass} style={{ "min-width": isMobile() ? "60px" : "140px" }} onClick={() => handleSort('memory')}>
Memory {renderSortIndicator('memory')}
</th>
</Show>
<Show when={isColVisible('disk')}>
<th class={thClass} onClick={() => handleSort('disk')}>
<th class={thClass} style={{ "min-width": isMobile() ? "60px" : "140px" }} onClick={() => handleSort('disk')}>
Disk {renderSortIndicator('disk')}
</th>
</Show>
@ -1169,66 +1164,45 @@ const HostRow: Component<HostRowProps> = (props) => {
{/* CPU */}
<Show when={props.isColVisible('cpu')}>
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<td class="px-2 py-1 align-middle" style={{ "min-width": props.isMobile() ? "60px" : "140px", "width": props.isMobile() ? undefined : "140px", "max-width": props.isMobile() ? undefined : "140px" }}>
<Show when={isOnline()} fallback={<div class="flex justify-center"><span class="text-xs text-gray-400"></span></div>}>
<Show when={props.isMobile()}>
<div class="md:hidden flex justify-center">
<MetricText value={cpuPercent} type="cpu" />
</div>
</Show>
<div class="hidden md:block">
<EnhancedCPUBar
usage={cpuPercent}
loadAverage={host.loadAverage?.[0]}
cores={host.cpuCount}
/>
</div>
<EnhancedCPUBar
usage={cpuPercent}
loadAverage={host.loadAverage?.[0]}
cores={host.cpuCount}
/>
</Show>
</td>
</Show>
{/* Memory */}
<Show when={props.isColVisible('memory')}>
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<td class="px-2 py-1 align-middle" style={{ "min-width": props.isMobile() ? "60px" : "140px", "width": props.isMobile() ? undefined : "140px", "max-width": props.isMobile() ? undefined : "140px" }}>
<Show when={isOnline()} fallback={<div class="flex justify-center"><span class="text-xs text-gray-400"></span></div>}>
<Show when={props.isMobile()}>
<div class="md:hidden flex justify-center">
<MetricText value={memPercent} type="memory" />
</div>
</Show>
<div class="hidden md:block">
<StackedMemoryBar
used={host.memory?.used || 0}
total={host.memory?.total || 0}
balloon={host.memory?.balloon || 0}
swapUsed={host.memory?.swapUsed || 0}
swapTotal={host.memory?.swapTotal || 0}
/>
</div>
<StackedMemoryBar
used={host.memory?.used || 0}
total={host.memory?.total || 0}
balloon={host.memory?.balloon || 0}
swapUsed={host.memory?.swapUsed || 0}
swapTotal={host.memory?.swapTotal || 0}
/>
</Show>
</td>
</Show>
{/* Disk */}
<Show when={props.isColVisible('disk')}>
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<td class="px-2 py-1 align-middle" style={{ "min-width": props.isMobile() ? "60px" : "140px", "width": props.isMobile() ? undefined : "140px", "max-width": props.isMobile() ? undefined : "140px" }}>
<Show when={isOnline()} fallback={<div class="flex justify-center"><span class="text-xs text-gray-400"></span></div>}>
<Show when={props.isMobile()}>
<div class="md:hidden flex justify-center">
<MetricText value={diskStats.percent} type="disk" />
</div>
</Show>
<div class="hidden md:block">
<StackedDiskBar
disks={host.disks}
aggregateDisk={{
total: diskStats.total,
used: diskStats.used,
free: diskStats.total - diskStats.used,
usage: diskStats.percent / 100
}}
/>
</div>
<StackedDiskBar
disks={host.disks}
aggregateDisk={{
total: diskStats.total,
used: diskStats.used,
free: diskStats.total - diskStats.used,
usage: diskStats.percent / 100
}}
/>
</Show>
</td>
</Show>

View file

@ -362,6 +362,7 @@ export interface DockerContainer {
finishedAt?: number | null;
ports?: DockerContainerPort[];
labels?: Record<string, string>;
env?: string[];
networks?: DockerContainerNetwork[];
writableLayerBytes?: number;
rootFilesystemBytes?: number;

View file

@ -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 {

View file

@ -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)
}
}
})
}
}

View file

@ -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

View file

@ -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"`