diff --git a/frontend-modern/src/api/discovery.ts b/frontend-modern/src/api/discovery.ts index 2301685b7..9b3f9f5f7 100644 --- a/frontend-modern/src/api/discovery.ts +++ b/frontend-modern/src/api/discovery.ts @@ -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 { return response.json(); } +/** + * Get discovery info for a resource type (AI provider info, commands that will run) + */ +export async function getDiscoveryInfo(resourceType: ResourceType): Promise { + 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 */ diff --git a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx index 0af9a0cdd..42ea45ba2 100644 --- a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx +++ b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx @@ -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 = (props) => { const [scanError, setScanError] = createSignal(null); const [scanProgress, setScanProgress] = createSignal(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 = (props) => { return (
+ {/* AI Provider Badge - Always visible when AI is configured */} + +
+ + {/* Cloud icon */} + + + + Analysis: {discoveryInfo()?.ai_provider?.label} + + } + > + + {/* Server/local icon */} + + + + Analysis: {discoveryInfo()?.ai_provider?.label} + + +
+
+ + {/* "What Discovery Does" Explanation - Shown when no discovery yet */} + +
+
+
+ + + +
+

What Discovery Does

+

+ 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. +

+
+
+ +
+
+
+ + {/* Commands Preview - Expandable before first scan */} + 0}> +
+ { e.preventDefault(); setShowCommandsPreview(!showCommandsPreview()); }} + > + + + + Commands that will run ({discoveryInfo()?.commands?.length || 0}) + + +
+ + {(cmd) => ( +
+
+ + {cmd.command} + +
+

{cmd.description}

+
+ )} +
+
+
+
+
+ {/* Loading state */}
@@ -562,6 +663,42 @@ export const DiscoveryTab: Component = (props) => { + {/* Scan Details / Raw Command Outputs (collapsible) */} + 0}> +
+ + Scan Details ({Object.keys(d().raw_command_output!).length} commands) + +
+ + {([cmdName, output]) => ( +
+
+ {cmdName} +
+
+                                                    {output || '(no output)'}
+                                                
+
+ )} +
+
+
+
+ + {/* Commands Run (for non-admin users who can't see full output) */} + 0}> +
+
+ Scan Info +
+

+ Scan completed in {(d().scan_duration! / 1000).toFixed(1)}s. + Full scan details are available to administrators. +

+
+
+ {/* Web Interface URL */}
diff --git a/frontend-modern/src/types/discovery.ts b/frontend-modern/src/types/discovery.ts index 78eb17d96..e9b5b79d2 100644 --- a/frontend-modern/src/types/discovery.ts +++ b/frontend-modern/src/types/discovery.ts @@ -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 +} diff --git a/internal/api/discovery_handlers.go b/internal/api/discovery_handlers.go index 9ddc9d945..39ecc3f74 100644 --- a/internal/api/discovery_handlers.go +++ b/internal/api/discovery_handlers.go @@ -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) +} diff --git a/internal/api/router.go b/internal/api/router.go index 2c2a25110..3ef4428c3 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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 } diff --git a/internal/servicediscovery/commands.go b/internal/servicediscovery/commands.go index 91cc84041..2fe8f18d3 100644 --- a/internal/servicediscovery/commands.go +++ b/internal/servicediscovery/commands.go @@ -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 +} diff --git a/internal/servicediscovery/service.go b/internal/servicediscovery/service.go index 08093a787..3dfb6bf70 100644 --- a/internal/servicediscovery/service.go +++ b/internal/servicediscovery/service.go @@ -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 { diff --git a/internal/servicediscovery/service_test.go b/internal/servicediscovery/service_test.go index b4fb0af1b..4e471dd9d 100644 --- a/internal/servicediscovery/service_test.go +++ b/internal/servicediscovery/service_test.go @@ -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 } diff --git a/internal/servicediscovery/types.go b/internal/servicediscovery/types.go index 7bc8b8362..64b8906fa 100644 --- a/internal/servicediscovery/types.go +++ b/internal/servicediscovery/types.go @@ -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"`