mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
feat: add Discovery Transparency & Trust features
- Add AI provider indicator showing local (Ollama) vs cloud (Anthropic/OpenAI) analysis - Add "What Discovery Does" explanation section before first scan - Show commands preview before scan so users know what will run - Add scan details section showing raw command outputs for admins - Filter sensitive Docker labels (passwords, secrets, tokens) before AI analysis - Add comprehensive tests for label filtering This improves sysadmin confidence by making discovery transparent about what it does, what data it collects, and where that data goes.
This commit is contained in:
@@ -7,6 +7,7 @@ import type {
|
||||
DiscoveryStatus,
|
||||
TriggerDiscoveryRequest,
|
||||
UpdateNotesRequest,
|
||||
DiscoveryInfo,
|
||||
} from '../types/discovery';
|
||||
|
||||
const API_BASE = '/api/discovery';
|
||||
@@ -164,6 +165,17 @@ export async function getDiscoveryStatus(): Promise<DiscoveryStatus> {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get discovery info for a resource type (AI provider info, commands that will run)
|
||||
*/
|
||||
export async function getDiscoveryInfo(resourceType: ResourceType): Promise<DiscoveryInfo> {
|
||||
const response = await apiFetch(`${API_BASE}/info/${resourceType}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get discovery info');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to format the last updated time
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, Show, For, createSignal, createResource, onCleanup, createEf
|
||||
import type { ResourceType, DiscoveryProgress } from '../../types/discovery';
|
||||
import {
|
||||
getDiscovery,
|
||||
getDiscoveryInfo,
|
||||
triggerDiscovery,
|
||||
updateDiscoveryNotes,
|
||||
formatDiscoveryAge,
|
||||
@@ -74,6 +75,20 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
const [scanError, setScanError] = createSignal<string | null>(null);
|
||||
const [scanProgress, setScanProgress] = createSignal<DiscoveryProgress | null>(null);
|
||||
const [scanSuccess, setScanSuccess] = createSignal(false);
|
||||
const [showCommandsPreview, setShowCommandsPreview] = createSignal(false);
|
||||
const [showExplanation, setShowExplanation] = createSignal(true);
|
||||
|
||||
// Fetch discovery info (AI provider, commands) - used for pre-scan transparency
|
||||
const [discoveryInfo] = createResource(
|
||||
() => props.resourceType,
|
||||
async (type) => {
|
||||
try {
|
||||
return await getDiscoveryInfo(type);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Fetch discovery data
|
||||
const [discovery, { refetch, mutate }] = createResource(
|
||||
@@ -209,6 +224,92 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
{/* AI Provider Badge - Always visible when AI is configured */}
|
||||
<Show when={discoveryInfo()?.ai_provider}>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show
|
||||
when={discoveryInfo()?.ai_provider?.is_local}
|
||||
fallback={
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
|
||||
{/* Cloud icon */}
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
|
||||
</svg>
|
||||
Analysis: {discoveryInfo()?.ai_provider?.label}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
|
||||
{/* Server/local icon */}
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
Analysis: {discoveryInfo()?.ai_provider?.label}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* "What Discovery Does" Explanation - Shown when no discovery yet */}
|
||||
<Show when={!discovery() && !discovery.loading && showExplanation()}>
|
||||
<div class="rounded border border-amber-200 bg-amber-50/80 p-3 shadow-sm dark:border-amber-800/50 dark:bg-amber-900/20">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-start gap-2.5">
|
||||
<svg class="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="text-xs text-amber-800 dark:text-amber-200">
|
||||
<p class="font-medium mb-1">What Discovery Does</p>
|
||||
<p class="text-amber-700 dark:text-amber-300">
|
||||
Discovery runs read-only commands to gather system information (processes, ports, services),
|
||||
then uses AI to analyze and identify what's running. No data is stored externally - only the analysis result is saved locally.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowExplanation(false)}
|
||||
class="text-amber-500 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 flex-shrink-0"
|
||||
title="Dismiss"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Commands Preview - Expandable before first scan */}
|
||||
<Show when={!discovery() && !discovery.loading && discoveryInfo()?.commands && discoveryInfo()!.commands!.length > 0}>
|
||||
<details class="rounded border border-gray-200 bg-white/70 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30" open={showCommandsPreview()}>
|
||||
<summary
|
||||
class="p-2.5 text-xs font-medium text-gray-700 dark:text-gray-300 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 flex items-center gap-2"
|
||||
onClick={(e) => { e.preventDefault(); setShowCommandsPreview(!showCommandsPreview()); }}
|
||||
>
|
||||
<svg class={`w-3.5 h-3.5 transition-transform ${showCommandsPreview() ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
Commands that will run ({discoveryInfo()?.commands?.length || 0})
|
||||
</summary>
|
||||
<Show when={showCommandsPreview()}>
|
||||
<div class="px-3 pb-3 space-y-2">
|
||||
<For each={discoveryInfo()?.commands}>
|
||||
{(cmd) => (
|
||||
<div class="text-xs">
|
||||
<div class="flex items-start gap-2">
|
||||
<code class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-mono break-all">
|
||||
{cmd.command}
|
||||
</code>
|
||||
</div>
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-0.5 pl-0.5">{cmd.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</details>
|
||||
</Show>
|
||||
|
||||
{/* Loading state */}
|
||||
<Show when={discovery.loading}>
|
||||
<div class="flex items-center justify-center py-8">
|
||||
@@ -562,6 +663,42 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
</details>
|
||||
</Show>
|
||||
|
||||
{/* Scan Details / Raw Command Outputs (collapsible) */}
|
||||
<Show when={d().raw_command_output && Object.keys(d().raw_command_output!).length > 0}>
|
||||
<details class="rounded border border-gray-200 bg-white/70 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||
<summary class="p-3 text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
Scan Details ({Object.keys(d().raw_command_output!).length} commands)
|
||||
</summary>
|
||||
<div class="px-3 pb-3 space-y-3">
|
||||
<For each={Object.entries(d().raw_command_output!)}>
|
||||
{([cmdName, output]) => (
|
||||
<div>
|
||||
<div class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{cmdName}
|
||||
</div>
|
||||
<pre class="text-[10px] bg-gray-100 dark:bg-gray-800 rounded p-2 overflow-x-auto text-gray-600 dark:text-gray-400 max-h-32 overflow-y-auto">
|
||||
{output || '(no output)'}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</details>
|
||||
</Show>
|
||||
|
||||
{/* Commands Run (for non-admin users who can't see full output) */}
|
||||
<Show when={!d().raw_command_output && d().scan_duration && d().scan_duration > 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 mb-1">
|
||||
Scan Info
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Scan completed in {(d().scan_duration! / 1000).toFixed(1)}s.
|
||||
Full scan details are available to administrators.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Web Interface URL */}
|
||||
<Show when={props.guestId}>
|
||||
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||
|
||||
@@ -141,3 +141,28 @@ export interface UpdateNotesRequest {
|
||||
export interface UpdateSettingsRequest {
|
||||
max_discovery_age_days?: number; // Days before rediscovery (default 30)
|
||||
}
|
||||
|
||||
// AI provider information for discovery transparency
|
||||
export interface AIProviderInfo {
|
||||
provider: string; // e.g., "anthropic", "openai", "ollama"
|
||||
model: string; // e.g., "claude-haiku-4-5", "gpt-4o"
|
||||
is_local: boolean; // true for ollama (local models)
|
||||
label: string; // Human-readable label, e.g., "Local (Ollama)" or "Cloud (Anthropic)"
|
||||
}
|
||||
|
||||
// Discovery command information
|
||||
export interface DiscoveryCommand {
|
||||
name: string; // Human-readable name
|
||||
command: string; // The actual command
|
||||
description: string; // What this command discovers
|
||||
categories: string[]; // Categories this provides info for
|
||||
timeout?: number; // Timeout in seconds
|
||||
optional?: boolean; // If true, failure won't stop discovery
|
||||
}
|
||||
|
||||
// Discovery info metadata (AI provider, commands that will run)
|
||||
export interface DiscoveryInfo {
|
||||
ai_provider?: AIProviderInfo; // Current AI provider info
|
||||
commands?: DiscoveryCommand[]; // Commands that will be run
|
||||
command_categories?: string[]; // Unique categories of commands
|
||||
}
|
||||
|
||||
@@ -11,12 +11,19 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// AIConfigProvider provides access to the current AI configuration.
|
||||
// This allows discovery handlers to show AI provider info without tight coupling.
|
||||
type AIConfigProvider interface {
|
||||
GetAIConfig() *config.AIConfig
|
||||
}
|
||||
|
||||
// Note: adminBypassEnabled() is defined in auth.go
|
||||
|
||||
// DiscoveryHandlers handles AI-powered infrastructure discovery endpoints.
|
||||
type DiscoveryHandlers struct {
|
||||
service *servicediscovery.Service
|
||||
config *config.Config // For admin status checks
|
||||
service *servicediscovery.Service
|
||||
config *config.Config // For admin status checks
|
||||
aiConfigProvider AIConfigProvider
|
||||
}
|
||||
|
||||
// NewDiscoveryHandlers creates new discovery handlers.
|
||||
@@ -32,6 +39,59 @@ func (h *DiscoveryHandlers) SetService(service *servicediscovery.Service) {
|
||||
h.service = service
|
||||
}
|
||||
|
||||
// SetAIConfigProvider sets the AI config provider for showing AI provider info.
|
||||
func (h *DiscoveryHandlers) SetAIConfigProvider(provider AIConfigProvider) {
|
||||
h.aiConfigProvider = provider
|
||||
}
|
||||
|
||||
// getAIProviderInfo returns info about the current AI provider for discovery.
|
||||
func (h *DiscoveryHandlers) getAIProviderInfo() *servicediscovery.AIProviderInfo {
|
||||
if h.aiConfigProvider == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
aiCfg := h.aiConfigProvider.GetAIConfig()
|
||||
if aiCfg == nil || !aiCfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get the discovery model
|
||||
model := aiCfg.GetDiscoveryModel()
|
||||
if model == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the model to get provider
|
||||
provider, modelName := config.ParseModelString(model)
|
||||
|
||||
// Determine if local
|
||||
isLocal := provider == config.AIProviderOllama
|
||||
|
||||
// Build human-readable label
|
||||
var label string
|
||||
switch provider {
|
||||
case config.AIProviderOllama:
|
||||
label = "Local (Ollama)"
|
||||
case config.AIProviderAnthropic:
|
||||
label = "Cloud (Anthropic)"
|
||||
case config.AIProviderOpenAI:
|
||||
label = "Cloud (OpenAI)"
|
||||
case config.AIProviderDeepSeek:
|
||||
label = "Cloud (DeepSeek)"
|
||||
case config.AIProviderGemini:
|
||||
label = "Cloud (Google Gemini)"
|
||||
default:
|
||||
label = "Cloud (" + provider + ")"
|
||||
}
|
||||
|
||||
return &servicediscovery.AIProviderInfo{
|
||||
Provider: provider,
|
||||
Model: modelName,
|
||||
IsLocal: isLocal,
|
||||
Label: label,
|
||||
}
|
||||
}
|
||||
|
||||
// writeDiscoveryJSON writes a JSON response.
|
||||
func writeDiscoveryJSON(w http.ResponseWriter, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -492,3 +552,26 @@ func (h *DiscoveryHandlers) HandleListByHost(w http.ResponseWriter, r *http.Requ
|
||||
"host": hostID,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleGetInfo handles GET /api/discovery/info/{type}
|
||||
// Returns metadata about the discovery process: AI provider info and commands that will run.
|
||||
func (h *DiscoveryHandlers) HandleGetInfo(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse resource type from path
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/discovery/info/")
|
||||
resourceType := servicediscovery.ResourceType(path)
|
||||
|
||||
// Get commands for this resource type
|
||||
commands := servicediscovery.GetCommandsForResource(resourceType)
|
||||
categories := servicediscovery.GetCommandCategories(resourceType)
|
||||
|
||||
// Get AI provider info
|
||||
aiProvider := h.getAIProviderInfo()
|
||||
|
||||
info := servicediscovery.DiscoveryInfo{
|
||||
AIProvider: aiProvider,
|
||||
Commands: commands,
|
||||
CommandCategories: categories,
|
||||
}
|
||||
|
||||
writeDiscoveryJSON(w, info)
|
||||
}
|
||||
|
||||
@@ -1505,6 +1505,7 @@ func (r *Router) setupRoutes() {
|
||||
r.mux.HandleFunc("/api/discovery", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.discoveryHandlers.HandleListDiscoveries)))
|
||||
r.mux.HandleFunc("/api/discovery/status", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.discoveryHandlers.HandleGetStatus)))
|
||||
r.mux.HandleFunc("/api/discovery/settings", RequireAuth(r.config, RequireScope(config.ScopeSettingsWrite, r.discoveryHandlers.HandleUpdateSettings)))
|
||||
r.mux.HandleFunc("/api/discovery/info/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.discoveryHandlers.HandleGetInfo)))
|
||||
r.mux.HandleFunc("/api/discovery/type/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.discoveryHandlers.HandleListByType)))
|
||||
r.mux.HandleFunc("/api/discovery/host/", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) {
|
||||
// Route based on method and path depth:
|
||||
@@ -1988,6 +1989,13 @@ func (r *Router) SetDiscoveryService(svc *servicediscovery.Service) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetDiscoveryAIConfigProvider sets the AI config provider for showing AI provider info in discovery.
|
||||
func (r *Router) SetDiscoveryAIConfigProvider(provider AIConfigProvider) {
|
||||
if r.discoveryHandlers != nil {
|
||||
r.discoveryHandlers.SetAIConfigProvider(provider)
|
||||
}
|
||||
}
|
||||
|
||||
// wsHubAdapter adapts websocket.Hub to the servicediscovery.WSBroadcaster interface.
|
||||
type wsHubAdapter struct {
|
||||
hub *websocket.Hub
|
||||
@@ -2253,6 +2261,8 @@ func (r *Router) StartPatrol(ctx context.Context) {
|
||||
r.SetDiscoveryService(discoveryService)
|
||||
log.Info().Msg("Discovery: Service wired to API handlers")
|
||||
}
|
||||
// Wire up AI config provider for showing AI provider info in discovery UI
|
||||
r.SetDiscoveryAIConfigProvider(aiService)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2602,7 +2612,9 @@ func (r *Router) wireChatServiceToAI() {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
// Use default org context for legacy service wiring
|
||||
// Multi-tenant orgs get their services wired via setupInvestigationOrchestrator
|
||||
ctx := context.WithValue(context.Background(), OrgIDContextKey, "default")
|
||||
chatSvc := r.aiHandler.GetService(ctx)
|
||||
if chatSvc == nil {
|
||||
return
|
||||
@@ -2635,7 +2647,8 @@ func (r *Router) wireAIChatProviders() {
|
||||
return
|
||||
}
|
||||
|
||||
service := r.aiHandler.GetService(context.Background())
|
||||
// Use default org context for legacy service wiring
|
||||
service := r.aiHandler.GetService(context.WithValue(context.Background(), OrgIDContextKey, "default"))
|
||||
if service == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -35,12 +35,12 @@ func shellQuote(s string) string {
|
||||
|
||||
// DiscoveryCommand represents a command to run during discovery.
|
||||
type DiscoveryCommand struct {
|
||||
Name string // Human-readable name
|
||||
Command string // The command template
|
||||
Description string // What this discovers
|
||||
Categories []string // What categories of info this provides
|
||||
Timeout int // Timeout in seconds (0 = default)
|
||||
Optional bool // If true, don't fail if command fails
|
||||
Name string `json:"name"` // Human-readable name
|
||||
Command string `json:"command"` // The command template
|
||||
Description string `json:"description"` // What this discovers
|
||||
Categories []string `json:"categories"` // What categories of info this provides
|
||||
Timeout int `json:"timeout"` // Timeout in seconds (0 = default)
|
||||
Optional bool `json:"optional"` // If true, don't fail if command fails
|
||||
}
|
||||
|
||||
// CommandSet represents a set of commands for a resource type.
|
||||
@@ -524,3 +524,40 @@ func FormatCLIAccess(resourceType ResourceType, vmid, containerName, namespace,
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetCommandCategories returns a unique sorted list of all categories for a resource type.
|
||||
func GetCommandCategories(resourceType ResourceType) []string {
|
||||
commands := GetCommandsForResource(resourceType)
|
||||
categorySet := make(map[string]bool)
|
||||
for _, cmd := range commands {
|
||||
for _, cat := range cmd.Categories {
|
||||
categorySet[cat] = true
|
||||
}
|
||||
}
|
||||
|
||||
categories := make([]string, 0, len(categorySet))
|
||||
for cat := range categorySet {
|
||||
categories = append(categories, cat)
|
||||
}
|
||||
|
||||
// Sort for consistent ordering
|
||||
for i := 0; i < len(categories)-1; i++ {
|
||||
for j := i + 1; j < len(categories); j++ {
|
||||
if categories[i] > categories[j] {
|
||||
categories[i], categories[j] = categories[j], categories[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return categories
|
||||
}
|
||||
|
||||
// GetCommandSummary returns a human-readable list of commands for a resource type.
|
||||
func GetCommandSummary(resourceType ResourceType) []string {
|
||||
commands := GetCommandsForResource(resourceType)
|
||||
summaries := make([]string, 0, len(commands))
|
||||
for _, cmd := range commands {
|
||||
summaries = append(summaries, cmd.Command)
|
||||
}
|
||||
return summaries
|
||||
}
|
||||
|
||||
@@ -15,6 +15,59 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// sensitiveKeyPatterns defines patterns that indicate a label/env key might contain secrets.
|
||||
// These patterns are case-insensitive and match if any part of the key contains them.
|
||||
var sensitiveKeyPatterns = []string{
|
||||
"password", "passwd", "pwd",
|
||||
"secret",
|
||||
"key", "apikey", "api_key",
|
||||
"token",
|
||||
"credential", "cred",
|
||||
"auth",
|
||||
"private",
|
||||
"cert",
|
||||
}
|
||||
|
||||
// filterSensitiveLabels removes or redacts labels that may contain sensitive values.
|
||||
// It returns a new map with sensitive values replaced with "[REDACTED]".
|
||||
// Keys are checked case-insensitively for sensitive patterns.
|
||||
func filterSensitiveLabels(labels map[string]string) map[string]string {
|
||||
if labels == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
filtered := make(map[string]string, len(labels))
|
||||
redactedCount := 0
|
||||
|
||||
for key, value := range labels {
|
||||
keyLower := strings.ToLower(key)
|
||||
isSensitive := false
|
||||
|
||||
for _, pattern := range sensitiveKeyPatterns {
|
||||
if strings.Contains(keyLower, pattern) {
|
||||
isSensitive = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isSensitive {
|
||||
filtered[key] = "[REDACTED]"
|
||||
redactedCount++
|
||||
} else {
|
||||
filtered[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
if redactedCount > 0 {
|
||||
log.Debug().
|
||||
Int("redacted_count", redactedCount).
|
||||
Int("total_labels", len(labels)).
|
||||
Msg("Redacted sensitive labels before AI analysis")
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// StateProvider provides access to the current infrastructure state.
|
||||
type StateProvider interface {
|
||||
GetState() StateSnapshot
|
||||
@@ -1223,7 +1276,8 @@ func (s *Service) getResourceMetadata(req DiscoveryRequest) map[string]any {
|
||||
if c.Name == req.ResourceID {
|
||||
metadata["image"] = c.Image
|
||||
metadata["status"] = c.Status
|
||||
metadata["labels"] = c.Labels
|
||||
// Filter sensitive labels before sending to AI
|
||||
metadata["labels"] = filterSensitiveLabels(c.Labels)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -1272,7 +1326,8 @@ func (s *Service) buildMetadataAnalysisPrompt(c DockerContainer, host DockerHost
|
||||
}
|
||||
|
||||
if len(c.Labels) > 0 {
|
||||
info["labels"] = c.Labels
|
||||
// Filter sensitive labels before sending to AI
|
||||
info["labels"] = filterSensitiveLabels(c.Labels)
|
||||
}
|
||||
|
||||
if len(c.Mounts) > 0 {
|
||||
|
||||
@@ -29,6 +29,167 @@ func (errorAnalyzer) AnalyzeForDiscovery(ctx context.Context, prompt string) (st
|
||||
return "", context.Canceled
|
||||
}
|
||||
|
||||
func TestFilterSensitiveLabels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
labels map[string]string
|
||||
wantKeys map[string]string // expected values (use "[REDACTED]" for sensitive ones)
|
||||
}{
|
||||
{
|
||||
name: "nil labels",
|
||||
labels: nil,
|
||||
wantKeys: nil,
|
||||
},
|
||||
{
|
||||
name: "empty labels",
|
||||
labels: map[string]string{},
|
||||
wantKeys: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "safe labels only",
|
||||
labels: map[string]string{
|
||||
"app": "myapp",
|
||||
"version": "1.0.0",
|
||||
"env": "production",
|
||||
},
|
||||
wantKeys: map[string]string{
|
||||
"app": "myapp",
|
||||
"version": "1.0.0",
|
||||
"env": "production",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "redacts PASSWORD labels",
|
||||
labels: map[string]string{
|
||||
"app": "myapp",
|
||||
"DB_PASSWORD": "super-secret",
|
||||
"mysql_password": "another-secret",
|
||||
"PASSWORD_FILE": "/secrets/pass",
|
||||
},
|
||||
wantKeys: map[string]string{
|
||||
"app": "myapp",
|
||||
"DB_PASSWORD": "[REDACTED]",
|
||||
"mysql_password": "[REDACTED]",
|
||||
"PASSWORD_FILE": "[REDACTED]",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "redacts SECRET labels",
|
||||
labels: map[string]string{
|
||||
"app": "myapp",
|
||||
"AWS_SECRET_KEY": "secret123",
|
||||
"client_secret": "xyz",
|
||||
},
|
||||
wantKeys: map[string]string{
|
||||
"app": "myapp",
|
||||
"AWS_SECRET_KEY": "[REDACTED]",
|
||||
"client_secret": "[REDACTED]",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "redacts TOKEN labels",
|
||||
labels: map[string]string{
|
||||
"app": "myapp",
|
||||
"ACCESS_TOKEN": "tok_123",
|
||||
"oauth_token": "tok_456",
|
||||
},
|
||||
wantKeys: map[string]string{
|
||||
"app": "myapp",
|
||||
"ACCESS_TOKEN": "[REDACTED]",
|
||||
"oauth_token": "[REDACTED]",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "redacts API KEY labels",
|
||||
labels: map[string]string{
|
||||
"app": "myapp",
|
||||
"API_KEY": "key123",
|
||||
"openai_apikey": "sk-123",
|
||||
"stripe_api_key": "sk_live_123",
|
||||
},
|
||||
wantKeys: map[string]string{
|
||||
"app": "myapp",
|
||||
"API_KEY": "[REDACTED]",
|
||||
"openai_apikey": "[REDACTED]",
|
||||
"stripe_api_key": "[REDACTED]",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "redacts CREDENTIAL labels",
|
||||
labels: map[string]string{
|
||||
"app": "myapp",
|
||||
"DB_CREDENTIALS": "user:pass",
|
||||
"admin_cred": "admin123",
|
||||
},
|
||||
wantKeys: map[string]string{
|
||||
"app": "myapp",
|
||||
"DB_CREDENTIALS": "[REDACTED]",
|
||||
"admin_cred": "[REDACTED]",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "redacts AUTH labels",
|
||||
labels: map[string]string{
|
||||
"app": "myapp",
|
||||
"auth_code": "abc123",
|
||||
"BASIC_AUTH": "dXNlcjpwYXNz",
|
||||
},
|
||||
wantKeys: map[string]string{
|
||||
"app": "myapp",
|
||||
"auth_code": "[REDACTED]",
|
||||
"BASIC_AUTH": "[REDACTED]",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed sensitive and safe labels",
|
||||
labels: map[string]string{
|
||||
"app": "myapp",
|
||||
"version": "2.0",
|
||||
"maintainer": "team@example.com",
|
||||
"DB_PASSWORD": "secret",
|
||||
"API_KEY": "key123",
|
||||
"prometheus_port": "9090",
|
||||
},
|
||||
wantKeys: map[string]string{
|
||||
"app": "myapp",
|
||||
"version": "2.0",
|
||||
"maintainer": "team@example.com",
|
||||
"DB_PASSWORD": "[REDACTED]",
|
||||
"API_KEY": "[REDACTED]",
|
||||
"prometheus_port": "9090",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := filterSensitiveLabels(tt.labels)
|
||||
|
||||
if tt.wantKeys == nil {
|
||||
if got != nil {
|
||||
t.Errorf("filterSensitiveLabels() = %v, want nil", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(got) != len(tt.wantKeys) {
|
||||
t.Errorf("filterSensitiveLabels() returned %d labels, want %d", len(got), len(tt.wantKeys))
|
||||
}
|
||||
|
||||
for k, wantV := range tt.wantKeys {
|
||||
gotV, ok := got[k]
|
||||
if !ok {
|
||||
t.Errorf("filterSensitiveLabels() missing key %q", k)
|
||||
continue
|
||||
}
|
||||
if gotV != wantV {
|
||||
t.Errorf("filterSensitiveLabels()[%q] = %q, want %q", k, gotV, wantV)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type stubStateProvider struct {
|
||||
state StateSnapshot
|
||||
}
|
||||
|
||||
@@ -191,6 +191,21 @@ type DiscoveryProgress struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// AIProviderInfo describes the AI provider being used for discovery analysis.
|
||||
type AIProviderInfo struct {
|
||||
Provider string `json:"provider"` // e.g., "anthropic", "openai", "ollama"
|
||||
Model string `json:"model"` // e.g., "claude-haiku-4-5", "gpt-4o"
|
||||
IsLocal bool `json:"is_local"` // true for ollama (local models)
|
||||
Label string `json:"label"` // Human-readable label, e.g., "Local (Ollama)" or "Cloud (Anthropic)"
|
||||
}
|
||||
|
||||
// DiscoveryInfo provides metadata about the discovery system configuration.
|
||||
type DiscoveryInfo struct {
|
||||
AIProvider *AIProviderInfo `json:"ai_provider,omitempty"` // Current AI provider info
|
||||
Commands []DiscoveryCommand `json:"commands,omitempty"` // Commands that will be run
|
||||
CommandCategories []string `json:"command_categories,omitempty"` // Unique categories of commands
|
||||
}
|
||||
|
||||
// UpdateNotesRequest represents a request to update user notes.
|
||||
type UpdateNotesRequest struct {
|
||||
UserNotes string `json:"user_notes"`
|
||||
|
||||
Reference in New Issue
Block a user