diff --git a/app/src/confgen/formatter.py b/app/src/confgen/formatter.py new file mode 100644 index 0000000..4074491 --- /dev/null +++ b/app/src/confgen/formatter.py @@ -0,0 +1,103 @@ +"""Output formatting with secret masking.""" + +from typing import Any + + +class OutputFormatter: + """Formatter for configuration output with secret masking.""" + + SECRET_PATTERNS = [ + "SECRET_", + "PASSWORD", + "API_KEY", + "API_SECRET", + "CREDENTIAL", + "TOKEN", + "PRIVATE_KEY", + "DB_PASSWORD", + "AUTH", + ] + + def __init__(self): + pass + + def format_value(self, value: Any, masked: bool = False) -> str: + """Format a single value for display.""" + if masked or self._is_secret(str(value)): + return "***" + return str(value) + + def _is_secret(self, value: str) -> bool: + """Check if a value appears to be a secret.""" + value_upper = value.upper() + return any(pattern in value_upper for pattern in self.SECRET_PATTERNS) + + def mask_content(self, content: str) -> str: + """Mask secret values in content.""" + import re + + patterns = [ + (r"{{env\.([A-Z0-9_]+)}}", "***"), + (r"{{vault\.([A-Z0-9_]+)}}", "***"), + (r"(\b[A-Z0-9_]*SECRET[A-Z0-9_]*\b)", "***"), + (r"(\b[A-Z0-9_]*PASSWORD[A-Z0-9_]*\b)", "***"), + (r"(\b[A-Z0-9_]*API_KEY[A-Z0-9_]*\b)", "***"), + (r"(\b[A-Z0-9_]*TOKEN[A-Z0-9_]*\b)", "***"), + ] + + for pattern, _ in patterns: + content = re.sub(pattern, "***", content, flags=re.IGNORECASE) + + return content + + def format_dict(self, data: dict[str, Any], indent: int = 0) -> str: + """Format a dictionary for display with secret masking.""" + lines = [] + prefix = " " * indent + + for key, value in data.items(): + if isinstance(value, dict): + lines.append(f"{prefix}{key}:") + lines.append(self.format_dict(value, indent + 1)) + elif isinstance(value, list): + lines.append(f"{prefix}{key}:") + for item in value: + if isinstance(item, dict): + lines.append(f"{prefix} -") + lines.append(self.format_dict(item, indent + 2)) + else: + masked = self._is_secret(str(item)) + lines.append(f"{prefix} - {self.format_value(item, masked)}") + else: + masked = self._is_secret(key) or self._is_secret(str(value)) + lines.append(f"{prefix}{key}: {self.format_value(value, masked)}") + + return "\n".join(lines) + + def format_json(self, data: dict[str, Any], indent: int = 2) -> str: + """Format data as JSON with secret masking.""" + import json + + masked_data = self._mask_dict(data) + return json.dumps(masked_data, indent=indent, ensure_ascii=False) + + def _mask_dict(self, data: dict[str, Any]) -> dict[str, Any]: + """Recursively mask secrets in a dictionary.""" + masked = {} + for key, value in data.items(): + if isinstance(value, dict): + masked[key] = self._mask_dict(value) + elif isinstance(value, list): + masked[key] = [ + self._mask_dict(item) if isinstance(item, dict) else self._mask_value(item) + for item in value + ] + else: + masked[key] = self._mask_value(value) + return masked + + def _mask_value(self, value: Any) -> Any: + """Mask a single value if it's a secret.""" + if isinstance(value, str) and self._is_secret(value): + return "***" + return value