Add terminal session management with heartbeat monitoring, idle timeout detection, session reuse logic, and command history panel UI with search and filtering capabilities
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
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
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Security modules for the Homelab Automation application.
|
||||
"""
|
||||
from .command_policy import CommandPolicy, CommandPolicyResult
|
||||
|
||||
__all__ = ["CommandPolicy", "CommandPolicyResult"]
|
||||
@@ -0,0 +1,511 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user