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
This commit is contained in:
rcourtman
2026-02-03 19:28:02 +00:00
parent 1733bea15c
commit 2ebe65bbc5
9 changed files with 244 additions and 28 deletions

View File

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

View File

@@ -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<DiscoveryTabProps> = (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<DiscoveryTabProps> = (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<DiscoveryTabProps> = (props) => {
Run a discovery scan to identify services and configurations
</p>
</div>
{/* Connection Status Warning - Show when commands are needed but not available */}
<Show when={props.resourceType === 'host' && !connectedAgents.loading}>
<Show when={props.commandsEnabled === false}>
<div class="mb-4 mx-auto max-w-md rounded-lg border border-amber-200 bg-amber-50 p-3 text-left dark:border-amber-800/50 dark:bg-amber-900/20">
<div class="flex items-start gap-2">
<svg class="w-4 h-4 text-amber-500 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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div class="text-xs">
<p class="font-medium text-amber-800 dark:text-amber-200">Commands not enabled</p>
<p class="text-amber-700 dark:text-amber-300 mt-0.5">
Discovery requires command execution. Enable "Pulse Commands" in{' '}
<a href="/settings/agents" class="underline hover:no-underline">Settings → Unified Agents</a>.
</p>
</div>
</div>
</div>
</Show>
<Show when={props.commandsEnabled === true && !hasConnectedAgent()}>
<div class="mb-4 mx-auto max-w-md rounded-lg border border-amber-200 bg-amber-50 p-3 text-left dark:border-amber-800/50 dark:bg-amber-900/20">
<div class="flex items-start gap-2">
<svg class="w-4 h-4 text-amber-500 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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div class="text-xs">
<p class="font-medium text-amber-800 dark:text-amber-200">Agent not connected for commands</p>
<p class="text-amber-700 dark:text-amber-300 mt-0.5">
Commands are enabled, but the agent isn't connected via WebSocket. Check that the API token has the{' '}
<code class="px-1 py-0.5 bg-amber-100 dark:bg-amber-800/50 rounded">agent:exec</code>{' '}
scope in <a href="/settings/api" class="underline hover:no-underline">Settings API Tokens</a>.
</p>
</div>
</div>
</div>
</Show>
<Show when={props.commandsEnabled === true && hasConnectedAgent()}>
<div class="mb-4 mx-auto max-w-md rounded-lg border border-green-200 bg-green-50 p-3 text-left dark:border-green-800/50 dark:bg-green-900/20">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-green-500 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<p class="text-xs font-medium text-green-800 dark:text-green-200">
Agent connected and ready for command execution
</p>
</div>
</div>
</Show>
</Show>
<button
onClick={() => handleTriggerDiscovery(true)}
disabled={isScanning()}

View File

@@ -350,6 +350,7 @@ export const HostDrawer: Component<HostDrawerProps> = (props) => {
guestId={props.host.id}
customUrl={props.customUrl}
onCustomUrlChange={(url) => props.onCustomUrlChange?.(props.host.id, url)}
commandsEnabled={props.host.commandsEnabled}
/>
</Suspense>
</div>

View File

@@ -173,10 +173,29 @@ func (a *discoveryStateAdapter) GetState() servicediscovery.StateSnapshot {
}
}
// Convert Hosts
hosts := make([]servicediscovery.Host, len(state.Hosts))
for i, h := range state.Hosts {
hosts[i] = servicediscovery.Host{
ID: h.ID,
Hostname: h.Hostname,
DisplayName: h.DisplayName,
Platform: h.Platform,
OSName: h.OSName,
OSVersion: h.OSVersion,
KernelVersion: h.KernelVersion,
Architecture: h.Architecture,
CPUCount: h.CPUCount,
Status: h.Status,
Tags: h.Tags,
}
}
return servicediscovery.StateSnapshot{
VMs: vms,
Containers: containers,
DockerHosts: dockerHosts,
Hosts: hosts,
}
}

View File

@@ -515,11 +515,12 @@ func (r *Router) setupRoutes() {
}
})
// Config Profile Routes - Protected by Admin Auth and Pro License
// Config Profile Routes - Protected by Admin Auth, Settings Scope, and Pro License
// SECURITY: Require settings:write scope to prevent low-privilege tokens from modifying agent profiles
// r.configProfileHandler.ServeHTTP implements http.Handler, so we wrap it
r.mux.Handle("/api/admin/profiles/", RequireAdmin(r.config, RequireLicenseFeature(r.licenseHandlers, license.FeatureAgentProfiles, func(w http.ResponseWriter, req *http.Request) {
r.mux.Handle("/api/admin/profiles/", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, RequireLicenseFeature(r.licenseHandlers, license.FeatureAgentProfiles, func(w http.ResponseWriter, req *http.Request) {
http.StripPrefix("/api/admin/profiles", r.configProfileHandler).ServeHTTP(w, req)
})))
}))))
// System settings routes
r.mux.HandleFunc("/api/config/system", func(w http.ResponseWriter, req *http.Request) {
@@ -1421,19 +1422,22 @@ func (r *Router) setupRoutes() {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}))
r.mux.HandleFunc("/api/ai/patrol/history", RequireAuth(r.config, r.aiSettingsHandler.HandleGetFindingsHistory))
r.mux.HandleFunc("/api/ai/patrol/run", RequireAdmin(r.config, r.aiSettingsHandler.HandleForcePatrol))
r.mux.HandleFunc("/api/ai/patrol/acknowledge", RequireAuth(r.config, r.aiSettingsHandler.HandleAcknowledgeFinding))
// SECURITY: AI Patrol read endpoints - require ai:execute scope
r.mux.HandleFunc("/api/ai/patrol/history", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetFindingsHistory)))
r.mux.HandleFunc("/api/ai/patrol/run", RequireAdmin(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleForcePatrol)))
// SECURITY: AI Patrol mutation endpoints - require ai:execute scope to prevent low-privilege tokens from
// dismissing, suppressing, or otherwise hiding findings. This prevents attackers from blinding AI Patrol.
r.mux.HandleFunc("/api/ai/patrol/acknowledge", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleAcknowledgeFinding)))
// Dismiss and resolve don't require Pro license - users should be able to clear findings they can see
// This is especially important for users who accumulated findings before fixing the patrol-without-AI bug
r.mux.HandleFunc("/api/ai/patrol/dismiss", RequireAuth(r.config, r.aiSettingsHandler.HandleDismissFinding))
r.mux.HandleFunc("/api/ai/patrol/findings/note", RequireAuth(r.config, r.aiSettingsHandler.HandleSetFindingNote))
r.mux.HandleFunc("/api/ai/patrol/suppress", RequireAuth(r.config, r.aiSettingsHandler.HandleSuppressFinding))
r.mux.HandleFunc("/api/ai/patrol/snooze", RequireAuth(r.config, r.aiSettingsHandler.HandleSnoozeFinding))
r.mux.HandleFunc("/api/ai/patrol/resolve", RequireAuth(r.config, r.aiSettingsHandler.HandleResolveFinding))
r.mux.HandleFunc("/api/ai/patrol/runs", RequireAuth(r.config, r.aiSettingsHandler.HandleGetPatrolRunHistory))
// Suppression rules management - free with patrol
r.mux.HandleFunc("/api/ai/patrol/suppressions", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) {
r.mux.HandleFunc("/api/ai/patrol/dismiss", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleDismissFinding)))
r.mux.HandleFunc("/api/ai/patrol/findings/note", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleSetFindingNote)))
r.mux.HandleFunc("/api/ai/patrol/suppress", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleSuppressFinding)))
r.mux.HandleFunc("/api/ai/patrol/snooze", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleSnoozeFinding)))
r.mux.HandleFunc("/api/ai/patrol/resolve", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleResolveFinding)))
r.mux.HandleFunc("/api/ai/patrol/runs", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetPatrolRunHistory)))
// Suppression rules management - require scope to prevent low-privilege tokens from creating suppression rules
r.mux.HandleFunc("/api/ai/patrol/suppressions", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, func(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
r.aiSettingsHandler.HandleGetSuppressionRules(w, req)
@@ -1442,9 +1446,9 @@ func (r *Router) setupRoutes() {
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}))
r.mux.HandleFunc("/api/ai/patrol/suppressions/", RequireAuth(r.config, r.aiSettingsHandler.HandleDeleteSuppressionRule))
r.mux.HandleFunc("/api/ai/patrol/dismissed", RequireAuth(r.config, r.aiSettingsHandler.HandleGetDismissedFindings))
})))
r.mux.HandleFunc("/api/ai/patrol/suppressions/", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleDeleteSuppressionRule)))
r.mux.HandleFunc("/api/ai/patrol/dismissed", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetDismissedFindings)))
// Patrol Autonomy - monitor/approval free, assisted/full require Pro (enforced in handlers)
r.mux.HandleFunc("/api/ai/patrol/autonomy", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, func(w http.ResponseWriter, req *http.Request) {

View File

@@ -8965,6 +8965,7 @@ func (m *Monitor) pollStorageBackupsWithNodes(ctx context.Context, instanceName
contentSuccess := 0 // Number of successful storage content fetches
contentFailures := 0 // Number of failed storage content fetches
storageQueryErrors := 0 // Number of nodes where storage list could not be queried
hadPermissionError := false // Track if any permission errors occurred this cycle
storagePreserveNeeded := map[string]struct{}{}
storageSuccess := map[string]struct{}{}
@@ -9051,6 +9052,7 @@ func (m *Monitor) pollStorageBackupsWithNodes(ctx context.Context, instanceName
// Check if this is a permission error
if strings.Contains(errStr, "403") || strings.Contains(errStr, "401") ||
strings.Contains(errStr, "permission") || strings.Contains(errStr, "forbidden") {
hadPermissionError = true
m.mu.Lock()
m.backupPermissionWarnings[instanceName] = "Missing PVEDatastoreAdmin permission on /storage. Run: pveum aclmod /storage -user pulse-monitor@pam -role PVEDatastoreAdmin"
m.mu.Unlock()
@@ -9072,11 +9074,6 @@ func (m *Monitor) pollStorageBackupsWithNodes(ctx context.Context, instanceName
continue
}
// Clear any previous permission warning on success
m.mu.Lock()
delete(m.backupPermissionWarnings, instanceName)
m.mu.Unlock()
contentSuccess++
storageSuccess[storage.Storage] = struct{}{}
delete(storagePreserveNeeded, storage.Storage)
@@ -9212,6 +9209,13 @@ func (m *Monitor) pollStorageBackupsWithNodes(ctx context.Context, instanceName
m.alertManager.CheckBackups(pveStorage, pbsBackups, pmgBackups, guestsByKey, guestsByVMID)
}
// Clear permission warning if no permission errors occurred this cycle
if !hadPermissionError {
m.mu.Lock()
delete(m.backupPermissionWarnings, instanceName)
m.mu.Unlock()
}
log.Debug().
Str("instance", instanceName).
Int("count", len(allBackups)).

View File

@@ -370,6 +370,20 @@ func (s *DeepScanner) getTargetID(resourceType ResourceType, resourceID string)
func (s *DeepScanner) findAgentForHost(hostID, hostname string) string {
agents := s.executor.GetConnectedAgents()
log.Debug().
Str("hostID", hostID).
Str("hostname", hostname).
Int("connected_agents", len(agents)).
Msg("Finding agent for host")
// Log connected agents for debugging
for _, agent := range agents {
log.Debug().
Str("agent_id", agent.AgentID).
Str("agent_hostname", agent.Hostname).
Msg("Connected agent")
}
// First try exact match on agent ID
for _, agent := range agents {
if agent.AgentID == hostID {

View File

@@ -80,6 +80,22 @@ type StateSnapshot struct {
Containers []Container
DockerHosts []DockerHost
KubernetesClusters []KubernetesCluster
Hosts []Host
}
// Host represents a host system (via host-agent).
type Host struct {
ID string
Hostname string
DisplayName string
Platform string // e.g., "linux", "darwin", "windows"
OSName string // e.g., "Unraid", "Ubuntu", "Debian"
OSVersion string
KernelVersion string
Architecture string // e.g., "amd64", "arm64"
CPUCount int
Status string
Tags []string
}
// VM represents a virtual machine.
@@ -1125,11 +1141,15 @@ func (s *Service) DiscoverResource(ctx context.Context, req DiscoveryRequest) (*
// Run deep scan if scanner is available
var scanResult *ScanResult
var scanError error
if s.scanner != nil {
var err error
scanResult, err = s.scanner.Scan(ctx, req)
if err != nil {
log.Warn().Err(err).Str("id", resourceID).Msg("Deep scan failed, using metadata only")
scanResult, scanError = s.scanner.Scan(ctx, req)
if scanError != nil {
log.Warn().
Err(scanError).
Str("id", resourceID).
Str("resource_type", string(req.ResourceType)).
Msg("Deep scan failed, falling back to metadata-only analysis. For full discovery, ensure the host agent is connected with commands enabled.")
}
}
@@ -1222,6 +1242,19 @@ func (s *Service) DiscoverResource(ctx context.Context, req DiscoveryRequest) (*
Msg("Parsed Docker bind mounts from on-demand discovery")
}
}
} else if scanError != nil {
// Add note to reasoning when we couldn't run commands
metadataNote := "[Note: Discovery was limited to metadata-only analysis because command execution was unavailable. "
if strings.Contains(scanError.Error(), "no connected agent") {
metadataNote += "To enable full discovery with command execution, ensure the host agent has 'Pulse Commands' enabled in Settings → Unified Agents.]"
} else {
metadataNote += "Error: " + scanError.Error() + "]"
}
if discovery.AIReasoning != "" {
discovery.AIReasoning = metadataNote + " " + discovery.AIReasoning
} else {
discovery.AIReasoning = metadataNote
}
}
// Preserve user notes from existing discovery
@@ -1294,6 +1327,24 @@ func (s *Service) getResourceMetadata(req DiscoveryRequest) map[string]any {
break
}
}
case ResourceTypeHost:
for _, host := range state.Hosts {
if host.ID == req.ResourceID || host.Hostname == req.ResourceID || host.ID == req.HostID {
metadata["hostname"] = host.Hostname
metadata["display_name"] = host.DisplayName
metadata["platform"] = host.Platform
metadata["os_name"] = host.OSName
metadata["os_version"] = host.OSVersion
metadata["kernel_version"] = host.KernelVersion
metadata["architecture"] = host.Architecture
metadata["cpu_count"] = host.CPUCount
metadata["status"] = host.Status
if len(host.Tags) > 0 {
metadata["tags"] = host.Tags
}
break
}
}
}
return metadata

View File

@@ -15,4 +15,6 @@ const (
ResourceNodes = "nodes"
ResourceUsers = "users"
ResourceLicense = "license"
ResourceAI = "ai"
ResourceDiscovery = "discovery"
)