mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 11:30:15 +00:00
The blocked patterns for 'reboot' and 'shutdown' were too broad, matching anywhere in the command string. This caused legitimate Proxmox VM control commands like 'qm reboot 201' to be blocked instead of requiring approval. Fix by anchoring these patterns to only match bare system commands (^reboot, ^shutdown, etc.) while allowing qm/pct variants through the RequireApproval path. Related to #1024
278 lines
6.8 KiB
Go
278 lines
6.8 KiB
Go
package agentexec
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// CommandPolicy defines what commands are allowed, blocked, or require approval
|
|
type CommandPolicy struct {
|
|
// AutoApprove patterns - commands matching these are automatically allowed
|
|
AutoApprove []string
|
|
|
|
// RequireApproval patterns - commands matching these need user approval
|
|
RequireApproval []string
|
|
|
|
// Blocked patterns - commands matching these are never allowed
|
|
Blocked []string
|
|
|
|
// compiled regex patterns
|
|
autoApproveRe []*regexp.Regexp
|
|
requireApprovalRe []*regexp.Regexp
|
|
blockedRe []*regexp.Regexp
|
|
}
|
|
|
|
// DefaultPolicy returns a sensible default command policy
|
|
func DefaultPolicy() *CommandPolicy {
|
|
p := &CommandPolicy{
|
|
AutoApprove: []string{
|
|
// System inspection
|
|
`^ps(\s|$)`,
|
|
`^top\s+-bn`,
|
|
`^df(\s|$)`,
|
|
`^free(\s|$)`,
|
|
`^uptime$`,
|
|
`^hostname$`,
|
|
`^uname(\s|$)`,
|
|
`^cat\s+/proc/`,
|
|
`^cat\s+/etc/os-release`,
|
|
`^lsof(\s|$)`,
|
|
`^netstat(\s|$)`,
|
|
`^ss(\s|$)`,
|
|
`^ip\s+(addr|route|link)`,
|
|
`^ifconfig`,
|
|
`^w$`,
|
|
`^who$`,
|
|
`^last(\s|$)`,
|
|
|
|
// Log reading (read-only)
|
|
`^cat\s+/var/log/`,
|
|
`^tail\s+.*(/var/log/|/log/)`,
|
|
`^head\s+.*(/var/log/|/log/)`,
|
|
`^grep\s+.*(/var/log/|/log/)`,
|
|
`^journalctl\s`,
|
|
|
|
// Service status (read-only)
|
|
`^systemctl\s+status\s`,
|
|
`^systemctl\s+is-active\s`,
|
|
`^systemctl\s+is-enabled\s`,
|
|
`^systemctl\s+list-units`,
|
|
`^service\s+\S+\s+status`,
|
|
|
|
// Docker inspection (read-only)
|
|
`^docker\s+ps`,
|
|
`^docker\s+logs\s`,
|
|
`^docker\s+inspect\s`,
|
|
`^docker\s+stats\s+--no-stream`,
|
|
`^docker\s+top\s`,
|
|
`^docker\s+images`,
|
|
`^docker\s+network\s+ls`,
|
|
`^docker\s+volume\s+ls`,
|
|
|
|
// Proxmox inspection (read-only)
|
|
`^pct\s+list`,
|
|
`^pct\s+config\s`,
|
|
`^pct\s+status\s`,
|
|
`^qm\s+list`,
|
|
`^qm\s+config\s`,
|
|
`^qm\s+status\s`,
|
|
`^pvesh\s+get\s`,
|
|
|
|
// Disk/storage info
|
|
`^lsblk`,
|
|
`^blkid`,
|
|
`^fdisk\s+-l`,
|
|
`^du(\s|$)`,
|
|
`^ls(\s|$)`,
|
|
`^stat(\s|$)`,
|
|
`^file(\s|$)`,
|
|
`^find\s+/.*-size`, // Find large files
|
|
`^find\s+/.*-mtime`, // Find by modification time
|
|
`^find\s+/.*-type`, // Find by type
|
|
|
|
// Memory inspection
|
|
`^vmstat`,
|
|
`^sar\s`,
|
|
`^iostat`,
|
|
`^mpstat`,
|
|
|
|
// Docker inspection (read-only)
|
|
`^docker\s+system\s+df`,
|
|
|
|
// APT inspection (read-only)
|
|
`^apt\s+list`,
|
|
`^apt-cache\s`,
|
|
`^dpkg\s+-l`,
|
|
`^dpkg\s+--list`,
|
|
},
|
|
|
|
RequireApproval: []string{
|
|
// Service control
|
|
`^systemctl\s+(restart|stop|start|reload)\s`,
|
|
`^service\s+\S+\s+(restart|stop|start|reload)`,
|
|
|
|
// Docker control
|
|
`^docker\s+(restart|stop|start|kill)\s`,
|
|
`^docker\s+exec\s`,
|
|
`^docker\s+rm\s`,
|
|
|
|
// Process control
|
|
`^kill\s`,
|
|
`^pkill\s`,
|
|
`^killall\s`,
|
|
|
|
// Package management
|
|
`^apt\s`,
|
|
`^apt-get\s`,
|
|
`^yum\s`,
|
|
`^dnf\s`,
|
|
`^pacman\s`,
|
|
|
|
// Proxmox control
|
|
`^pct\s+(start|stop|shutdown|reboot|resize|set)\s`,
|
|
`^qm\s+(start|stop|shutdown|reboot|reset|resize|set)\s`,
|
|
|
|
// journalctl maintenance (modifies logs / persistent state)
|
|
`^journalctl\s+--vacuum`,
|
|
`^journalctl\s+--rotate`,
|
|
|
|
// Temp file cleanup (safe with approval)
|
|
`^rm\s+(-rf?|-fr?)\s+/var/tmp/`,
|
|
`^rm\s+(-rf?|-fr?)\s+/tmp/`,
|
|
},
|
|
|
|
Blocked: []string{
|
|
// Destructive filesystem operations - block root and critical paths
|
|
// Note: /var/tmp and /tmp cleanup is allowed with approval (see RequireApproval)
|
|
`rm\s+-rf\s+/$`, // rm -rf / (root)
|
|
`rm\s+-rf\s+/\*`, // rm -rf /* (root wildcard)
|
|
`rm\s+-rf\s+/home($|\s|/)`, // rm -rf /home
|
|
`rm\s+-rf\s+/etc($|\s|/)`, // rm -rf /etc
|
|
`rm\s+-rf\s+/usr($|\s|/)`, // rm -rf /usr
|
|
`rm\s+-rf\s+/var/lib($|\s|/)`, // rm -rf /var/lib
|
|
`rm\s+-rf\s+/boot($|\s|/)`, // rm -rf /boot
|
|
`rm\s+-rf\s+/root($|\s|/)`, // rm -rf /root (root home)
|
|
`rm\s+-rf\s+/bin($|\s|/)`, // rm -rf /bin
|
|
`rm\s+-rf\s+/sbin($|\s|/)`, // rm -rf /sbin
|
|
`rm\s+-rf\s+/lib($|\s|/)`, // rm -rf /lib
|
|
`rm\s+-rf\s+/opt($|\s|/)`, // rm -rf /opt
|
|
`rm\s+--no-preserve-root`,
|
|
`mkfs`,
|
|
`dd\s+.*of=/dev/`,
|
|
`>\s*/dev/sd`,
|
|
`>\s*/dev/nvme`,
|
|
|
|
// System destruction (host-level commands only)
|
|
// Note: qm/pct reboot/shutdown are allowed with approval (see RequireApproval)
|
|
`^shutdown(\s|$)`,
|
|
`^reboot(\s|$)`,
|
|
`^init\s+0`,
|
|
`^poweroff(\s|$)`,
|
|
`^halt(\s|$)`,
|
|
|
|
// Dangerous permissions
|
|
`chmod\s+777`,
|
|
`chmod\s+-R\s+777`,
|
|
`chown\s+-R\s+.*:.*\s+/`,
|
|
|
|
// Remote code execution
|
|
`curl.*\|\s*(ba)?sh`,
|
|
`wget.*\|\s*(ba)?sh`,
|
|
`bash\s+-c\s+.*curl`,
|
|
`bash\s+-c\s+.*wget`,
|
|
|
|
// Crypto mining indicators
|
|
`xmrig`,
|
|
`minerd`,
|
|
`cpuminer`,
|
|
|
|
// Fork bomb patterns
|
|
`:\(\)\s*{\s*:\s*\|\s*:`,
|
|
|
|
// Clear system logs
|
|
`>\s*/var/log/`,
|
|
`truncate.*--size.*0.*/var/log/`,
|
|
},
|
|
}
|
|
|
|
p.compile()
|
|
return p
|
|
}
|
|
|
|
func (p *CommandPolicy) compile() {
|
|
p.autoApproveRe = compilePatterns(p.AutoApprove)
|
|
p.requireApprovalRe = compilePatterns(p.RequireApproval)
|
|
p.blockedRe = compilePatterns(p.Blocked)
|
|
}
|
|
|
|
func compilePatterns(patterns []string) []*regexp.Regexp {
|
|
result := make([]*regexp.Regexp, 0, len(patterns))
|
|
for _, pattern := range patterns {
|
|
re, err := regexp.Compile(pattern)
|
|
if err == nil {
|
|
result = append(result, re)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// PolicyDecision represents the policy decision for a command
|
|
type PolicyDecision string
|
|
|
|
const (
|
|
PolicyAllow PolicyDecision = "allow"
|
|
PolicyRequireApproval PolicyDecision = "require_approval"
|
|
PolicyBlock PolicyDecision = "block"
|
|
)
|
|
|
|
// Evaluate checks a command against the policy
|
|
func (p *CommandPolicy) Evaluate(command string) PolicyDecision {
|
|
command = strings.TrimSpace(command)
|
|
// Normalize simple sudo prefix so policy applies consistently.
|
|
// For complex sudo invocations (sudo flags), keep the command as-is (conservative).
|
|
if strings.HasPrefix(command, "sudo ") {
|
|
parts := strings.Fields(command)
|
|
if len(parts) >= 2 && !strings.HasPrefix(parts[1], "-") {
|
|
command = strings.Join(parts[1:], " ")
|
|
}
|
|
}
|
|
|
|
// Check blocked first (highest priority)
|
|
for _, re := range p.blockedRe {
|
|
if re.MatchString(command) {
|
|
return PolicyBlock
|
|
}
|
|
}
|
|
|
|
// Check require approval
|
|
for _, re := range p.requireApprovalRe {
|
|
if re.MatchString(command) {
|
|
return PolicyRequireApproval
|
|
}
|
|
}
|
|
|
|
// Check auto-approve
|
|
for _, re := range p.autoApproveRe {
|
|
if re.MatchString(command) {
|
|
return PolicyAllow
|
|
}
|
|
}
|
|
|
|
// Default: require approval for unknown commands
|
|
return PolicyRequireApproval
|
|
}
|
|
|
|
// IsBlocked returns true if a command is blocked
|
|
func (p *CommandPolicy) IsBlocked(command string) bool {
|
|
return p.Evaluate(command) == PolicyBlock
|
|
}
|
|
|
|
// NeedsApproval returns true if a command needs user approval
|
|
func (p *CommandPolicy) NeedsApproval(command string) bool {
|
|
return p.Evaluate(command) == PolicyRequireApproval
|
|
}
|
|
|
|
// IsAutoApproved returns true if a command can run without approval
|
|
func (p *CommandPolicy) IsAutoApproved(command string) bool {
|
|
return p.Evaluate(command) == PolicyAllow
|
|
}
|