mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
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:
parent
4f4fdcf81b
commit
86e41effc0
8 changed files with 204 additions and 71 deletions
|
|
@ -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()}>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -362,6 +362,7 @@ export interface DockerContainer {
|
|||
finishedAt?: number | null;
|
||||
ports?: DockerContainerPort[];
|
||||
labels?: Record<string, string>;
|
||||
env?: string[];
|
||||
networks?: DockerContainerNetwork[];
|
||||
writableLayerBytes?: number;
|
||||
rootFilesystemBytes?: number;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
76
internal/dockeragent/env_mask_test.go
Normal file
76
internal/dockeragent/env_mask_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
2
mock.env
2
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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue