Files
resolutionflow/backend/app/services/script_template_engine.py
2026-03-13 00:17:14 -04:00

140 lines
5.7 KiB
Python

"""
ScriptTemplateEngine — renders parameterized PowerShell templates.
Template syntax:
{{ param_name }} — simple substitution (string-escaped)
{{ param | filter }} — substitution with filter applied
{% if param %} ... {% endif %} — conditional block
Security: all string values are PowerShell-escaped before insertion.
Sensitive parameter values are never stored in generation records — use redact_sensitive().
"""
import re
from typing import Any
class ScriptRenderError(Exception):
"""Raised when a required parameter is missing or rendering fails."""
class ScriptTemplateEngine:
# ── Public API ────────────────────────────────────────────────────────
def render(self, body: str, parameters: dict[str, Any]) -> str:
"""Render a template body with the given parameters. Raises ScriptRenderError on missing required params."""
result = self._process_conditionals(body, parameters)
result = self._substitute_params(result, parameters)
return result
def redact_sensitive(self, parameters: dict[str, Any], sensitive_keys: set[str]) -> dict[str, Any]:
"""Return a copy of parameters with sensitive values replaced by [REDACTED]."""
return {
k: "[REDACTED]" if k in sensitive_keys else v
for k, v in parameters.items()
}
# ── Conditional processing ────────────────────────────────────────────
def _process_conditionals(self, body: str, parameters: dict[str, Any]) -> str:
"""Process {% if param %} ... {% endif %} blocks."""
pattern = re.compile(
r'\{%\s*if\s+(\w+)\s*%\}(.*?)\{%\s*endif\s*%\}',
re.DOTALL
)
def replace_block(match: re.Match) -> str:
key = match.group(1)
content = match.group(2)
value = parameters.get(key)
# Truthy: non-empty string, non-empty list, True, non-zero number
if value is None or value == "" or value == [] or value is False:
return ""
return content
return pattern.sub(replace_block, body)
# ── Parameter substitution ────────────────────────────────────────────
# Matches {{ param }} or {{ param | filter }}
_PLACEHOLDER = re.compile(r'\{\{\s*(\w+)(?:\s*\|\s*(\w+))?\s*\}\}')
def _substitute_params(self, body: str, parameters: dict[str, Any]) -> str:
missing: list[str] = []
def replace(match: re.Match) -> str:
key = match.group(1)
filter_name = match.group(2)
if key not in parameters:
missing.append(key)
return match.group(0) # leave as-is, report at end
value = parameters[key]
if filter_name:
return self._apply_filter(filter_name, value)
return self._escape_string(value)
result = self._PLACEHOLDER.sub(replace, body)
if missing:
raise ScriptRenderError(
f"Missing required template parameter(s): {', '.join(missing)}"
)
return result
# ── Filters ──────────────────────────────────────────────────────────
def _apply_filter(self, filter_name: str, value: Any) -> str:
filters = {
"as_secure_string": self._filter_as_secure_string,
"as_array": self._filter_as_array,
"as_bool": self._filter_as_bool,
"escape_single": self._filter_escape_single,
}
if filter_name not in filters:
raise ScriptRenderError(f"Unknown template filter: {filter_name}")
return filters[filter_name](value)
def _filter_as_secure_string(self, value: Any) -> str:
escaped = str(value).replace("'", "''")
return f"(ConvertTo-SecureString '{escaped}' -AsPlainText -Force)"
def _filter_as_array(self, value: Any) -> str:
if not isinstance(value, list):
value = [value]
items = ",".join(f"'{self._escape_single_quote(str(v))}'" for v in value)
return items
def _filter_as_bool(self, value: Any) -> str:
if isinstance(value, bool):
return "$true" if value else "$false"
return "$true" if str(value).lower() in ("true", "1", "yes") else "$false"
def _filter_escape_single(self, value: Any) -> str:
return self._escape_single_quote(str(value))
# ── Escaping helpers ──────────────────────────────────────────────────
def _escape_string(self, value: Any) -> str:
"""Escape a value for safe insertion into a PowerShell single-quoted context."""
if isinstance(value, bool):
return "$true" if value else "$false"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, list):
# Lists rendered without filter — join with space (caller should use | as_array for proper PS arrays)
return " ".join(str(v) for v in value)
s = str(value)
# Escape backtick (PS escape char) first, then dollar (variable interpolation)
s = s.replace("`", "``")
s = s.replace("$", "`$")
# Escape single quotes (doubling for PS single-quoted strings)
s = self._escape_single_quote(s)
return s
def _escape_single_quote(self, s: str) -> str:
return s.replace("'", "''")