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
512 lines
16 KiB
Python
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)
|