Complete Script Generator feature including: Backend: - ScriptCategory, ScriptTemplate, ScriptGeneration models - ScriptTemplateEngine with substitution, filters, sanitization - CRUD + share API endpoints with permission checks - Integration tests for permissions and sharing - Migration 057 with AD User Management seed templates Frontend — Script Library: - Browse templates with category tabs and search - Configure pane with parameter form and script generation - Script preview with live substitution and copy/download - scriptGeneratorStore Zustand store Frontend — Template Editor: - Full CRUD form with metadata, script body (Monaco Editor), parameters - ParameterSchemaBuilder with visual builder + JSON toggle - ScriptManagePage with routing and nav link Frontend — Parameter Detector: - Client-side PowerShell parameter detection engine - Detects script-level param() blocks and variable assignments - Type inference from PS type annotations and value patterns - ParameterDetectorStepper one-by-one review UI with accept/skip Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
140 lines
5.7 KiB
Python
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("'", "''")
|