From 2ebe65bbc5ae820113718217eb3fe9155ba13da0 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 3 Feb 2026 19:28:02 +0000 Subject: [PATCH] security: add scope checks to AI Patrol and agent profile endpoints - AI Patrol mutation endpoints (acknowledge, dismiss, suppress, snooze, resolve, findings/note, suppressions/*) now require ai:execute scope to prevent low-privilege tokens from blinding patrol by hiding/suppressing findings - Agent profile admin endpoints (/api/admin/profiles/*) now require settings:write scope to prevent low-privilege tokens from modifying fleet-wide agent behavior --- frontend-modern/src/api/discovery.ts | 22 ++++ .../src/components/Discovery/DiscoveryTab.tsx | 103 +++++++++++++++++- .../src/components/Hosts/HostDrawer.tsx | 1 + internal/ai/discovery_adapter.go | 19 ++++ internal/api/router.go | 38 ++++--- internal/monitoring/monitor.go | 14 ++- internal/servicediscovery/deep_scanner.go | 14 +++ internal/servicediscovery/service.go | 59 +++++++++- pkg/auth/permissions.go | 2 + 9 files changed, 244 insertions(+), 28 deletions(-) diff --git a/frontend-modern/src/api/discovery.ts b/frontend-modern/src/api/discovery.ts index 9b3f9f5f7..dd86e9b9d 100644 --- a/frontend-modern/src/api/discovery.ts +++ b/frontend-modern/src/api/discovery.ts @@ -237,3 +237,25 @@ export function getConfidenceLevel(confidence: number): { } return { label: 'Low confidence', color: 'text-gray-500 dark:text-gray-400' }; } + +/** + * Connected agent info from WebSocket + */ +export interface ConnectedAgent { + agent_id: string; + hostname: string; + version: string; + platform: string; + connected_at: string; +} + +/** + * Get list of agents connected via WebSocket (for command execution) + */ +export async function getConnectedAgents(): Promise<{ count: number; agents: ConnectedAgent[] }> { + const response = await apiFetch('/api/ai/agents'); + if (!response.ok) { + throw new Error('Failed to get connected agents'); + } + return response.json(); +} diff --git a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx index eeb66b5d2..ac8c9f488 100644 --- a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx +++ b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx @@ -8,6 +8,7 @@ import { formatDiscoveryAge, getCategoryDisplayName, getConfidenceLevel, + getConnectedAgents, } from '../../api/discovery'; import { GuestMetadataAPI } from '../../api/guestMetadata'; import { eventBus } from '../../stores/events'; @@ -23,6 +24,8 @@ interface DiscoveryTabProps { customUrl?: string; /** Called after a URL is saved or deleted so the parent can update its state */ onCustomUrlChange?: (url: string) => void; + /** Whether commands are enabled for this host (from host agent config) */ + commandsEnabled?: boolean; } // Construct the resource ID in the same format the backend uses @@ -94,6 +97,43 @@ export const DiscoveryTab: Component = (props) => { } ); + // Fetch connected agents (for WebSocket command execution) + const [connectedAgents] = createResource( + async () => { + try { + return await getConnectedAgents(); + } catch { + return { count: 0, agents: [] }; + } + } + ); + + // Check if this host has a connected agent for command execution + // Matches backend logic in deep_scanner.go findAgentForHost() + const hasConnectedAgent = () => { + const agents = connectedAgents()?.agents || []; + + // First try exact match on agent ID + if (agents.some(agent => agent.agent_id === props.hostId)) { + return true; + } + + // Then try hostname match + if (agents.some(agent => + agent.hostname === props.hostname || + agent.hostname === props.hostId + )) { + return true; + } + + // If only one agent connected, backend will use it (fallback) + if (agents.length === 1) { + return true; + } + + return false; + }; + // Fetch discovery data const [discovery, { refetch, mutate }] = createResource( () => ({ type: props.resourceType, host: props.hostId, id: props.resourceId }), @@ -148,9 +188,18 @@ export const DiscoveryTab: Component = (props) => { console.error('Discovery failed:', err); // Extract error message for display const message = err instanceof Error ? err.message : 'Discovery scan failed'; - // Provide helpful message if it's a connection issue + // Provide helpful, specific message based on the error and current state if (message.includes('no connected agent')) { - setScanError('No agent available. Enable "Pulse Commands" in Settings → Unified Agents for this host.'); + // Check if commands are enabled to provide more specific guidance + if (props.commandsEnabled === false) { + setScanError('Commands not enabled. Enable "Pulse Commands" in Settings → Unified Agents for this host.'); + } else if (props.commandsEnabled === true) { + // Commands enabled but no WebSocket connection - likely token scope issue + setScanError('Agent not connected for command execution. The API token may be missing the "agent:exec" scope. Check Settings → API Tokens.'); + } else { + // Unknown state - provide general guidance + setScanError('No agent available for command execution. Ensure "Pulse Commands" is enabled in Settings → Unified Agents and the API token has "agent:exec" scope.'); + } } else { setScanError(message); } @@ -420,6 +469,56 @@ export const DiscoveryTab: Component = (props) => { Run a discovery scan to identify services and configurations

+ + {/* Connection Status Warning - Show when commands are needed but not available */} + + +
+
+ + + +
+

Commands not enabled

+

+ Discovery requires command execution. Enable "Pulse Commands" in{' '} + Settings → Unified Agents. +

+
+
+
+
+ +
+
+ + + +
+

Agent not connected for commands

+

+ Commands are enabled, but the agent isn't connected via WebSocket. Check that the API token has the{' '} + agent:exec{' '} + scope in Settings → API Tokens. +

+
+
+
+
+ +
+
+ + + +

+ Agent connected and ready for command execution +

+
+
+
+
+