homelab_automation/app/security/command_policy.py
Bruno Charest 5bc12d0729
Some checks failed
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
Add terminal session management with heartbeat monitoring, idle timeout detection, session reuse logic, and command history panel UI with search and filtering capabilities
2025-12-18 13:49:40 -05:00

512 lines
16 KiB
Python

"""
Command Policy Engine for Terminal Command Logging.
This module enforces strict security policies for terminal commands:
- Blocklist: Commands that are NEVER logged (sensitive operations)
- Allowlist: Only commands matching these patterns are logged
- Masking: Sensitive values are redacted before logging
SECURITY PRINCIPLE: When in doubt, refuse to log rather than risk exposing secrets.
"""
import hashlib
import logging
import os
import re
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
import yaml
logger = logging.getLogger(__name__)
class PolicyDecision(Enum):
"""Result of policy evaluation."""
ALLOW = "allow" # Command is safe to log
BLOCK = "block" # Command contains sensitive data - do not log
UNKNOWN = "unknown" # Command not in allowlist - do not log by default
@dataclass
class CommandPolicyResult:
"""Result of command policy evaluation."""
decision: PolicyDecision
original_command: str
masked_command: Optional[str] = None
command_hash: Optional[str] = None
reason: Optional[str] = None
matched_rule: Optional[str] = None
@property
def should_log(self) -> bool:
"""Whether this command should be logged to the database."""
return self.decision == PolicyDecision.ALLOW
@property
def is_blocked(self) -> bool:
"""Whether this command was explicitly blocked."""
return self.decision == PolicyDecision.BLOCK
# ============================================================================
# DEFAULT BLOCKLIST - Commands that are NEVER logged
# ============================================================================
DEFAULT_BLOCKLIST_PATTERNS: List[str] = [
# Password/secret keywords in command
r'\bpassword\b',
r'\bpasswd\b',
r'\btoken\b',
r'\bapikey\b',
r'\bapi_key\b',
r'\bsecret\b',
r'\bsshpass\b',
r'\bcredential\b',
# Docker login (may contain credentials)
r'\bdocker\s+login\b',
# OpenSSL with password
r'\bopenssl\b.*(-pass\b|pass:)',
# curl/wget with auth headers
r'\b(curl|wget)\b.*\bAuthorization:\s*',
r'\b(curl|wget)\b.*\bBearer\s+',
r'\b(curl|wget)\b.*(-u\s+|--user\s+)',
r'\b(curl|wget)\b.*(-H\s+["\']?Authorization)',
# Export sensitive env vars
r'\bexport\s+\w*(TOKEN|SECRET|KEY|PASS|PASSWORD|CREDENTIAL)\w*\s*=',
# Reading SSH keys or shadow file
r'\bcat\s+.*~?/?\.ssh/',
r'\bcat\s+.*/etc/shadow',
r'\bcat\s+.*id_rsa',
r'\bcat\s+.*id_ed25519',
r'\bcat\s+.*id_ecdsa',
r'\bcat\s+.*authorized_keys',
r'\bcat\s+.*known_hosts',
# Less/more/view on sensitive files
r'\b(less|more|view|head|tail)\s+.*~?/?\.ssh/',
r'\b(less|more|view|head|tail)\s+.*/etc/shadow',
# Editing sensitive files
r'\b(vi|vim|nano|emacs|edit)\s+.*~?/?\.ssh/',
r'\b(vi|vim|nano|emacs|edit)\s+.*/etc/shadow',
# MySQL/PostgreSQL with password on command line
r'\bmysql\b.*-p\S+',
r'\bpsql\b.*:.*@',
# AWS/GCP/Azure CLI with credentials
r'\baws\s+configure\b',
r'\bgcloud\s+auth\b',
r'\baz\s+login\b',
# Kubernetes secrets
r'\bkubectl\s+(get|describe|edit)\s+secret',
# Ansible vault
r'\bansible-vault\s+(encrypt|decrypt|edit)',
# Gpg operations with passphrases
r'\bgpg\b.*--passphrase',
# Sudo with password echo
r'echo\s+.*\|\s*sudo\b',
# History manipulation (might contain secrets)
r'\bhistory\s*$',
r'\bcat\s+.*\.bash_history',
r'\bcat\s+.*\.zsh_history',
]
# ============================================================================
# DEFAULT ALLOWLIST - Only these command patterns are logged
# ============================================================================
DEFAULT_ALLOWLIST_PATTERNS: List[str] = [
# Basic navigation and info
r'^ls\b',
r'^ll\b',
r'^la\b',
r'^cd\b',
r'^pwd\s*$',
r'^whoami\s*$',
r'^id\s*$',
r'^uname\b',
r'^hostname\b',
r'^date\b',
r'^uptime\b',
r'^w\s*$',
r'^who\b',
r'^last\b',
# Disk and memory info
r'^df\b',
r'^du\b',
r'^free\b',
r'^lsblk\b',
r'^fdisk\s+-l',
r'^mount\s*$',
r'^findmnt\b',
# Process info
r'^ps\b',
r'^top\b',
r'^htop\b',
r'^pgrep\b',
r'^pstree\b',
# Network info (read-only)
r'^ip\s+(addr|link|route|neigh)',
r'^ifconfig\b',
r'^netstat\b',
r'^ss\b',
r'^ping\b',
r'^traceroute\b',
r'^tracepath\b',
r'^dig\b',
r'^nslookup\b',
r'^host\b',
r'^curl\s+(-I\s+|--head\s+)?https?://', # curl without auth only
r'^wget\s+--spider\b',
# Systemd services (status, start, stop, restart, enable, disable)
r'^systemctl\s+(status|start|stop|restart|reload|enable|disable|is-active|is-enabled|list-units|list-unit-files)\b',
r'^service\s+\S+\s+(status|start|stop|restart)',
# Journalctl (logs)
r'^journalctl\b',
# Docker read operations
r'^docker\s+(ps|images|logs|inspect|stats|info|version|network\s+ls|volume\s+ls|container\s+ls)\b',
r'^docker\s+compose\s+(ps|logs|config|images)\b',
r'^docker-compose\s+(ps|logs|config|images)\b',
# Docker management (careful operations)
r'^docker\s+(start|stop|restart|pause|unpause|kill)\s+',
r'^docker\s+compose\s+(up|down|start|stop|restart)\b',
r'^docker-compose\s+(up|down|start|stop|restart)\b',
r'^docker\s+(pull|build|rm|rmi)\b',
# Package managers (read operations)
r'^apt\s+(list|search|show|policy)\b',
r'^apt-cache\b',
r'^dpkg\s+(-l|-L|-s)\b',
r'^dnf\s+(list|search|info|repolist)\b',
r'^yum\s+(list|search|info|repolist)\b',
r'^rpm\s+(-q|-qa|-qi|-ql)\b',
r'^pacman\s+-Q',
# Package install/update (logged for audit)
r'^apt\s+(install|update|upgrade|remove|purge)\b',
r'^apt-get\s+(install|update|upgrade|remove|purge)\b',
r'^dnf\s+(install|update|upgrade|remove)\b',
r'^yum\s+(install|update|upgrade|remove)\b',
# File viewing (non-sensitive paths)
r'^cat\s+(/var/log/|/etc/(?!shadow|passwd-|group-|gshadow))',
r'^tail\b',
r'^head\b',
r'^less\b',
r'^more\b',
r'^grep\b',
r'^awk\b',
r'^sed\b',
r'^find\b',
r'^locate\b',
r'^wc\b',
r'^sort\b',
r'^uniq\b',
r'^cut\b',
r'^tr\b',
# File operations (careful, but useful for audit)
r'^cp\b',
r'^mv\b',
r'^rm\b',
r'^mkdir\b',
r'^rmdir\b',
r'^chmod\b',
r'^chown\b',
r'^touch\b',
r'^ln\b',
# Text editors opening files
r'^(vi|vim|nano|emacs)\s+(?!/etc/shadow)',
# Git operations
r'^git\s+(status|log|diff|branch|show|remote|fetch|pull|push|checkout|merge|rebase|stash)\b',
# Ansible
r'^ansible\b',
r'^ansible-playbook\b',
r'^ansible-galaxy\b',
# Terraform (read operations)
r'^terraform\s+(plan|show|state|output|validate)\b',
# Proxmox
r'^(qm|pct|pvesh|pvesm|pveam)\s+(list|status|config|get)',
# ZFS/LVM info
r'^zfs\s+(list|get|status)\b',
r'^zpool\s+(list|status|iostat)\b',
r'^lvs\b',
r'^vgs\b',
r'^pvs\b',
r'^lvm\s+(lvs|vgs|pvs)\b',
# Clear/exit (safe)
r'^clear\s*$',
r'^exit\s*$',
r'^logout\s*$',
]
# ============================================================================
# MASKING PATTERNS - Values to redact even in allowed commands
# ============================================================================
DEFAULT_MASK_PATTERNS: List[Tuple[str, str]] = [
# Password flags
(r'(--password[=\s]+)\S+', r'\1***'),
(r'(-p\s+)\S+', r'\1***'),
(r'(--pass[=\s]+)\S+', r'\1***'),
# Token flags
(r'(--token[=\s]+)\S+', r'\1***'),
(r'(-t\s+)\S+', r'\1***'),
# Auth headers
(r'(Authorization:\s*Bearer\s+)\S+', r'\1***'),
(r'(Authorization:\s*Basic\s+)\S+', r'\1***'),
(r'(Authorization:\s*)\S+', r'\1***'),
# API keys
(r'(--api-key[=\s]+)\S+', r'\1***'),
(r'(--apikey[=\s]+)\S+', r'\1***'),
# User credentials in URLs
(r'(https?://)[^:]+:[^@]+(@)', r'\1***:***\2'),
# MySQL password
(r'(-p)\S+', r'\1***'),
# Environment variable values that look sensitive
(r'(\w*(PASSWORD|TOKEN|SECRET|KEY|CREDENTIAL)\w*=)\S+', r'\1***'),
]
@dataclass
class CommandPolicy:
"""
Command Policy Engine for evaluating and masking terminal commands.
Usage:
policy = CommandPolicy()
result = policy.evaluate("ls -la /var/log")
if result.should_log:
# Log result.masked_command to database
pass
"""
blocklist_patterns: List[re.Pattern] = field(default_factory=list)
allowlist_patterns: List[re.Pattern] = field(default_factory=list)
mask_patterns: List[Tuple[re.Pattern, str]] = field(default_factory=list)
# Policy mode: 'strict' only logs allowlist, 'permissive' logs everything not blocked
mode: str = "strict"
# Config file path (optional)
config_path: Optional[Path] = None
def __post_init__(self):
"""Initialize with default patterns or load from config."""
if self.config_path and self.config_path.exists():
self._load_config()
else:
self._load_defaults()
def _load_defaults(self):
"""Load default patterns."""
self.blocklist_patterns = [
re.compile(p, re.IGNORECASE) for p in DEFAULT_BLOCKLIST_PATTERNS
]
self.allowlist_patterns = [
re.compile(p, re.IGNORECASE) for p in DEFAULT_ALLOWLIST_PATTERNS
]
self.mask_patterns = [
(re.compile(p, re.IGNORECASE), r) for p, r in DEFAULT_MASK_PATTERNS
]
logger.info(
f"CommandPolicy initialized with {len(self.blocklist_patterns)} blocklist, "
f"{len(self.allowlist_patterns)} allowlist, {len(self.mask_patterns)} mask patterns"
)
def _load_config(self):
"""Load patterns from YAML config file."""
try:
with open(self.config_path, 'r') as f:
config = yaml.safe_load(f)
if 'blocklist' in config:
self.blocklist_patterns = [
re.compile(p, re.IGNORECASE) for p in config['blocklist']
]
else:
self.blocklist_patterns = [
re.compile(p, re.IGNORECASE) for p in DEFAULT_BLOCKLIST_PATTERNS
]
if 'allowlist' in config:
self.allowlist_patterns = [
re.compile(p, re.IGNORECASE) for p in config['allowlist']
]
else:
self.allowlist_patterns = [
re.compile(p, re.IGNORECASE) for p in DEFAULT_ALLOWLIST_PATTERNS
]
if 'mask' in config:
self.mask_patterns = [
(re.compile(p, re.IGNORECASE), r) for p, r in config['mask']
]
else:
self.mask_patterns = [
(re.compile(p, re.IGNORECASE), r) for p, r in DEFAULT_MASK_PATTERNS
]
self.mode = config.get('mode', 'strict')
logger.info(f"CommandPolicy loaded from {self.config_path}")
except Exception as e:
logger.error(f"Failed to load command policy config: {e}, using defaults")
self._load_defaults()
def _check_blocklist(self, command: str) -> Optional[str]:
"""
Check if command matches any blocklist pattern.
Returns:
Matched pattern string if blocked, None otherwise
"""
for pattern in self.blocklist_patterns:
if pattern.search(command):
return pattern.pattern
return None
def _check_allowlist(self, command: str) -> Optional[str]:
"""
Check if command matches any allowlist pattern.
Returns:
Matched pattern string if allowed, None otherwise
"""
for pattern in self.allowlist_patterns:
if pattern.search(command):
return pattern.pattern
return None
def _mask_sensitive_values(self, command: str) -> str:
"""Apply all masking patterns to the command."""
masked = command
for pattern, replacement in self.mask_patterns:
masked = pattern.sub(replacement, masked)
return masked
def _compute_hash(self, command: str) -> str:
"""Compute SHA-256 hash of normalized command."""
# Normalize: lowercase, collapse whitespace
normalized = ' '.join(command.lower().split())
return hashlib.sha256(normalized.encode('utf-8')).hexdigest()
def evaluate(self, command: str) -> CommandPolicyResult:
"""
Evaluate a command against the policy.
Args:
command: The raw command string to evaluate
Returns:
CommandPolicyResult with decision and optional masked command
"""
# Normalize command (strip whitespace)
command = command.strip()
if not command:
return CommandPolicyResult(
decision=PolicyDecision.UNKNOWN,
original_command=command,
reason="Empty command"
)
# Step 1: Check blocklist first (security priority)
blocked_pattern = self._check_blocklist(command)
if blocked_pattern:
logger.debug(f"Command blocked by pattern: {blocked_pattern}")
return CommandPolicyResult(
decision=PolicyDecision.BLOCK,
original_command=command,
reason="Command contains sensitive content",
matched_rule=blocked_pattern
)
# Step 2: Check allowlist
allowed_pattern = self._check_allowlist(command)
if allowed_pattern or self.mode == "permissive":
# Step 3: Apply masking to allowed commands
masked = self._mask_sensitive_values(command)
command_hash = self._compute_hash(masked)
return CommandPolicyResult(
decision=PolicyDecision.ALLOW,
original_command=command,
masked_command=masked,
command_hash=command_hash,
matched_rule=allowed_pattern
)
# Not in allowlist and strict mode
return CommandPolicyResult(
decision=PolicyDecision.UNKNOWN,
original_command=command,
reason="Command not in allowlist"
)
def is_safe_to_log(self, command: str) -> bool:
"""Quick check if a command is safe to log."""
return self.evaluate(command).should_log
def get_masked_command(self, command: str) -> Optional[str]:
"""Get the masked version of a command if it's allowed to log."""
result = self.evaluate(command)
return result.masked_command if result.should_log else None
# ============================================================================
# Global instance with lazy initialization
# ============================================================================
_policy_instance: Optional[CommandPolicy] = None
def get_command_policy() -> CommandPolicy:
"""Get or create the global command policy instance."""
global _policy_instance
if _policy_instance is None:
# Check for config file in environment or default location
config_path = os.environ.get("COMMAND_POLICY_CONFIG")
if config_path:
_policy_instance = CommandPolicy(config_path=Path(config_path))
else:
_policy_instance = CommandPolicy()
return _policy_instance
def evaluate_command(command: str) -> CommandPolicyResult:
"""Convenience function to evaluate a command."""
return get_command_policy().evaluate(command)