More hardening measures
This commit is contained in:
parent
a9d56b66c5
commit
be1f9940a5
22 changed files with 902 additions and 162 deletions
6
debian/changelog
vendored
6
debian/changelog
vendored
|
|
@ -1,3 +1,9 @@
|
||||||
|
jinjaturtle (0.5.7) unstable; urgency=medium
|
||||||
|
|
||||||
|
* More hardening measures
|
||||||
|
|
||||||
|
-- Miguel Jacq <mig@mig5.net> Wed, 24 Jun 2026 16:13:00 +1000
|
||||||
|
|
||||||
jinjaturtle (0.5.6) unstable; urgency=medium
|
jinjaturtle (0.5.6) unstable; urgency=medium
|
||||||
|
|
||||||
* Try to prevent what could lead to execution of embedded jinja in original files when converting
|
* Try to prevent what could lead to execution of embedded jinja in original files when converting
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "jinjaturtle"
|
name = "jinjaturtle"
|
||||||
version = "0.5.6"
|
version = "0.5.7"
|
||||||
description = "Convert config files into Ansible defaults and Jinja2 templates."
|
description = "Convert config files into Ansible defaults and Jinja2 templates."
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Miguel Jacq", email = "mig@mig5.net" },
|
{ name = "Miguel Jacq", email = "mig@mig5.net" },
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
%global upstream_version 0.5.6
|
%global upstream_version 0.5.7
|
||||||
|
|
||||||
Name: jinjaturtle
|
Name: jinjaturtle
|
||||||
Version: %{upstream_version}
|
Version: %{upstream_version}
|
||||||
|
|
@ -42,6 +42,8 @@ Convert config files into Ansible defaults and Jinja2 templates.
|
||||||
%{_bindir}/jinjaturtle
|
%{_bindir}/jinjaturtle
|
||||||
|
|
||||||
%changelog
|
%changelog
|
||||||
|
* Wed Jun 24 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
||||||
|
- More hardening
|
||||||
* Tue Jun 23 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
* Tue Jun 23 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
||||||
- Try to prevent what could lead to execution of embedded jinja in original files when converting
|
- Try to prevent what could lead to execution of embedded jinja in original files when converting
|
||||||
* Sat Jun 20 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
* Sat Jun 20 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
||||||
|
|
@ -55,7 +57,7 @@ Convert config files into Ansible defaults and Jinja2 templates.
|
||||||
- Fix indentation problems with nested dicts
|
- Fix indentation problems with nested dicts
|
||||||
* Fri Jun 19 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
* Fri Jun 19 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
||||||
- Empty dicts and lists are now emitted as leaf defaults.
|
- Empty dicts and lists are now emitted as leaf defaults.
|
||||||
* Tue May 11 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
* Mon May 11 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
||||||
- Support ssh configs
|
- Support ssh configs
|
||||||
* Tue Jan 06 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
* Tue Jan 06 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
||||||
- Support converting systemd files and postfix main.cf
|
- Support converting systemd files and postfix main.cf
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,25 @@ from __future__ import annotations
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _register_yaml_unsafe_constructor() -> None:
|
||||||
|
"""Let PyYAML SafeLoader read Ansible's !unsafe tag as a plain string.
|
||||||
|
|
||||||
|
Ansible treats !unsafe specially at runtime. Ordinary PyYAML consumers such
|
||||||
|
as tests or static tooling do not know the tag by default; registering this
|
||||||
|
constructor keeps generated defaults inspectable without changing the YAML
|
||||||
|
emitted for Ansible.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except Exception: # pragma: no cover - PyYAML is a required dependency
|
||||||
|
return
|
||||||
|
|
||||||
|
def _construct_unsafe(loader, node):
|
||||||
|
return loader.construct_scalar(node)
|
||||||
|
|
||||||
|
yaml.SafeLoader.add_constructor("!unsafe", _construct_unsafe)
|
||||||
|
|
||||||
|
|
||||||
|
_register_yaml_unsafe_constructor()
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from defusedxml import defuse_stdlib
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from . import j2
|
from . import j2
|
||||||
|
|
@ -19,6 +19,18 @@ from .core import (
|
||||||
from .multi import process_directory
|
from .multi import process_directory
|
||||||
|
|
||||||
|
|
||||||
|
_SAFE_ROLE_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_role_name(value: str) -> str:
|
||||||
|
if not _SAFE_ROLE_RE.fullmatch(value):
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
"role name must match ^[A-Za-z_][A-Za-z0-9_]*$ so it can never "
|
||||||
|
"inject template syntax"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _build_arg_parser() -> argparse.ArgumentParser:
|
def _build_arg_parser() -> argparse.ArgumentParser:
|
||||||
ap = argparse.ArgumentParser(
|
ap = argparse.ArgumentParser(
|
||||||
prog="jinjaturtle",
|
prog="jinjaturtle",
|
||||||
|
|
@ -35,6 +47,7 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
||||||
"-r",
|
"-r",
|
||||||
"--role-name",
|
"--role-name",
|
||||||
default="jinjaturtle",
|
default="jinjaturtle",
|
||||||
|
type=_safe_role_name,
|
||||||
help="Ansible role name, used as variable prefix (default: jinjaturtle).",
|
help="Ansible role name, used as variable prefix (default: jinjaturtle).",
|
||||||
)
|
)
|
||||||
ap.add_argument(
|
ap.add_argument(
|
||||||
|
|
@ -76,7 +89,6 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
||||||
|
|
||||||
|
|
||||||
def _main(argv: list[str] | None = None) -> int:
|
def _main(argv: list[str] | None = None) -> int:
|
||||||
defuse_stdlib()
|
|
||||||
parser = _build_arg_parser()
|
parser = _build_arg_parser()
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import yaml
|
||||||
|
|
||||||
from .loop_analyzer import LoopAnalyzer, LoopCandidate
|
from .loop_analyzer import LoopAnalyzer, LoopCandidate
|
||||||
from .erb import puppet_class_name, puppet_local_var_name, translate_jinja2_to_erb
|
from .erb import puppet_class_name, puppet_local_var_name, translate_jinja2_to_erb
|
||||||
|
from .escape import contains_erb_markup, contains_jinja_markup
|
||||||
|
from .template_safety import validate_generated_template
|
||||||
from .handlers import (
|
from .handlers import (
|
||||||
BaseHandler,
|
BaseHandler,
|
||||||
IniHandler,
|
IniHandler,
|
||||||
|
|
@ -23,8 +25,17 @@ from .handlers import (
|
||||||
|
|
||||||
|
|
||||||
class QuotedString(str):
|
class QuotedString(str):
|
||||||
"""
|
"""Marker type for strings that must be double-quoted in YAML output."""
|
||||||
Marker type for strings that must be double-quoted in YAML output.
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UnsafeString(str):
|
||||||
|
"""Marker type for Ansible values that must never be recursively templated.
|
||||||
|
|
||||||
|
Ansible honours the ``!unsafe`` YAML tag by loading the value as unsafe text,
|
||||||
|
preventing a later templating pass from evaluating delimiters such as
|
||||||
|
``{{ ... }}`` or ``<%= ... %>`` that came from the original config file.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
@ -49,7 +60,12 @@ def _quoted_str_representer(dumper: yaml.SafeDumper, data: QuotedString):
|
||||||
return dumper.represent_scalar("tag:yaml.org,2002:str", str(data), style='"')
|
return dumper.represent_scalar("tag:yaml.org,2002:str", str(data), style='"')
|
||||||
|
|
||||||
|
|
||||||
|
def _unsafe_str_representer(dumper: yaml.SafeDumper, data: UnsafeString):
|
||||||
|
return dumper.represent_scalar("!unsafe", str(data))
|
||||||
|
|
||||||
|
|
||||||
_TurtleDumper.add_representer(QuotedString, _quoted_str_representer)
|
_TurtleDumper.add_representer(QuotedString, _quoted_str_representer)
|
||||||
|
_TurtleDumper.add_representer(UnsafeString, _unsafe_str_representer)
|
||||||
# Use our fallback for any unknown object types
|
# Use our fallback for any unknown object types
|
||||||
_TurtleDumper.add_representer(None, _fallback_str_representer)
|
_TurtleDumper.add_representer(None, _fallback_str_representer)
|
||||||
|
|
||||||
|
|
@ -92,6 +108,26 @@ def dump_yaml(data: Any, *, sort_keys: bool = True) -> str:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_ansible_unsafe(obj: Any) -> Any:
|
||||||
|
"""Recursively mark source strings that could be re-templated by Ansible.
|
||||||
|
|
||||||
|
JinjaTurtle's primary template render treats source values as data, but the
|
||||||
|
generated defaults file may later be consumed by other Ansible tasks or
|
||||||
|
templates. Any source-derived string containing Jinja2 or ERB delimiters is
|
||||||
|
therefore emitted with Ansible's ``!unsafe`` tag so it cannot become live
|
||||||
|
template code in a recursive templating context.
|
||||||
|
"""
|
||||||
|
if isinstance(obj, str):
|
||||||
|
if contains_jinja_markup(obj) or contains_erb_markup(obj):
|
||||||
|
return UnsafeString(obj)
|
||||||
|
return obj
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [mark_ansible_unsafe(v) for v in obj]
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {k: mark_ansible_unsafe(v) for k, v in obj.items()}
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
def make_var_name(role_prefix: str, path: Iterable[str]) -> str:
|
def make_var_name(role_prefix: str, path: Iterable[str]) -> str:
|
||||||
"""
|
"""
|
||||||
Wrapper for :meth:`BaseHandler.make_var_name`.
|
Wrapper for :meth:`BaseHandler.make_var_name`.
|
||||||
|
|
@ -337,6 +373,37 @@ def _path_starts_with(path: tuple[str, ...], prefix: tuple[str, ...]) -> bool:
|
||||||
return path[: len(prefix)] == prefix
|
return path[: len(prefix)] == prefix
|
||||||
|
|
||||||
|
|
||||||
|
def _path_label(path: tuple[str, ...]) -> str:
|
||||||
|
return ".".join(path) if path else "<root>"
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_on_variable_name_collisions(
|
||||||
|
role_prefix: str,
|
||||||
|
flat_items: list[tuple[tuple[str, ...], Any]],
|
||||||
|
loop_candidates: list[LoopCandidate] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Fail closed when distinct source paths collapse to one variable name."""
|
||||||
|
seen: dict[str, list[str]] = {}
|
||||||
|
for path, _value in flat_items:
|
||||||
|
seen.setdefault(make_var_name(role_prefix, path), []).append(_path_label(path))
|
||||||
|
if loop_candidates:
|
||||||
|
for candidate in loop_candidates:
|
||||||
|
seen.setdefault(make_var_name(role_prefix, candidate.path), []).append(
|
||||||
|
_path_label(candidate.path)
|
||||||
|
)
|
||||||
|
|
||||||
|
collisions = {name: paths for name, paths in seen.items() if len(set(paths)) > 1}
|
||||||
|
if collisions:
|
||||||
|
details = "; ".join(
|
||||||
|
f"{name}: {', '.join(paths)}" for name, paths in sorted(collisions.items())
|
||||||
|
)
|
||||||
|
raise ValueError(
|
||||||
|
"Refusing to generate templates because multiple config paths map to "
|
||||||
|
f"the same variable name ({details}). Rename one of the keys or use "
|
||||||
|
"separate role names."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def generate_ansible_yaml(
|
def generate_ansible_yaml(
|
||||||
role_prefix: str,
|
role_prefix: str,
|
||||||
flat_items: list[tuple[tuple[str, ...], Any]],
|
flat_items: list[tuple[tuple[str, ...], Any]],
|
||||||
|
|
@ -345,18 +412,20 @@ def generate_ansible_yaml(
|
||||||
"""
|
"""
|
||||||
Create Ansible YAML for defaults/main.yml.
|
Create Ansible YAML for defaults/main.yml.
|
||||||
"""
|
"""
|
||||||
|
_raise_on_variable_name_collisions(role_prefix, flat_items, loop_candidates)
|
||||||
|
|
||||||
defaults: dict[str, Any] = {}
|
defaults: dict[str, Any] = {}
|
||||||
|
|
||||||
# Add scalar variables
|
# Add scalar variables
|
||||||
for path, value in flat_items:
|
for path, value in flat_items:
|
||||||
var_name = make_var_name(role_prefix, path)
|
var_name = make_var_name(role_prefix, path)
|
||||||
defaults[var_name] = value # No normalization - keep original types
|
defaults[var_name] = mark_ansible_unsafe(value) # keep type, tag unsafe strings
|
||||||
|
|
||||||
# Add loop collections
|
# Add loop collections
|
||||||
if loop_candidates:
|
if loop_candidates:
|
||||||
for candidate in loop_candidates:
|
for candidate in loop_candidates:
|
||||||
var_name = make_var_name(role_prefix, candidate.path)
|
var_name = make_var_name(role_prefix, candidate.path)
|
||||||
defaults[var_name] = candidate.items
|
defaults[var_name] = mark_ansible_unsafe(candidate.safe_items())
|
||||||
|
|
||||||
return dump_yaml(defaults, sort_keys=True)
|
return dump_yaml(defaults, sort_keys=True)
|
||||||
|
|
||||||
|
|
@ -378,14 +447,17 @@ def generate_jinja2_template(
|
||||||
|
|
||||||
# Check if handler supports loop-aware generation
|
# Check if handler supports loop-aware generation
|
||||||
if hasattr(handler, "generate_jinja2_template_with_loops") and loop_candidates:
|
if hasattr(handler, "generate_jinja2_template_with_loops") and loop_candidates:
|
||||||
return handler.generate_jinja2_template_with_loops(
|
template = handler.generate_jinja2_template_with_loops(
|
||||||
parsed, role_prefix, original_text, loop_candidates
|
parsed, role_prefix, original_text, loop_candidates
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# Fallback to original scalar-only generation
|
||||||
|
template = handler.generate_jinja2_template(
|
||||||
|
parsed, role_prefix, original_text=original_text
|
||||||
|
)
|
||||||
|
|
||||||
# Fallback to original scalar-only generation
|
validate_generated_template(template)
|
||||||
return handler.generate_jinja2_template(
|
return template
|
||||||
parsed, role_prefix, original_text=original_text
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _template_variable_names(
|
def _template_variable_names(
|
||||||
|
|
@ -414,6 +486,8 @@ def generate_puppet_hiera_yaml(
|
||||||
they are the same, so ``php_memory_limit`` becomes ``php::memory_limit``.
|
they are the same, so ``php_memory_limit`` becomes ``php::memory_limit``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_raise_on_variable_name_collisions(role_prefix, flat_items, loop_candidates)
|
||||||
|
|
||||||
klass = puppet_class_name(puppet_class or role_prefix)
|
klass = puppet_class_name(puppet_class or role_prefix)
|
||||||
data: dict[str, Any] = {}
|
data: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
@ -426,7 +500,7 @@ def generate_puppet_hiera_yaml(
|
||||||
for candidate in loop_candidates:
|
for candidate in loop_candidates:
|
||||||
generated = make_var_name(role_prefix, candidate.path)
|
generated = make_var_name(role_prefix, candidate.path)
|
||||||
local = puppet_local_var_name(role_prefix, generated, puppet_class=klass)
|
local = puppet_local_var_name(role_prefix, generated, puppet_class=klass)
|
||||||
data[f"{klass}::{local}"] = candidate.items
|
data[f"{klass}::{local}"] = candidate.safe_items()
|
||||||
|
|
||||||
return dump_yaml(data, sort_keys=True)
|
return dump_yaml(data, sort_keys=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import re
|
||||||
|
|
||||||
from .escape import escape_erb_literal
|
from .escape import escape_erb_literal
|
||||||
|
|
||||||
|
_SAFE_GENERATED_FIELD_RE = re.compile(r"^jt_[A-Za-z0-9_]+_[0-9a-f]{8}$")
|
||||||
|
_REF_RE = r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*"
|
||||||
|
|
||||||
|
|
||||||
def _safe_name(raw: str, *, fallback: str = "var") -> str:
|
def _safe_name(raw: str, *, fallback: str = "var") -> str:
|
||||||
text = re.sub(r"[^A-Za-z0-9_]+", "_", str(raw or fallback)).strip("_").lower()
|
text = re.sub(r"[^A-Za-z0-9_]+", "_", str(raw or fallback)).strip("_").lower()
|
||||||
|
|
@ -128,24 +131,41 @@ class ErbTranslator:
|
||||||
return "false"
|
return "false"
|
||||||
if expr in {"none", "None", "null"}:
|
if expr in {"none", "None", "null"}:
|
||||||
return "nil"
|
return "nil"
|
||||||
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", expr):
|
|
||||||
if any(expr == loop_var for loop_var, _idx, _coll in self.loop_stack):
|
if not re.match(rf"^{_REF_RE}$", expr):
|
||||||
return expr
|
raise ValueError(
|
||||||
return f"@{self.local_var(expr)}"
|
f"Unsupported JinjaTurtle expression for ERB translation: {expr}"
|
||||||
m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$", expr)
|
)
|
||||||
if m:
|
|
||||||
base, key = m.groups()
|
parts = expr.split(".")
|
||||||
|
base = parts[0]
|
||||||
|
attrs = parts[1:]
|
||||||
|
|
||||||
|
if not attrs:
|
||||||
if any(base == loop_var for loop_var, _idx, _coll in self.loop_stack):
|
if any(base == loop_var for loop_var, _idx, _coll in self.loop_stack):
|
||||||
return f"{base}[{key!r}]"
|
return base
|
||||||
return f"@{self.local_var(base)}[{key!r}]"
|
return f"@{self.local_var(base)}"
|
||||||
return expr
|
|
||||||
|
if any(base == loop_var for loop_var, _idx, _coll in self.loop_stack):
|
||||||
|
ruby = base
|
||||||
|
else:
|
||||||
|
ruby = f"@{self.local_var(base)}"
|
||||||
|
|
||||||
|
for attr in attrs:
|
||||||
|
if not _SAFE_GENERATED_FIELD_RE.match(attr):
|
||||||
|
raise ValueError(
|
||||||
|
"Unsafe attribute access in generated template during ERB "
|
||||||
|
f"translation: {expr}"
|
||||||
|
)
|
||||||
|
ruby = f"{ruby}[{attr!r}]"
|
||||||
|
return ruby
|
||||||
|
|
||||||
def expr_to_ruby(self, expr: str) -> str:
|
def expr_to_ruby(self, expr: str) -> str:
|
||||||
expr = expr.strip()
|
expr = expr.strip()
|
||||||
|
|
||||||
# JinjaTurtle emits these YAML-preserving ternaries for booleans/nulls.
|
# JinjaTurtle emits these YAML-preserving ternaries for booleans/nulls.
|
||||||
m = re.match(
|
m = re.match(
|
||||||
r"^(['\"])(true|false)\1\s+if\s+([A-Za-z_][A-Za-z0-9_\.]*)\s+else\s+(['\"])(true|false)\4$",
|
rf"^(['\"])(true|false)\1\s+if\s+({_REF_RE})\s+else\s+(['\"])(true|false)\4$",
|
||||||
expr,
|
expr,
|
||||||
)
|
)
|
||||||
if m:
|
if m:
|
||||||
|
|
@ -155,7 +175,7 @@ class ErbTranslator:
|
||||||
return f"{cond} ? {truthy!r} : {falsy!r}"
|
return f"{cond} ? {truthy!r} : {falsy!r}"
|
||||||
|
|
||||||
m = re.match(
|
m = re.match(
|
||||||
r"^(['\"])(null)\1\s+if\s+([A-Za-z_][A-Za-z0-9_\.]*)\s+is\s+none\s+else\s+([A-Za-z_][A-Za-z0-9_\.]*)$",
|
rf"^(['\"])(null)\1\s+if\s+({_REF_RE})\s+is\s+none\s+else\s+({_REF_RE})$",
|
||||||
expr,
|
expr,
|
||||||
)
|
)
|
||||||
if m:
|
if m:
|
||||||
|
|
@ -175,6 +195,10 @@ class ErbTranslator:
|
||||||
ruby = f"JSON.pretty_generate({ruby})"
|
ruby = f"JSON.pretty_generate({ruby})"
|
||||||
else:
|
else:
|
||||||
ruby = f"JSON.generate({ruby})"
|
ruby = f"JSON.generate({ruby})"
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported JinjaTurtle filter for ERB translation: {filt}"
|
||||||
|
)
|
||||||
return ruby
|
return ruby
|
||||||
|
|
||||||
return self.ruby_value(expr)
|
return self.ruby_value(expr)
|
||||||
|
|
@ -205,10 +229,10 @@ class ErbTranslator:
|
||||||
if cond == "not loop.last" and self.loop_stack:
|
if cond == "not loop.last" and self.loop_stack:
|
||||||
_loop_var, idx_var, collection_ruby = self.loop_stack[-1]
|
_loop_var, idx_var, collection_ruby = self.loop_stack[-1]
|
||||||
return f"<% if {idx_var} < ({collection_ruby}.length - 1) -%>"
|
return f"<% if {idx_var} < ({collection_ruby}.length - 1) -%>"
|
||||||
m = re.match(r"^([A-Za-z_][A-Za-z0-9_\.]*)\s+is\s+defined$", cond)
|
m = re.match(rf"^({_REF_RE})\s+is\s+defined$", cond)
|
||||||
if m:
|
if m:
|
||||||
return f"<% unless {self.ruby_value(m.group(1))}.nil? -%>"
|
return f"<% unless {self.ruby_value(m.group(1))}.nil? -%>"
|
||||||
m = re.match(r"^([A-Za-z_][A-Za-z0-9_\.]*)\s+is\s+none$", cond)
|
m = re.match(rf"^({_REF_RE})\s+is\s+none$", cond)
|
||||||
if m:
|
if m:
|
||||||
return f"<% if {self.ruby_value(m.group(1))}.nil? -%>"
|
return f"<% if {self.ruby_value(m.group(1))}.nil? -%>"
|
||||||
return f"<% if {self.expr_to_ruby(cond)} -%>"
|
return f"<% if {self.expr_to_ruby(cond)} -%>"
|
||||||
|
|
|
||||||
|
|
@ -95,13 +95,15 @@ def _defang_endraw(text: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def escape_jinja_literal(text: str) -> str:
|
def escape_jinja_literal(text: str) -> str:
|
||||||
"""Make *text* render as literal characters under a later Jinja2 render.
|
"""Make *text* render as literal characters under later template renders.
|
||||||
|
|
||||||
Text with no Jinja metacharacters is returned unchanged so the common case
|
JinjaTurtle's ERB templates are produced by translating the generated Jinja2
|
||||||
stays byte-for-byte identical to the source. Otherwise the text is wrapped
|
template. Therefore source text that contains *either* Jinja2 or ERB
|
||||||
in a single ``{% raw %}`` block, with any embedded ``endraw`` defanged.
|
delimiters must be carried through as a Jinja raw block: a later Jinja2 render
|
||||||
|
will print it literally, and the ERB translator will recognise the raw block
|
||||||
|
and escape any ERB delimiters inside it.
|
||||||
"""
|
"""
|
||||||
if not text or not contains_jinja_markup(text):
|
if not text or not (contains_jinja_markup(text) or contains_erb_markup(text)):
|
||||||
return text
|
return text
|
||||||
return "{% raw %}" + _defang_endraw(text) + "{% endraw %}"
|
return "{% raw %}" + _defang_endraw(text) + "{% endraw %}"
|
||||||
|
|
||||||
|
|
@ -109,9 +111,11 @@ def escape_jinja_literal(text: str) -> str:
|
||||||
def escape_erb_literal(text: str) -> str:
|
def escape_erb_literal(text: str) -> str:
|
||||||
"""Make *text* render as literal characters under a later ERB render.
|
"""Make *text* render as literal characters under a later ERB render.
|
||||||
|
|
||||||
ERB has no ``raw`` block, so each opening/closing delimiter is rewritten as
|
ERB has no raw block. Replace each opening delimiter with a small, safe ERB
|
||||||
an ERB expression that prints the delimiter literally. Text with no ERB
|
expression that prints the two characters ``<%`` literally, followed by the
|
||||||
metacharacters is returned unchanged.
|
original variant suffix (``=``, ``-`` or ``#``). Closing delimiters outside
|
||||||
|
an ERB tag are plain text and must not be wrapped in an expression, because
|
||||||
|
``<%= "%>" %>`` is parsed by ERB as a prematurely closed tag.
|
||||||
"""
|
"""
|
||||||
if not text or not contains_erb_markup(text):
|
if not text or not contains_erb_markup(text):
|
||||||
return text
|
return text
|
||||||
|
|
@ -121,13 +125,12 @@ def escape_erb_literal(text: str) -> str:
|
||||||
n = len(text)
|
n = len(text)
|
||||||
while i < n:
|
while i < n:
|
||||||
matched = None
|
matched = None
|
||||||
for marker in (*_ERB_CLOSE_MARKERS, *_ERB_OPEN_MARKERS):
|
for marker in _ERB_OPEN_MARKERS:
|
||||||
if text.startswith(marker, i):
|
if text.startswith(marker, i):
|
||||||
matched = marker
|
matched = marker
|
||||||
break
|
break
|
||||||
if matched is not None:
|
if matched is not None:
|
||||||
escaped = matched.replace("\\", "\\\\").replace('"', '\\"')
|
result.append("<%= '<%' %>" + matched[2:])
|
||||||
result.append('<%= "' + escaped + '" %>')
|
|
||||||
i += len(matched)
|
i += len(matched)
|
||||||
else:
|
else:
|
||||||
result.append(text[i])
|
result.append(text[i])
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
class BaseHandler:
|
class BaseHandler:
|
||||||
|
|
@ -50,30 +51,39 @@ class BaseHandler:
|
||||||
return text[:i], text[i:]
|
return text[:i], text[i:]
|
||||||
return text, ""
|
return text, ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_identifier_part(raw: object, *, fallback: str = "var") -> str:
|
||||||
|
"""Return a conservative lowercase full identifier."""
|
||||||
|
text = BaseHandler._safe_path_part(raw, fallback=fallback)
|
||||||
|
if not re.match(r"^[a-z_]", text):
|
||||||
|
text = f"{fallback}_{text}"
|
||||||
|
return text
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_path_part(raw: object, *, fallback: str = "part") -> str:
|
||||||
|
"""Return a conservative lowercase identifier fragment."""
|
||||||
|
text = re.sub(r"[^A-Za-z0-9_]+", "_", str(raw or "").strip())
|
||||||
|
text = re.sub(r"_+", "_", text).strip("_").lower()
|
||||||
|
return text or fallback
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def make_var_name(role_prefix: str, path: Iterable[str]) -> str:
|
def make_var_name(role_prefix: str, path: Iterable[str]) -> str:
|
||||||
"""
|
"""
|
||||||
Build an Ansible var name like:
|
Build a safe variable name like ``role_section_key``.
|
||||||
role_prefix_section_subsection_key
|
|
||||||
|
|
||||||
Sanitises parts to lowercase [a-z0-9_] and strips extras.
|
Both the role prefix and every source-derived path component are reduced
|
||||||
|
to a strict identifier grammar. Config keys, section names and CLI role
|
||||||
|
names are untrusted input; they must never be able to shape Jinja/ERB
|
||||||
|
statement syntax.
|
||||||
"""
|
"""
|
||||||
role_prefix = role_prefix.strip().lower()
|
prefix = BaseHandler._safe_identifier_part(role_prefix, fallback="jinjaturtle")
|
||||||
clean_parts: list[str] = []
|
clean_parts: list[str] = []
|
||||||
|
|
||||||
for part in path:
|
for part in path:
|
||||||
part = str(part).strip()
|
cleaned_part = BaseHandler._safe_path_part(part, fallback="part")
|
||||||
part = part.replace(" ", "_")
|
|
||||||
cleaned_chars: list[str] = []
|
|
||||||
for c in part:
|
|
||||||
if c.isalnum() or c == "_":
|
|
||||||
cleaned_chars.append(c.lower())
|
|
||||||
else:
|
|
||||||
cleaned_chars.append("_")
|
|
||||||
cleaned_part = "".join(cleaned_chars).strip("_")
|
|
||||||
if cleaned_part:
|
if cleaned_part:
|
||||||
clean_parts.append(cleaned_part)
|
clean_parts.append(cleaned_part)
|
||||||
|
|
||||||
if clean_parts:
|
if clean_parts:
|
||||||
return role_prefix + "_" + "_".join(clean_parts)
|
return prefix + "_" + "_".join(clean_parts)
|
||||||
return role_prefix
|
return prefix
|
||||||
|
|
|
||||||
|
|
@ -59,15 +59,19 @@ class IniHandler(BaseHandler):
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
|
|
||||||
for section in parser.sections():
|
for section in parser.sections():
|
||||||
lines.append(f"[{section}]")
|
lines.append(f"[{escape_jinja_literal(section)}]")
|
||||||
for key, value in parser.items(section, raw=True):
|
for key, value in parser.items(section, raw=True):
|
||||||
path = (section, key)
|
path = (section, key)
|
||||||
var_name = self.make_var_name(role_prefix, path)
|
var_name = self.make_var_name(role_prefix, path)
|
||||||
value = value.strip()
|
value = value.strip()
|
||||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
||||||
lines.append(f"{key} = {j2.quoted_variable(var_name)}")
|
lines.append(
|
||||||
|
f"{escape_jinja_literal(key)} = {j2.quoted_variable(var_name)}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
lines.append(f"{key} = {j2.variable(var_name)}")
|
lines.append(
|
||||||
|
f"{escape_jinja_literal(key)} = {j2.variable(var_name)}"
|
||||||
|
)
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
return "\n".join(lines).rstrip() + "\n"
|
return "\n".join(lines).rstrip() + "\n"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from typing import Any
|
||||||
|
|
||||||
from . import DictLikeHandler
|
from . import DictLikeHandler
|
||||||
from .. import j2
|
from .. import j2
|
||||||
|
from ..escape import escape_jinja_literal
|
||||||
from ..loop_analyzer import LoopCandidate
|
from ..loop_analyzer import LoopCandidate
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -109,10 +110,14 @@ class JsonHandler(DictLikeHandler):
|
||||||
chunks: list[str] = []
|
chunks: list[str] = []
|
||||||
pos = 0
|
pos = 0
|
||||||
for path, start, end in spans:
|
for path, start, end in spans:
|
||||||
chunks.append(text[pos:start])
|
# Chunks are copied verbatim from the source and may include object
|
||||||
|
# keys, whitespace or punctuation. Object keys are attacker-
|
||||||
|
# controlled literal text, so escape template delimiters before
|
||||||
|
# inserting the generated value expression between chunks.
|
||||||
|
chunks.append(escape_jinja_literal(text[pos:start]))
|
||||||
chunks.append(self._json_value_expr(self.make_var_name(role_prefix, path)))
|
chunks.append(self._json_value_expr(self.make_var_name(role_prefix, path)))
|
||||||
pos = end
|
pos = end
|
||||||
chunks.append(text[pos:])
|
chunks.append(escape_jinja_literal(text[pos:]))
|
||||||
return "".join(chunks)
|
return "".join(chunks)
|
||||||
|
|
||||||
def _collect_json_scalar_spans(
|
def _collect_json_scalar_spans(
|
||||||
|
|
@ -213,7 +218,10 @@ class JsonHandler(DictLikeHandler):
|
||||||
|
|
||||||
def _walk(obj: Any, path: tuple[str, ...] = ()) -> Any:
|
def _walk(obj: Any, path: tuple[str, ...] = ()) -> Any:
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
return {k: _walk(v, path + (str(k),)) for k, v in obj.items()}
|
return {
|
||||||
|
escape_jinja_literal(str(k)): _walk(v, path + (str(k),))
|
||||||
|
for k, v in obj.items()
|
||||||
|
}
|
||||||
if isinstance(obj, list):
|
if isinstance(obj, list):
|
||||||
return [_walk(v, path + (str(i),)) for i, v in enumerate(obj)]
|
return [_walk(v, path + (str(i),)) for i, v in enumerate(obj)]
|
||||||
# scalar - use marker that will be replaced with to_json
|
# scalar - use marker that will be replaced with to_json
|
||||||
|
|
@ -261,7 +269,10 @@ class JsonHandler(DictLikeHandler):
|
||||||
return f"__LOOP_DICT__{collection_var}__{item_var}__"
|
return f"__LOOP_DICT__{collection_var}__{item_var}__"
|
||||||
|
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
return {k: _walk(v, current_path + (str(k),)) for k, v in obj.items()}
|
return {
|
||||||
|
escape_jinja_literal(str(k)): _walk(v, current_path + (str(k),))
|
||||||
|
for k, v in obj.items()
|
||||||
|
}
|
||||||
if isinstance(obj, list):
|
if isinstance(obj, list):
|
||||||
# Check if this list is a loop candidate
|
# Check if this list is a loop candidate
|
||||||
if current_path in loop_paths:
|
if current_path in loop_paths:
|
||||||
|
|
@ -364,8 +375,10 @@ class JsonHandler(DictLikeHandler):
|
||||||
] # first line has no indent; we prepend `inner` when emitting
|
] # first line has no indent; we prepend `inner` when emitting
|
||||||
for i, key in enumerate(keys):
|
for i, key in enumerate(keys):
|
||||||
comma = "," if i < len(keys) - 1 else ""
|
comma = "," if i < len(keys) - 1 else ""
|
||||||
|
safe_key = escape_jinja_literal(str(key))
|
||||||
|
value_expr = candidate.value_expr(item_var, str(key))
|
||||||
dict_lines.append(
|
dict_lines.append(
|
||||||
f'{field}"{key}": ' f"{j2.to_json(f'{item_var}.{key}')}{comma}"
|
f'{field}"{safe_key}": ' f"{j2.to_json(value_expr)}{comma}"
|
||||||
)
|
)
|
||||||
# Comma between *items* goes after the closing brace.
|
# Comma between *items* goes after the closing brace.
|
||||||
dict_lines.append(f"{inner}}}{j2.if_not_loop_last()},{j2.endif()}")
|
dict_lines.append(f"{inner}}}{j2.if_not_loop_last()},{j2.endif()}")
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ class PostfixMainHandler(BaseHandler):
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
for k, v in parsed.items():
|
for k, v in parsed.items():
|
||||||
var = self.make_var_name(role_prefix, (k,))
|
var = self.make_var_name(role_prefix, (k,))
|
||||||
lines.append(f"{k} = {j2.variable(var)}")
|
lines.append(f"{escape_jinja_literal(k)} = {j2.variable(var)}")
|
||||||
return "\n".join(lines).rstrip() + "\n"
|
return "\n".join(lines).rstrip() + "\n"
|
||||||
return self._generate_from_text(role_prefix, original_text)
|
return self._generate_from_text(role_prefix, original_text)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -441,17 +441,17 @@ class TomlHandler(DictLikeHandler):
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
out_lines.append(
|
out_lines.append(
|
||||||
f"{escape_jinja_literal(str(key))} = "
|
f"{escape_jinja_literal(str(key))} = "
|
||||||
f"{self._toml_quoted_expr(f'{item_var}.{key}')}\n"
|
f"{self._toml_quoted_expr(candidate.value_expr(item_var, str(key)))}\n"
|
||||||
)
|
)
|
||||||
elif isinstance(value, bool):
|
elif isinstance(value, bool):
|
||||||
out_lines.append(
|
out_lines.append(
|
||||||
f"{escape_jinja_literal(str(key))} = "
|
f"{escape_jinja_literal(str(key))} = "
|
||||||
f"{self._toml_value_expr(f'{item_var}.{key}', value)}\n"
|
f"{self._toml_value_expr(candidate.value_expr(item_var, str(key)), value)}\n"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
out_lines.append(
|
out_lines.append(
|
||||||
f"{escape_jinja_literal(str(key))} = "
|
f"{escape_jinja_literal(str(key))} = "
|
||||||
f"{self._toml_value_expr(f'{item_var}.{key}', value)}\n"
|
f"{self._toml_value_expr(candidate.value_expr(item_var, str(key)), value)}\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
out_lines.append(f"{j2.for_end()}\n")
|
out_lines.append(f"{j2.for_end()}\n")
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import xml.etree.ElementTree as ET # nosec
|
import xml.etree.ElementTree as ET # nosec B405 - not used for untrusted XML parsing; all parsing uses defusedxml.
|
||||||
|
import defusedxml.ElementTree as DET
|
||||||
|
|
||||||
from .base import BaseHandler
|
from .base import BaseHandler
|
||||||
from .. import j2
|
from .. import j2
|
||||||
|
|
@ -18,11 +20,24 @@ class XmlHandler(BaseHandler):
|
||||||
|
|
||||||
fmt = "xml"
|
fmt = "xml"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# Marker comments are implementation details converted to Jinja control
|
||||||
|
# structures later in the same generation pass. Include an unguessable
|
||||||
|
# nonce so source-file comments such as <!--IF:...--> can never be
|
||||||
|
# mistaken for trusted internal markers.
|
||||||
|
self._marker_prefix = f"JINJATURTLE:{secrets.token_hex(16)}:"
|
||||||
|
|
||||||
|
def _marker(self, kind: str, payload: str) -> str:
|
||||||
|
return f"{self._marker_prefix}{kind}:{payload}"
|
||||||
|
|
||||||
def parse(self, path: Path) -> ET.Element:
|
def parse(self, path: Path) -> ET.Element:
|
||||||
text = path.read_text(encoding="utf-8")
|
text = path.read_text(encoding="utf-8")
|
||||||
parser = ET.XMLParser(
|
parser = DET.XMLParser(
|
||||||
target=ET.TreeBuilder(insert_comments=False)
|
target=ET.TreeBuilder(insert_comments=False),
|
||||||
) # nosec B314
|
forbid_dtd=True,
|
||||||
|
forbid_entities=True,
|
||||||
|
forbid_external=True,
|
||||||
|
)
|
||||||
parser.feed(text)
|
parser.feed(text)
|
||||||
root = parser.close()
|
root = parser.close()
|
||||||
return root
|
return root
|
||||||
|
|
@ -216,7 +231,7 @@ class XmlHandler(BaseHandler):
|
||||||
|
|
||||||
# Create a loop comment/marker
|
# Create a loop comment/marker
|
||||||
# We'll handle the actual loop generation in text processing
|
# We'll handle the actual loop generation in text processing
|
||||||
loop_marker = ET.Comment(f"LOOP:{tag}")
|
loop_marker = ET.Comment(self._marker("LOOP", tag))
|
||||||
elem.append(loop_marker)
|
elem.append(loop_marker)
|
||||||
|
|
||||||
elif counts[tag] > 1:
|
elif counts[tag] > 1:
|
||||||
|
|
@ -231,14 +246,8 @@ class XmlHandler(BaseHandler):
|
||||||
|
|
||||||
walk(root, ())
|
walk(root, ())
|
||||||
|
|
||||||
# Internal marker prefixes used by JinjaTurtle's own comment nodes. These
|
|
||||||
# must NOT be escaped (they are converted into real Jinja control structures
|
|
||||||
# downstream). Source-file comments have none of these prefixes.
|
|
||||||
_MARKER_PREFIXES = ("LOOP:", "IF:", "ENDIF:")
|
|
||||||
|
|
||||||
def _is_jt_marker(self, comment_text: str) -> bool:
|
def _is_jt_marker(self, comment_text: str) -> bool:
|
||||||
stripped = (comment_text or "").lstrip()
|
return (comment_text or "").lstrip().startswith(self._marker_prefix)
|
||||||
return any(stripped.startswith(p) for p in self._MARKER_PREFIXES)
|
|
||||||
|
|
||||||
def _escape_source_comments(self, root: ET.Element) -> None:
|
def _escape_source_comments(self, root: ET.Element) -> None:
|
||||||
"""Escape template metacharacters in comments preserved from the source.
|
"""Escape template metacharacters in comments preserved from the source.
|
||||||
|
|
@ -252,8 +261,9 @@ class XmlHandler(BaseHandler):
|
||||||
placeholders, so comments (and the prolog, handled separately) are the
|
placeholders, so comments (and the prolog, handled separately) are the
|
||||||
only XML injection vector.
|
only XML injection vector.
|
||||||
|
|
||||||
JinjaTurtle's own internal marker comments are left untouched so they can
|
Only nonce-bearing marker comments generated during this process are left
|
||||||
be converted into real loops/conditionals later.
|
untouched. Source comments that look like old marker syntax (for
|
||||||
|
example <!--IF:...-->) are ordinary comments and remain inert.
|
||||||
"""
|
"""
|
||||||
# ET represents comments with a callable tag (ET.Comment). Iterate all
|
# ET represents comments with a callable tag (ET.Comment). Iterate all
|
||||||
# descendants and escape comment text that is not one of our markers.
|
# descendants and escape comment text that is not one of our markers.
|
||||||
|
|
@ -266,7 +276,12 @@ class XmlHandler(BaseHandler):
|
||||||
"""Generate scalar-only Jinja2 template."""
|
"""Generate scalar-only Jinja2 template."""
|
||||||
prolog, body = self._split_xml_prolog(text)
|
prolog, body = self._split_xml_prolog(text)
|
||||||
|
|
||||||
parser = ET.XMLParser(target=ET.TreeBuilder(insert_comments=True)) # nosec B314
|
parser = DET.XMLParser(
|
||||||
|
target=ET.TreeBuilder(insert_comments=True),
|
||||||
|
forbid_dtd=True,
|
||||||
|
forbid_entities=True,
|
||||||
|
forbid_external=True,
|
||||||
|
)
|
||||||
parser.feed(body)
|
parser.feed(body)
|
||||||
root = parser.close()
|
root = parser.close()
|
||||||
|
|
||||||
|
|
@ -294,7 +309,12 @@ class XmlHandler(BaseHandler):
|
||||||
prolog, body = self._split_xml_prolog(text)
|
prolog, body = self._split_xml_prolog(text)
|
||||||
|
|
||||||
# Parse with comments preserved
|
# Parse with comments preserved
|
||||||
parser = ET.XMLParser(target=ET.TreeBuilder(insert_comments=True)) # nosec B314
|
parser = DET.XMLParser(
|
||||||
|
target=ET.TreeBuilder(insert_comments=True),
|
||||||
|
forbid_dtd=True,
|
||||||
|
forbid_entities=True,
|
||||||
|
forbid_external=True,
|
||||||
|
)
|
||||||
parser.feed(body)
|
parser.feed(body)
|
||||||
root = parser.close()
|
root = parser.close()
|
||||||
|
|
||||||
|
|
@ -334,12 +354,15 @@ class XmlHandler(BaseHandler):
|
||||||
# Build a sample element for each loop to use as template
|
# Build a sample element for each loop to use as template
|
||||||
lines = xml_str.split("\n")
|
lines = xml_str.split("\n")
|
||||||
result_lines = []
|
result_lines = []
|
||||||
|
loop_marker = f"<!--{self._marker_prefix}LOOP:"
|
||||||
|
if_marker = f"<!--{self._marker_prefix}IF:"
|
||||||
|
endif_marker = f"<!--{self._marker_prefix}ENDIF:"
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
# Check if this line contains a loop marker
|
# Check if this line contains a nonce-bearing loop marker
|
||||||
if "<!--LOOP:" in line:
|
if loop_marker in line:
|
||||||
# Extract tag name from marker
|
# Extract tag name from marker
|
||||||
start = line.find("<!--LOOP:") + 9
|
start = line.find(loop_marker) + len(loop_marker)
|
||||||
end = line.find("-->", start)
|
end = line.find("-->", start)
|
||||||
tag_name = line[start:end].strip()
|
tag_name = line[start:end].strip()
|
||||||
|
|
||||||
|
|
@ -365,7 +388,7 @@ class XmlHandler(BaseHandler):
|
||||||
merged_dict = self._merge_dicts_for_template(candidate.items)
|
merged_dict = self._merge_dicts_for_template(candidate.items)
|
||||||
|
|
||||||
sample_elem = self._dict_to_xml_element(
|
sample_elem = self._dict_to_xml_element(
|
||||||
tag_name, merged_dict, item_var
|
tag_name, merged_dict, item_var, candidate
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply indentation to the sample element
|
# Apply indentation to the sample element
|
||||||
|
|
@ -393,18 +416,16 @@ class XmlHandler(BaseHandler):
|
||||||
else:
|
else:
|
||||||
result_lines.append(line)
|
result_lines.append(line)
|
||||||
|
|
||||||
# Post-process to replace <!--IF:...--> and <!--ENDIF:...--> with Jinja2 conditionals
|
# Post-process nonce-bearing IF/ENDIF markers into Jinja2 conditionals.
|
||||||
final_lines = []
|
final_lines = []
|
||||||
for line in result_lines:
|
for line in result_lines:
|
||||||
# Replace <!--IF:var.field--> with {% if var.field is defined %}
|
if if_marker in line:
|
||||||
if "<!--IF:" in line:
|
start = line.find(if_marker) + len(if_marker)
|
||||||
start = line.find("<!--IF:") + 7
|
|
||||||
end = line.find("-->", start)
|
end = line.find("-->", start)
|
||||||
condition = line[start:end]
|
condition = line[start:end]
|
||||||
indent = len(line) - len(line.lstrip())
|
indent = len(line) - len(line.lstrip())
|
||||||
final_lines.append(f"{' ' * indent}{j2.if_defined(condition)}")
|
final_lines.append(f"{' ' * indent}{j2.if_defined(condition)}")
|
||||||
# Replace <!--ENDIF:field--> with {% endif %}
|
elif endif_marker in line:
|
||||||
elif "<!--ENDIF:" in line:
|
|
||||||
indent = len(line) - len(line.lstrip())
|
indent = len(line) - len(line.lstrip())
|
||||||
final_lines.append(f"{' ' * indent}{j2.endif()}")
|
final_lines.append(f"{' ' * indent}{j2.endif()}")
|
||||||
else:
|
else:
|
||||||
|
|
@ -436,7 +457,11 @@ class XmlHandler(BaseHandler):
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
def _dict_to_xml_element(
|
def _dict_to_xml_element(
|
||||||
self, tag: str, data: dict[str, Any], loop_var: str
|
self,
|
||||||
|
tag: str,
|
||||||
|
data: dict[str, Any],
|
||||||
|
loop_var: str,
|
||||||
|
candidate: LoopCandidate,
|
||||||
) -> ET.Element:
|
) -> ET.Element:
|
||||||
"""
|
"""
|
||||||
Convert a dict to an XML element with Jinja2 variable references.
|
Convert a dict to an XML element with Jinja2 variable references.
|
||||||
|
|
@ -458,13 +483,13 @@ class XmlHandler(BaseHandler):
|
||||||
# Attribute - these come from element attributes
|
# Attribute - these come from element attributes
|
||||||
attr_name = key[1:] # Remove @ prefix
|
attr_name = key[1:] # Remove @ prefix
|
||||||
# Use simple variable reference - attributes should always exist
|
# Use simple variable reference - attributes should always exist
|
||||||
elem.set(attr_name, j2.variable(f"{loop_var}.{attr_name}"))
|
elem.set(attr_name, j2.variable(candidate.value_expr(loop_var, key)))
|
||||||
elif key == "_text":
|
elif key == "_text":
|
||||||
# Simple text content - use ._text accessor for dict-based items
|
# Simple text content - use ._text accessor for dict-based items
|
||||||
elem.text = j2.variable(f"{loop_var}._text")
|
elem.text = j2.variable(candidate.value_expr(loop_var, "_text"))
|
||||||
elif key == "value":
|
elif key == "value":
|
||||||
# Text with attributes/children
|
# Text with attributes/children
|
||||||
elem.text = j2.variable(f"{loop_var}.value")
|
elem.text = j2.variable(candidate.value_expr(loop_var, "value"))
|
||||||
elif key == "_key":
|
elif key == "_key":
|
||||||
# This is the dict key (for dict collections), skip in XML
|
# This is the dict key (for dict collections), skip in XML
|
||||||
pass
|
pass
|
||||||
|
|
@ -473,29 +498,37 @@ class XmlHandler(BaseHandler):
|
||||||
# Create a conditional wrapper comment
|
# Create a conditional wrapper comment
|
||||||
child = ET.Element(key)
|
child = ET.Element(key)
|
||||||
if "_text" in value:
|
if "_text" in value:
|
||||||
child.text = j2.variable(f"{loop_var}.{key}._text")
|
child.text = j2.variable(
|
||||||
|
candidate.value_expr(loop_var, str(key), "_text")
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# More complex nested structure
|
# More complex nested structure
|
||||||
for sub_key, sub_val in value.items():
|
for sub_key, sub_val in value.items():
|
||||||
if not sub_key.startswith("_"):
|
if not sub_key.startswith("_"):
|
||||||
grandchild = ET.SubElement(child, sub_key)
|
grandchild = ET.SubElement(child, sub_key)
|
||||||
grandchild.text = j2.variable(f"{loop_var}.{key}.{sub_key}")
|
grandchild.text = j2.variable(
|
||||||
|
candidate.value_expr(loop_var, str(key), str(sub_key))
|
||||||
|
)
|
||||||
|
|
||||||
# Wrap the child in a Jinja if statement (will be done via text replacement)
|
# Wrap the child in a Jinja if statement (will be done via text replacement)
|
||||||
# For now, add a marker comment before the element
|
# For now, add a marker comment before the element
|
||||||
marker = ET.Comment(f"IF:{loop_var}.{key}")
|
marker = ET.Comment(
|
||||||
|
self._marker("IF", candidate.value_expr(loop_var, str(key)))
|
||||||
|
)
|
||||||
elem.append(marker)
|
elem.append(marker)
|
||||||
elem.append(child)
|
elem.append(child)
|
||||||
end_marker = ET.Comment(f"ENDIF:{key}")
|
end_marker = ET.Comment(self._marker("ENDIF", key))
|
||||||
elem.append(end_marker)
|
elem.append(end_marker)
|
||||||
|
|
||||||
elif not isinstance(value, list):
|
elif not isinstance(value, list):
|
||||||
# Simple child element (scalar value) - also wrap in conditional
|
# Simple child element (scalar value) - also wrap in conditional
|
||||||
marker = ET.Comment(f"IF:{loop_var}.{key}")
|
marker = ET.Comment(
|
||||||
|
self._marker("IF", candidate.value_expr(loop_var, str(key)))
|
||||||
|
)
|
||||||
elem.append(marker)
|
elem.append(marker)
|
||||||
child = ET.SubElement(elem, key)
|
child = ET.SubElement(elem, key)
|
||||||
child.text = j2.variable(f"{loop_var}.{key}")
|
child.text = j2.variable(candidate.value_expr(loop_var, str(key)))
|
||||||
end_marker = ET.Comment(f"ENDIF:{key}")
|
end_marker = ET.Comment(self._marker("ENDIF", key))
|
||||||
elem.append(end_marker)
|
elem.append(end_marker)
|
||||||
|
|
||||||
return elem
|
return elem
|
||||||
|
|
|
||||||
|
|
@ -500,7 +500,7 @@ class YamlHandler(DictLikeHandler):
|
||||||
item_lines.append(f"{item_indent_str}- {value_expr}")
|
item_lines.append(f"{item_indent_str}- {value_expr}")
|
||||||
elif candidate.item_schema in ("simple_dict", "nested"):
|
elif candidate.item_schema in ("simple_dict", "nested"):
|
||||||
item_lines = self._dict_to_yaml_lines(
|
item_lines = self._dict_to_yaml_lines(
|
||||||
sample_item, item_var, item_indent, is_list_item=True
|
sample_item, item_var, item_indent, candidate, is_list_item=True
|
||||||
)
|
)
|
||||||
|
|
||||||
if item_lines:
|
if item_lines:
|
||||||
|
|
@ -523,6 +523,7 @@ class YamlHandler(DictLikeHandler):
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
loop_var: str,
|
loop_var: str,
|
||||||
indent: int,
|
indent: int,
|
||||||
|
candidate: LoopCandidate,
|
||||||
is_list_item: bool = False,
|
is_list_item: bool = False,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -549,7 +550,9 @@ class YamlHandler(DictLikeHandler):
|
||||||
|
|
||||||
if first_key and is_list_item:
|
if first_key and is_list_item:
|
||||||
# First key gets the list marker
|
# First key gets the list marker
|
||||||
value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value)
|
value_expr = self._yaml_value_expr(
|
||||||
|
candidate.value_expr(loop_var, str(key)), value
|
||||||
|
)
|
||||||
lines.append(
|
lines.append(
|
||||||
f"{indent_str}- {escape_jinja_literal(str(key))}: {value_expr}"
|
f"{indent_str}- {escape_jinja_literal(str(key))}: {value_expr}"
|
||||||
)
|
)
|
||||||
|
|
@ -557,7 +560,9 @@ class YamlHandler(DictLikeHandler):
|
||||||
else:
|
else:
|
||||||
# Subsequent keys are indented
|
# Subsequent keys are indented
|
||||||
sub_indent = indent + 2 if is_list_item else indent
|
sub_indent = indent + 2 if is_list_item else indent
|
||||||
value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value)
|
value_expr = self._yaml_value_expr(
|
||||||
|
candidate.value_expr(loop_var, str(key)), value
|
||||||
|
)
|
||||||
lines.append(
|
lines.append(
|
||||||
f"{' ' * sub_indent}{escape_jinja_literal(str(key))}: {value_expr}"
|
f"{' ' * sub_indent}{escape_jinja_literal(str(key))}: {value_expr}"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,28 @@ def to_json(
|
||||||
return filtered(value, f"to_json({', '.join(args)})")
|
return filtered(value, f"to_json({', '.join(args)})")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_field_name(raw: str) -> str:
|
||||||
|
"""Return a safe generated identifier for a loop-item field name.
|
||||||
|
|
||||||
|
Source-derived keys must never be emitted as Jinja expression syntax.
|
||||||
|
Loop candidates therefore remap every source key to a generated field name
|
||||||
|
beginning with ``jt_`` plus a short hash. The prefix avoids collisions with
|
||||||
|
dictionary methods such as ``items``/``keys`` and with dunder attributes.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
text = re.sub(r"[^A-Za-z0-9_]+", "_", str(raw or "field")).strip("_").lower()
|
||||||
|
text = re.sub(r"_+", "_", text) or "field"
|
||||||
|
digest = hashlib.sha256(str(raw).encode("utf-8", "surrogatepass")).hexdigest()[:8]
|
||||||
|
return f"jt_{text}_{digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def loop_field_expr(loop_var: str, safe_field: str) -> str:
|
||||||
|
"""Return a Jinja expression for a generated loop-item field."""
|
||||||
|
return f"{loop_var}.{safe_field}"
|
||||||
|
|
||||||
|
|
||||||
def statement(
|
def statement(
|
||||||
value: str,
|
value: str,
|
||||||
*,
|
*,
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,11 @@ instead of flattened scalar variables.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
import re
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from . import j2
|
||||||
|
|
||||||
|
|
||||||
class LoopCandidate:
|
class LoopCandidate:
|
||||||
"""
|
"""
|
||||||
|
|
@ -36,6 +39,47 @@ class LoopCandidate:
|
||||||
self.items = items
|
self.items = items
|
||||||
self.item_schema = item_schema
|
self.item_schema = item_schema
|
||||||
self.confidence = confidence
|
self.confidence = confidence
|
||||||
|
self._safe_key_cache: dict[str, str] = {}
|
||||||
|
|
||||||
|
def safe_field_key(self, source_key: str) -> str:
|
||||||
|
"""Return a generated, non-source-derived field name for loop data.
|
||||||
|
|
||||||
|
The rendered template prints the original source key literally, but it
|
||||||
|
reads the corresponding value from a generated field name in defaults.
|
||||||
|
This prevents a malicious source key from becoming part of a Jinja/ERB
|
||||||
|
expression (for example ``item.__class__.__mro__``).
|
||||||
|
"""
|
||||||
|
key = str(source_key)
|
||||||
|
if key not in self._safe_key_cache:
|
||||||
|
self._safe_key_cache[key] = j2.safe_field_name(key)
|
||||||
|
return self._safe_key_cache[key]
|
||||||
|
|
||||||
|
def value_expr(self, loop_var: str, *source_path: str) -> str:
|
||||||
|
"""Return a safe Jinja expression for a field inside a loop item."""
|
||||||
|
expr = loop_var
|
||||||
|
for key in source_path:
|
||||||
|
expr = j2.loop_field_expr(expr, self.safe_field_key(str(key)))
|
||||||
|
return expr
|
||||||
|
|
||||||
|
def safe_items(self) -> list[Any] | dict[str, Any]:
|
||||||
|
"""Return loop items with all dict keys remapped to generated names."""
|
||||||
|
|
||||||
|
def _convert(obj: Any) -> Any:
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
converted: dict[str, Any] = {}
|
||||||
|
for key, value in obj.items():
|
||||||
|
if key == "_key":
|
||||||
|
# Internal bookkeeping key for potential dict-collection
|
||||||
|
# loops. It is not emitted as a template field.
|
||||||
|
converted[key] = _convert(value)
|
||||||
|
else:
|
||||||
|
converted[self.safe_field_key(str(key))] = _convert(value)
|
||||||
|
return converted
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [_convert(v) for v in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
return _convert(self.items)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
path_str = ".".join(self.path) if self.path else "<root>"
|
path_str = ".".join(self.path) if self.path else "<root>"
|
||||||
|
|
@ -292,49 +336,43 @@ class LoopAnalyzer:
|
||||||
else:
|
else:
|
||||||
return "homogeneous"
|
return "homogeneous"
|
||||||
|
|
||||||
def _derive_loop_var(self, path: tuple[str, ...], singular: bool = True) -> str:
|
def _safe_loop_identifier(self, raw: str, *, fallback: str = "item") -> str:
|
||||||
"""
|
text = re.sub(r"[^A-Za-z0-9_]+", "_", str(raw or "").strip())
|
||||||
Derive a sensible loop variable name from the path.
|
text = re.sub(r"_+", "_", text).strip("_").lower()
|
||||||
|
if not text:
|
||||||
|
text = fallback
|
||||||
|
if not re.match(r"^[a-z_]", text):
|
||||||
|
text = f"{fallback}_{text}"
|
||||||
|
return text
|
||||||
|
|
||||||
Examples:
|
def _derive_loop_var(self, path: tuple[str, ...], singular: bool = True) -> str:
|
||||||
("servers",) -> "server" (singular)
|
"""Derive a safe Jinja identifier for loop items from a source path."""
|
||||||
("config", "endpoints") -> "endpoint"
|
|
||||||
("users",) -> "user"
|
|
||||||
("databases",) -> "database"
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not path:
|
if not path:
|
||||||
return "item"
|
return "item"
|
||||||
|
|
||||||
last_part = path[-1].lower()
|
last_part = self._safe_loop_identifier(path[-1], fallback="item")
|
||||||
|
|
||||||
if singular:
|
if singular:
|
||||||
# Simple English pluralization rules (order matters - most specific first)
|
# Simple English pluralization rules (order matters - most specific first)
|
||||||
if last_part.endswith("sses"):
|
if last_part.endswith("sses"):
|
||||||
return last_part[:-2] # "classes" -> "class"
|
last_part = last_part[:-2] # "classes" -> "class"
|
||||||
elif last_part.endswith("xes"):
|
elif last_part.endswith("xes"):
|
||||||
return last_part[:-2] # "boxes" -> "box"
|
last_part = last_part[:-2] # "boxes" -> "box"
|
||||||
elif last_part.endswith("ches"):
|
elif last_part.endswith("ches"):
|
||||||
return last_part[:-2] # "watches" -> "watch"
|
last_part = last_part[:-2] # "watches" -> "watch"
|
||||||
elif last_part.endswith("shes"):
|
elif last_part.endswith("shes"):
|
||||||
return last_part[:-2] # "dishes" -> "dish"
|
last_part = last_part[:-2] # "dishes" -> "dish"
|
||||||
elif last_part.endswith("ies"):
|
elif last_part.endswith("ies"):
|
||||||
return last_part[:-3] + "y" # "entries" -> "entry"
|
last_part = last_part[:-3] + "y" # "entries" -> "entry"
|
||||||
elif last_part.endswith("oes"):
|
elif last_part.endswith("oes"):
|
||||||
return last_part[:-2] # "tomatoes" -> "tomato"
|
last_part = last_part[:-2] # "tomatoes" -> "tomato"
|
||||||
elif last_part.endswith("ses") and not last_part.endswith("sses"):
|
elif last_part.endswith("ses") and not last_part.endswith("sses"):
|
||||||
# Only for words ending in "se": "databases" -> "database"
|
last_part = last_part[:-1]
|
||||||
# But NOT for "sses" which we already handled
|
|
||||||
if len(last_part) > 3 and last_part[-4] not in "aeiou":
|
|
||||||
# "databases" -> "database" (consonant before 's')
|
|
||||||
return last_part[:-1]
|
|
||||||
else:
|
|
||||||
# "houses" -> "house", "causes" -> "cause"
|
|
||||||
return last_part[:-1]
|
|
||||||
elif last_part.endswith("s") and not last_part.endswith("ss"):
|
elif last_part.endswith("s") and not last_part.endswith("ss"):
|
||||||
return last_part[:-1] # "servers" -> "server"
|
last_part = last_part[:-1] # "servers" -> "server"
|
||||||
|
|
||||||
return last_part
|
return self._safe_loop_identifier(last_part, fallback="item")
|
||||||
|
|
||||||
def _analyze_xml(self, root: Any) -> None:
|
def _analyze_xml(self, root: Any) -> None:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,19 @@ import configparser
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
import xml.etree.ElementTree as ET # nosec
|
import xml.etree.ElementTree as ET # nosec B405 - not used for untrusted XML parsing; all parsing uses defusedxml.
|
||||||
|
|
||||||
from . import j2
|
from . import j2
|
||||||
from .core import dump_yaml, flatten_config, make_var_name, parse_config
|
from .escape import escape_jinja_literal
|
||||||
|
from .core import (
|
||||||
|
dump_yaml,
|
||||||
|
flatten_config,
|
||||||
|
make_var_name,
|
||||||
|
mark_ansible_unsafe,
|
||||||
|
parse_config,
|
||||||
|
)
|
||||||
from .handlers.xml import XmlHandler
|
from .handlers.xml import XmlHandler
|
||||||
|
from .template_safety import validate_generated_template
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED_SUFFIXES: dict[str, set[str]] = {
|
SUPPORTED_SUFFIXES: dict[str, set[str]] = {
|
||||||
|
|
@ -43,7 +51,10 @@ SUPPORTED_SUFFIXES: dict[str, set[str]] = {
|
||||||
|
|
||||||
|
|
||||||
def is_supported_file(path: Path) -> bool:
|
def is_supported_file(path: Path) -> bool:
|
||||||
if not path.is_file():
|
# Do not follow symlinks by default. Directory mode may be pointed at an
|
||||||
|
# attacker-controlled tree; following a supported-extension symlink would let
|
||||||
|
# that tree cause reads outside the requested root.
|
||||||
|
if path.is_symlink() or not path.is_file():
|
||||||
return False
|
return False
|
||||||
suffix = path.suffix.lower()
|
suffix = path.suffix.lower()
|
||||||
for exts in SUPPORTED_SUFFIXES.values():
|
for exts in SUPPORTED_SUFFIXES.values():
|
||||||
|
|
@ -60,8 +71,18 @@ def iter_supported_files(root: Path, recursive: bool) -> list[Path]:
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
resolved_root = root.resolve()
|
||||||
it = root.rglob("*") if recursive else root.glob("*")
|
it = root.rglob("*") if recursive else root.glob("*")
|
||||||
files = [p for p in it if is_supported_file(p)]
|
files = []
|
||||||
|
for p in it:
|
||||||
|
if not is_supported_file(p):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if not p.resolve().is_relative_to(resolved_root):
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
files.append(p)
|
||||||
files.sort()
|
files.sort()
|
||||||
return files
|
return files
|
||||||
|
|
||||||
|
|
@ -75,6 +96,16 @@ def _is_scalar(obj: Any) -> bool:
|
||||||
return not isinstance(obj, (dict, list))
|
return not isinstance(obj, (dict, list))
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_literal(text: object) -> str:
|
||||||
|
"""Escape source-derived literal text before placing it in templates."""
|
||||||
|
return escape_jinja_literal(str(text))
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_role_prefix(role_prefix: str) -> str:
|
||||||
|
# Reuse make_var_name's strict identifier handling for list variable names.
|
||||||
|
return make_var_name(role_prefix, ())
|
||||||
|
|
||||||
|
|
||||||
def _merge_union(a: Any, b: Any) -> Any:
|
def _merge_union(a: Any, b: Any) -> Any:
|
||||||
"""Merge two parsed objects into a union structure.
|
"""Merge two parsed objects into a union structure.
|
||||||
|
|
||||||
|
|
@ -170,13 +201,13 @@ def _yaml_render_union(
|
||||||
value = _yaml_scalar_placeholder(role_prefix, key_path, val)
|
value = _yaml_scalar_placeholder(role_prefix, key_path, val)
|
||||||
if cond_var:
|
if cond_var:
|
||||||
lines.append(f"{ind}{j2.if_defined(cond_var)}")
|
lines.append(f"{ind}{j2.if_defined(cond_var)}")
|
||||||
lines.append(f"{ind}{key}: {value}")
|
lines.append(f"{ind}{_safe_literal(key)}: {value}")
|
||||||
if cond_var:
|
if cond_var:
|
||||||
lines.append(f"{ind}{j2.endif()}")
|
lines.append(f"{ind}{j2.endif()}")
|
||||||
else:
|
else:
|
||||||
if cond_var:
|
if cond_var:
|
||||||
lines.append(f"{ind}{j2.if_defined(cond_var)}")
|
lines.append(f"{ind}{j2.if_defined(cond_var)}")
|
||||||
lines.append(f"{ind}{key}:")
|
lines.append(f"{ind}{_safe_literal(key)}:")
|
||||||
lines.extend(
|
lines.extend(
|
||||||
_yaml_render_union(
|
_yaml_render_union(
|
||||||
role_prefix,
|
role_prefix,
|
||||||
|
|
@ -224,14 +255,14 @@ def _yaml_render_union(
|
||||||
if first:
|
if first:
|
||||||
if k_cond:
|
if k_cond:
|
||||||
lines.append(f"{ind}{j2.if_defined(k_cond)}")
|
lines.append(f"{ind}{j2.if_defined(k_cond)}")
|
||||||
lines.append(f"{ind}- {k}: {value}")
|
lines.append(f"{ind}- {_safe_literal(k)}: {value}")
|
||||||
if k_cond:
|
if k_cond:
|
||||||
lines.append(f"{ind}{j2.endif()}")
|
lines.append(f"{ind}{j2.endif()}")
|
||||||
first = False
|
first = False
|
||||||
else:
|
else:
|
||||||
if k_cond:
|
if k_cond:
|
||||||
lines.append(f"{ind} {j2.if_defined(k_cond)}")
|
lines.append(f"{ind} {j2.if_defined(k_cond)}")
|
||||||
lines.append(f"{ind} {k}: {value}")
|
lines.append(f"{ind} {_safe_literal(k)}: {value}")
|
||||||
if k_cond:
|
if k_cond:
|
||||||
lines.append(f"{ind} {j2.endif()}")
|
lines.append(f"{ind} {j2.endif()}")
|
||||||
else:
|
else:
|
||||||
|
|
@ -239,7 +270,7 @@ def _yaml_render_union(
|
||||||
if first:
|
if first:
|
||||||
if k_cond:
|
if k_cond:
|
||||||
lines.append(f"{ind}{j2.if_defined(k_cond)}")
|
lines.append(f"{ind}{j2.if_defined(k_cond)}")
|
||||||
lines.append(f"{ind}- {k}:")
|
lines.append(f"{ind}- {_safe_literal(k)}:")
|
||||||
lines.extend(
|
lines.extend(
|
||||||
_yaml_render_union(
|
_yaml_render_union(
|
||||||
role_prefix,
|
role_prefix,
|
||||||
|
|
@ -255,7 +286,7 @@ def _yaml_render_union(
|
||||||
else:
|
else:
|
||||||
if k_cond:
|
if k_cond:
|
||||||
lines.append(f"{ind} {j2.if_defined(k_cond)}")
|
lines.append(f"{ind} {j2.if_defined(k_cond)}")
|
||||||
lines.append(f"{ind} {k}:")
|
lines.append(f"{ind} {_safe_literal(k)}:")
|
||||||
lines.extend(
|
lines.extend(
|
||||||
_yaml_render_union(
|
_yaml_render_union(
|
||||||
role_prefix,
|
role_prefix,
|
||||||
|
|
@ -309,11 +340,11 @@ def _toml_render_union(
|
||||||
if cond:
|
if cond:
|
||||||
lines.append(f"{j2.if_defined(cond)}")
|
lines.append(f"{j2.if_defined(cond)}")
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
lines.append(f"{key} = {j2.quoted_variable(var_name)}")
|
lines.append(f"{_safe_literal(key)} = {j2.quoted_variable(var_name)}")
|
||||||
elif isinstance(value, bool):
|
elif isinstance(value, bool):
|
||||||
lines.append(f"{key} = {j2.lower(var_name)}")
|
lines.append(f"{_safe_literal(key)} = {j2.lower(var_name)}")
|
||||||
else:
|
else:
|
||||||
lines.append(f"{key} = {j2.variable(var_name)}")
|
lines.append(f"{_safe_literal(key)} = {j2.variable(var_name)}")
|
||||||
if cond:
|
if cond:
|
||||||
lines.append(j2.endif())
|
lines.append(j2.endif())
|
||||||
|
|
||||||
|
|
@ -326,7 +357,7 @@ def _toml_render_union(
|
||||||
)
|
)
|
||||||
if cond:
|
if cond:
|
||||||
lines.append(f"{j2.if_defined(cond)}")
|
lines.append(f"{j2.if_defined(cond)}")
|
||||||
lines.append(f"[{'.'.join(path)}]")
|
lines.append(f"[{'.'.join(_safe_literal(p) for p in path)}]")
|
||||||
|
|
||||||
scalar_items = {k: v for k, v in obj.items() if not isinstance(v, dict)}
|
scalar_items = {k: v for k, v in obj.items() if not isinstance(v, dict)}
|
||||||
nested_items = {k: v for k, v in obj.items() if isinstance(v, dict)}
|
nested_items = {k: v for k, v in obj.items() if isinstance(v, dict)}
|
||||||
|
|
@ -412,7 +443,7 @@ def _ini_render_union(
|
||||||
)
|
)
|
||||||
if sec_cond:
|
if sec_cond:
|
||||||
lines.append(f"{j2.if_defined(sec_cond)}")
|
lines.append(f"{j2.if_defined(sec_cond)}")
|
||||||
lines.append(f"[{section}]")
|
lines.append(f"[{_safe_literal(section)}]")
|
||||||
for key, raw_val in union.items(section, raw=True):
|
for key, raw_val in union.items(section, raw=True):
|
||||||
path = (section, key)
|
path = (section, key)
|
||||||
var = make_var_name(role_prefix, path)
|
var = make_var_name(role_prefix, path)
|
||||||
|
|
@ -424,9 +455,9 @@ def _ini_render_union(
|
||||||
if key_cond:
|
if key_cond:
|
||||||
lines.append(f"{j2.if_defined(key_cond)}")
|
lines.append(f"{j2.if_defined(key_cond)}")
|
||||||
if quoted:
|
if quoted:
|
||||||
lines.append(f"{key} = {j2.quoted_variable(var)}")
|
lines.append(f"{_safe_literal(key)} = {j2.quoted_variable(var)}")
|
||||||
else:
|
else:
|
||||||
lines.append(f"{key} = {j2.variable(var)}")
|
lines.append(f"{_safe_literal(key)} = {j2.variable(var)}")
|
||||||
if key_cond:
|
if key_cond:
|
||||||
lines.append(j2.endif())
|
lines.append(j2.endif())
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
@ -549,9 +580,9 @@ def _xml_apply_jinja_union(
|
||||||
|
|
||||||
if child_path in optional_elements:
|
if child_path in optional_elements:
|
||||||
cond = defined_var_name(role_prefix, child_path)
|
cond = defined_var_name(role_prefix, child_path)
|
||||||
new_children.append(ET.Comment(f"IF:{cond}"))
|
new_children.append(ET.Comment(handler._marker("IF", cond)))
|
||||||
new_children.append(child)
|
new_children.append(child)
|
||||||
new_children.append(ET.Comment(f"ENDIF:{cond}"))
|
new_children.append(ET.Comment(handler._marker("ENDIF", cond)))
|
||||||
else:
|
else:
|
||||||
new_children.append(child)
|
new_children.append(child)
|
||||||
|
|
||||||
|
|
@ -621,9 +652,9 @@ def process_directory(
|
||||||
# JSON: simplest robust union template
|
# JSON: simplest robust union template
|
||||||
if fmt == "json":
|
if fmt == "json":
|
||||||
list_var = (
|
list_var = (
|
||||||
f"{role_prefix}_{fmt}_items"
|
f"{_safe_role_prefix(role_prefix)}_{fmt}_items"
|
||||||
if multiple_formats
|
if multiple_formats
|
||||||
else f"{role_prefix}_items"
|
else f"{_safe_role_prefix(role_prefix)}_items"
|
||||||
)
|
)
|
||||||
template = f"{j2.to_json('data', indent=2)}\n"
|
template = f"{j2.to_json('data', indent=2)}\n"
|
||||||
items: list[dict[str, Any]] = []
|
items: list[dict[str, Any]] = []
|
||||||
|
|
@ -652,9 +683,9 @@ def process_directory(
|
||||||
optional_containers = cont_union - cont_inter
|
optional_containers = cont_union - cont_inter
|
||||||
|
|
||||||
list_var = (
|
list_var = (
|
||||||
f"{role_prefix}_{fmt}_items"
|
f"{_safe_role_prefix(role_prefix)}_{fmt}_items"
|
||||||
if multiple_formats
|
if multiple_formats
|
||||||
else f"{role_prefix}_items"
|
else f"{_safe_role_prefix(role_prefix)}_items"
|
||||||
)
|
)
|
||||||
|
|
||||||
if fmt == "yaml":
|
if fmt == "yaml":
|
||||||
|
|
@ -693,9 +724,9 @@ def process_directory(
|
||||||
union, opt_sections, opt_keys = _ini_union_and_presence(parsers) # type: ignore[arg-type]
|
union, opt_sections, opt_keys = _ini_union_and_presence(parsers) # type: ignore[arg-type]
|
||||||
|
|
||||||
list_var = (
|
list_var = (
|
||||||
f"{role_prefix}_{fmt}_items"
|
f"{_safe_role_prefix(role_prefix)}_{fmt}_items"
|
||||||
if multiple_formats
|
if multiple_formats
|
||||||
else f"{role_prefix}_items"
|
else f"{_safe_role_prefix(role_prefix)}_items"
|
||||||
)
|
)
|
||||||
template = _ini_render_union(role_prefix, union, opt_sections, opt_keys)
|
template = _ini_render_union(role_prefix, union, opt_sections, opt_keys)
|
||||||
|
|
||||||
|
|
@ -737,9 +768,9 @@ def process_directory(
|
||||||
optional_elements = (elem_union - elem_inter) - {()} # never wrap root
|
optional_elements = (elem_union - elem_inter) - {()} # never wrap root
|
||||||
|
|
||||||
list_var = (
|
list_var = (
|
||||||
f"{role_prefix}_{fmt}_items"
|
f"{_safe_role_prefix(role_prefix)}_{fmt}_items"
|
||||||
if multiple_formats
|
if multiple_formats
|
||||||
else f"{role_prefix}_items"
|
else f"{_safe_role_prefix(role_prefix)}_items"
|
||||||
)
|
)
|
||||||
template = _xml_apply_jinja_union(
|
template = _xml_apply_jinja_union(
|
||||||
role_prefix, union_root, optional_elements
|
role_prefix, union_root, optional_elements
|
||||||
|
|
@ -763,10 +794,13 @@ def process_directory(
|
||||||
|
|
||||||
raise ValueError(f"Unsupported format in folder mode: {fmt}")
|
raise ValueError(f"Unsupported format in folder mode: {fmt}")
|
||||||
|
|
||||||
|
for out in outputs:
|
||||||
|
validate_generated_template(out.template)
|
||||||
|
|
||||||
# Build combined defaults YAML
|
# Build combined defaults YAML
|
||||||
defaults_doc: dict[str, Any] = {}
|
defaults_doc: dict[str, Any] = {}
|
||||||
for out in outputs:
|
for out in outputs:
|
||||||
defaults_doc[out.list_var] = out.items
|
defaults_doc[out.list_var] = mark_ansible_unsafe(out.items)
|
||||||
defaults_yaml = dump_yaml(defaults_doc, sort_keys=True)
|
defaults_yaml = dump_yaml(defaults_doc, sort_keys=True)
|
||||||
|
|
||||||
return defaults_yaml, outputs
|
return defaults_yaml, outputs
|
||||||
|
|
|
||||||
111
src/jinjaturtle/template_safety.py
Normal file
111
src/jinjaturtle/template_safety.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""Safety validator for generated Jinja2 templates.
|
||||||
|
|
||||||
|
JinjaTurtle's threat model requires source-derived config text to remain data,
|
||||||
|
not executable template syntax. Escaping source literals and remapping loop
|
||||||
|
field names closes known injection paths, but this module adds a defence in
|
||||||
|
depth: every generated Jinja template is parsed and checked against the small
|
||||||
|
Jinja subset JinjaTurtle is supposed to emit.
|
||||||
|
|
||||||
|
If a future handler accidentally interpolates a source key into an expression
|
||||||
|
again, e.g. ``{{ item.__class__.__mro__[1]... }}``, this validator rejects the
|
||||||
|
output before it is returned or translated to ERB.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from jinja2 import Environment, TemplateSyntaxError, nodes
|
||||||
|
|
||||||
|
|
||||||
|
class UnsafeTemplateError(ValueError):
|
||||||
|
"""Raised when generated template syntax exceeds JinjaTurtle's safe subset."""
|
||||||
|
|
||||||
|
|
||||||
|
_ALLOWED_FILTERS = {"lower", "to_json", "tojson"}
|
||||||
|
_ALLOWED_TESTS = {"defined", "none"}
|
||||||
|
_SAFE_GENERATED_FIELD_RE = re.compile(r"^jt_[A-Za-z0-9_]+_[0-9a-f]{8}$")
|
||||||
|
|
||||||
|
# These node types cover the deliberately small subset emitted by j2.py:
|
||||||
|
# output expressions, for loops, if/endif, literal text, booleans/null ternaries,
|
||||||
|
# filters, and tests such as "is defined" / "is none".
|
||||||
|
_ALLOWED_NODE_TYPES = (
|
||||||
|
nodes.Template,
|
||||||
|
nodes.Output,
|
||||||
|
nodes.TemplateData,
|
||||||
|
nodes.Name,
|
||||||
|
nodes.Const,
|
||||||
|
nodes.Filter,
|
||||||
|
nodes.For,
|
||||||
|
nodes.If,
|
||||||
|
nodes.Not,
|
||||||
|
nodes.Getattr,
|
||||||
|
nodes.CondExpr,
|
||||||
|
nodes.Test,
|
||||||
|
nodes.Compare,
|
||||||
|
nodes.Operand,
|
||||||
|
nodes.Keyword,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _describe(node: nodes.Node) -> str:
|
||||||
|
line = getattr(node, "lineno", None)
|
||||||
|
suffix = f" on line {line}" if line is not None else ""
|
||||||
|
return f"{node.__class__.__name__}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_allowed_getattr(node: nodes.Getattr) -> bool:
|
||||||
|
"""Only allow loop.last and generated loop field names.
|
||||||
|
|
||||||
|
Arbitrary attribute access is the primitive that turned malicious source
|
||||||
|
keys into RCE (``item.__class__.__mro__...``). Loop item fields are now
|
||||||
|
remapped to generated names such as ``jt_name_82a3537f``; ordinary source
|
||||||
|
text cannot produce that syntax. ``loop.last`` is the single Jinja runtime
|
||||||
|
attribute JinjaTurtle intentionally emits.
|
||||||
|
"""
|
||||||
|
if isinstance(node.node, nodes.Name) and node.node.name == "loop":
|
||||||
|
return node.attr == "last"
|
||||||
|
return bool(_SAFE_GENERATED_FIELD_RE.match(node.attr))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_generated_template(template_text: str) -> None:
|
||||||
|
"""Raise :class:`UnsafeTemplateError` if *template_text* is unsafe."""
|
||||||
|
# autoescape=True is only to satisfy static analysis. This environment is
|
||||||
|
# used solely for AST parsing, never for rendering generated config templates.
|
||||||
|
env = Environment(autoescape=True)
|
||||||
|
try:
|
||||||
|
parsed = env.parse(template_text)
|
||||||
|
except TemplateSyntaxError as exc:
|
||||||
|
raise UnsafeTemplateError(
|
||||||
|
f"generated template is not valid Jinja2: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
for node in parsed.find_all(nodes.Node):
|
||||||
|
if isinstance(node, nodes.Call):
|
||||||
|
raise UnsafeTemplateError(
|
||||||
|
f"generated template contains a function/method call at {_describe(node)}"
|
||||||
|
)
|
||||||
|
if isinstance(node, nodes.Getitem):
|
||||||
|
raise UnsafeTemplateError(
|
||||||
|
f"generated template contains bracket lookup at {_describe(node)}; "
|
||||||
|
"loop fields must be remapped to generated identifiers"
|
||||||
|
)
|
||||||
|
if isinstance(node, nodes.Getattr) and not _is_allowed_getattr(node):
|
||||||
|
raise UnsafeTemplateError(
|
||||||
|
f"generated template contains unsafe attribute access .{node.attr!s} "
|
||||||
|
f"at {_describe(node)}"
|
||||||
|
)
|
||||||
|
if isinstance(node, nodes.Filter) and node.name not in _ALLOWED_FILTERS:
|
||||||
|
raise UnsafeTemplateError(
|
||||||
|
f"generated template contains unsupported filter {node.name!r} "
|
||||||
|
f"at {_describe(node)}"
|
||||||
|
)
|
||||||
|
if isinstance(node, nodes.Test) and node.name not in _ALLOWED_TESTS:
|
||||||
|
raise UnsafeTemplateError(
|
||||||
|
f"generated template contains unsupported test {node.name!r} "
|
||||||
|
f"at {_describe(node)}"
|
||||||
|
)
|
||||||
|
if not isinstance(node, _ALLOWED_NODE_TYPES):
|
||||||
|
raise UnsafeTemplateError(
|
||||||
|
f"generated template contains unsupported Jinja node {_describe(node)}"
|
||||||
|
)
|
||||||
|
|
@ -196,6 +196,16 @@ def test_escape_jinja_literal_actually_blocks_execution():
|
||||||
assert TRIP in fired
|
assert TRIP in fired
|
||||||
|
|
||||||
|
|
||||||
|
def test_escape_jinja_literal_wraps_erb_markup_for_translated_erb_safety():
|
||||||
|
payload = "<%= boom.run('erb-through-jinja') %>"
|
||||||
|
escaped = escape_jinja_literal(payload)
|
||||||
|
assert escaped != payload
|
||||||
|
env = jinja2.Environment(undefined=jinja2.ChainableUndefined)
|
||||||
|
rendered = env.from_string(escaped).render(boom=_Boom())
|
||||||
|
assert rendered == payload
|
||||||
|
assert TRIP not in rendered
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"payload",
|
"payload",
|
||||||
[
|
[
|
||||||
|
|
@ -234,3 +244,306 @@ def test_escape_literal_jinja_output_is_inert():
|
||||||
env = jinja2.Environment(undefined=jinja2.ChainableUndefined)
|
env = jinja2.Environment(undefined=jinja2.ChainableUndefined)
|
||||||
rendered = env.from_string(escaped).render(boom=_Boom())
|
rendered = env.from_string(escaped).render(boom=_Boom())
|
||||||
assert TRIP not in rendered
|
assert TRIP not in rendered
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_source_keys_are_escaped_and_do_not_execute(tmp_path):
|
||||||
|
body = '{"evil_{{ boom.run(\'json-key\') }}": "ok"}\n'
|
||||||
|
template_text, defaults = _run_jinjaturtle(tmp_path, "evil.json", body, "json")
|
||||||
|
rendered = _render_jinja(template_text, {**defaults, "boom": _Boom()})
|
||||||
|
assert TRIP not in rendered
|
||||||
|
assert "evil_{{ boom.run('json-key') }}" in rendered
|
||||||
|
|
||||||
|
|
||||||
|
def test_xml_source_comments_cannot_spoof_internal_markers(tmp_path):
|
||||||
|
body = (
|
||||||
|
"<root>\n"
|
||||||
|
" <!--IF:boom.run('xml-marker')-->\n"
|
||||||
|
" <item>ok</item>\n"
|
||||||
|
" <!--ENDIF:any-->\n"
|
||||||
|
"</root>\n"
|
||||||
|
)
|
||||||
|
template_text, defaults = _run_jinjaturtle(tmp_path, "evil.xml", body, "xml")
|
||||||
|
assert "{% if boom.run" not in template_text
|
||||||
|
rendered = _render_jinja(template_text, {**defaults, "boom": _Boom()})
|
||||||
|
assert TRIP not in rendered
|
||||||
|
assert "IF:boom.run('xml-marker')" in rendered
|
||||||
|
|
||||||
|
|
||||||
|
def test_folder_yaml_keys_are_escaped_and_do_not_execute(tmp_path):
|
||||||
|
from jinjaturtle.multi import process_directory
|
||||||
|
|
||||||
|
(tmp_path / "one.yaml").write_text(
|
||||||
|
'evil_{{ boom.run("folder-yaml") }}: ok\n', encoding="utf-8"
|
||||||
|
)
|
||||||
|
defaults_yaml, outputs = process_directory(
|
||||||
|
tmp_path, recursive=False, role_prefix="role"
|
||||||
|
)
|
||||||
|
defaults = pyyaml.safe_load(defaults_yaml) or {}
|
||||||
|
assert len(outputs) == 1
|
||||||
|
template_text = outputs[0].template
|
||||||
|
rendered = _render_jinja(
|
||||||
|
template_text, {**defaults["role_items"][0], "boom": _Boom()}
|
||||||
|
)
|
||||||
|
assert TRIP not in rendered
|
||||||
|
assert 'evil_{{ boom.run("folder-yaml") }}' in rendered
|
||||||
|
|
||||||
|
|
||||||
|
def test_erb_source_comment_payload_is_literal_not_executed(tmp_path):
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
marker = tmp_path / "erb-owned"
|
||||||
|
src = tmp_path / "evil.ini"
|
||||||
|
src.write_text(
|
||||||
|
"[s]\n" "good = ok\n" f"# <%= File.write({str(marker)!r}, 'owned') %>\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
tpl = tmp_path / "out.erb"
|
||||||
|
dfl = tmp_path / "hiera.yml"
|
||||||
|
res = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"jinjaturtle.cli",
|
||||||
|
str(src),
|
||||||
|
"-f",
|
||||||
|
"ini",
|
||||||
|
"--template-engine",
|
||||||
|
"erb",
|
||||||
|
"--role-name",
|
||||||
|
"role",
|
||||||
|
"-t",
|
||||||
|
str(tpl),
|
||||||
|
"-d",
|
||||||
|
str(dfl),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert res.returncode == 0, res.stderr
|
||||||
|
erb_text = tpl.read_text(encoding="utf-8")
|
||||||
|
live_chunks = re.findall(r"<%[-=#]?(.*?)-?%>", erb_text, re.S)
|
||||||
|
assert not any("File.write" in chunk for chunk in live_chunks)
|
||||||
|
|
||||||
|
ruby = shutil.which("ruby")
|
||||||
|
if ruby is None:
|
||||||
|
pytest.skip("ruby is not installed")
|
||||||
|
run = subprocess.run(
|
||||||
|
[
|
||||||
|
ruby,
|
||||||
|
"-rerb",
|
||||||
|
"-e",
|
||||||
|
"puts ERB.new(File.read(ARGV[0])).result(binding)",
|
||||||
|
str(tpl),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert run.returncode == 0, run.stderr
|
||||||
|
assert not marker.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ansible_defaults_tag_dangerous_strings_as_unsafe_and_still_load():
|
||||||
|
from jinjaturtle.core import generate_ansible_yaml
|
||||||
|
|
||||||
|
out = generate_ansible_yaml(
|
||||||
|
"role",
|
||||||
|
[
|
||||||
|
(("jinja",), "{{ boom.run('unsafe') }}"),
|
||||||
|
(("erb",), "<%= File.write('/tmp/nope', 'x') %>"),
|
||||||
|
(("plain",), "ordinary"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert "role_jinja: !unsafe" in out
|
||||||
|
assert "role_erb: !unsafe" in out
|
||||||
|
assert "role_plain: ordinary" in out
|
||||||
|
loaded = pyyaml.safe_load(out)
|
||||||
|
assert loaded["role_jinja"] == "{{ boom.run('unsafe') }}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_rejects_template_unsafe_role_name(tmp_path):
|
||||||
|
from jinjaturtle import cli
|
||||||
|
|
||||||
|
src = tmp_path / "a.ini"
|
||||||
|
src.write_text("[s]\nkey=value\n", encoding="utf-8")
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
cli._main([str(src), "--role-name", "bad-{{ role }}"])
|
||||||
|
assert exc.value.code == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_yaml_loop_source_key_cannot_become_jinja_expression(tmp_path):
|
||||||
|
from jinjaturtle.core import (
|
||||||
|
analyze_loops,
|
||||||
|
flatten_config,
|
||||||
|
generate_ansible_yaml,
|
||||||
|
generate_jinja2_template,
|
||||||
|
parse_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
malicious_key = (
|
||||||
|
"__class__.__mro__[1].__subclasses__()[166].__init__"
|
||||||
|
".__globals__['__builtins__']['__import__']('os')"
|
||||||
|
".popen('echo JT_RCE').read()"
|
||||||
|
)
|
||||||
|
src = tmp_path / "evil.yaml"
|
||||||
|
src.write_text(
|
||||||
|
"servers:\n" f" - {malicious_key!r}: a\n" f" - {malicious_key!r}: b\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
fmt, parsed = parse_config(src, "yaml")
|
||||||
|
loops = analyze_loops(fmt, parsed)
|
||||||
|
flat = flatten_config(fmt, parsed, loops)
|
||||||
|
template = generate_jinja2_template(fmt, parsed, "role", src.read_text(), loops)
|
||||||
|
defaults = pyyaml.safe_load(generate_ansible_yaml("role", flat, loops)) or {}
|
||||||
|
|
||||||
|
assert f"server.{malicious_key}" not in template
|
||||||
|
assert "server.jt_" in template
|
||||||
|
rendered = _render_jinja(template, defaults)
|
||||||
|
assert TRIP not in rendered
|
||||||
|
assert "JT_RCE" in rendered # literal source key only, not command output
|
||||||
|
|
||||||
|
|
||||||
|
def test_toml_array_table_loop_source_key_cannot_become_jinja_expression(tmp_path):
|
||||||
|
from jinjaturtle.core import (
|
||||||
|
analyze_loops,
|
||||||
|
flatten_config,
|
||||||
|
generate_ansible_yaml,
|
||||||
|
generate_jinja2_template,
|
||||||
|
parse_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
malicious_key = (
|
||||||
|
"__class__.__mro__[1].__subclasses__()[166].__init__"
|
||||||
|
".__globals__['__builtins__']['__import__']('os')"
|
||||||
|
".popen('echo JT_TOML_RCE').read()"
|
||||||
|
)
|
||||||
|
src = tmp_path / "evil.toml"
|
||||||
|
src.write_text(
|
||||||
|
f'[[servers]]\n"{malicious_key}" = "a"\n\n'
|
||||||
|
f'[[servers]]\n"{malicious_key}" = "b"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
fmt, parsed = parse_config(src, "toml")
|
||||||
|
loops = analyze_loops(fmt, parsed)
|
||||||
|
flat = flatten_config(fmt, parsed, loops)
|
||||||
|
template = generate_jinja2_template(fmt, parsed, "role", src.read_text(), loops)
|
||||||
|
defaults = pyyaml.safe_load(generate_ansible_yaml("role", flat, loops)) or {}
|
||||||
|
|
||||||
|
assert f"server.{malicious_key}" not in template
|
||||||
|
assert "server.jt_" in template
|
||||||
|
rendered = _render_jinja(template, defaults)
|
||||||
|
assert TRIP not in rendered
|
||||||
|
assert "JT_TOML_RCE" in rendered
|
||||||
|
|
||||||
|
|
||||||
|
def test_erb_loop_source_key_cannot_become_ruby_method_call(tmp_path):
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from jinjaturtle.core import (
|
||||||
|
analyze_loops,
|
||||||
|
flatten_config,
|
||||||
|
generate_erb_template,
|
||||||
|
generate_puppet_hiera_yaml,
|
||||||
|
parse_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
marker = tmp_path / "erb-loop-owned"
|
||||||
|
key = f"instance_eval(%q{{File.write({str(marker)!r}, 'owned')}})"
|
||||||
|
src = tmp_path / "evil.yaml"
|
||||||
|
src.write_text(
|
||||||
|
"servers:\n" f" - {key!r}: a\n" f" - {key!r}: b\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
fmt, parsed = parse_config(src, "yaml")
|
||||||
|
loops = analyze_loops(fmt, parsed)
|
||||||
|
flat = flatten_config(fmt, parsed, loops)
|
||||||
|
erb = generate_erb_template(
|
||||||
|
fmt,
|
||||||
|
parsed,
|
||||||
|
"role",
|
||||||
|
original_text=src.read_text(),
|
||||||
|
loop_candidates=loops,
|
||||||
|
flat_items=flat,
|
||||||
|
)
|
||||||
|
assert "server.instance_eval" not in erb
|
||||||
|
assert "server['jt_" in erb
|
||||||
|
|
||||||
|
ruby = shutil.which("ruby")
|
||||||
|
if ruby is None:
|
||||||
|
pytest.skip("ruby is not installed")
|
||||||
|
|
||||||
|
hiera_path = tmp_path / "hiera.yml"
|
||||||
|
hiera_path.write_text(
|
||||||
|
generate_puppet_hiera_yaml("role", flat, loops), encoding="utf-8"
|
||||||
|
)
|
||||||
|
tpl = tmp_path / "safe.erb"
|
||||||
|
tpl.write_text(erb, encoding="utf-8")
|
||||||
|
run = subprocess.run(
|
||||||
|
[
|
||||||
|
ruby,
|
||||||
|
"-rerb",
|
||||||
|
"-ryaml",
|
||||||
|
"-e",
|
||||||
|
"@servers = YAML.load_file(ARGV[1])['role::servers']; "
|
||||||
|
"puts ERB.new(File.read(ARGV[0]), trim_mode: '-').result(binding)",
|
||||||
|
str(tpl),
|
||||||
|
str(hiera_path),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert run.returncode == 0, run.stderr
|
||||||
|
assert not marker.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_ini_and_postfix_escape_source_keys():
|
||||||
|
from configparser import ConfigParser
|
||||||
|
|
||||||
|
from jinjaturtle.core import generate_jinja2_template
|
||||||
|
|
||||||
|
parser = ConfigParser()
|
||||||
|
parser.optionxform = str
|
||||||
|
parser.add_section('s_{{ boom.run("ini") }}')
|
||||||
|
parser.set('s_{{ boom.run("ini") }}', "key", "ok")
|
||||||
|
ini_template = generate_jinja2_template("ini", parser, "role", original_text=None)
|
||||||
|
rendered = _render_jinja(
|
||||||
|
ini_template, {"role_s_boom_run_ini_key": "ok", "boom": _Boom()}
|
||||||
|
)
|
||||||
|
assert TRIP not in rendered
|
||||||
|
assert 's_{{ boom.run("ini") }}' in rendered
|
||||||
|
|
||||||
|
postfix_template = generate_jinja2_template(
|
||||||
|
"postfix",
|
||||||
|
{'evil_{{ boom.run("postfix") }}': "ok"},
|
||||||
|
"role",
|
||||||
|
original_text=None,
|
||||||
|
)
|
||||||
|
rendered = _render_jinja(
|
||||||
|
postfix_template,
|
||||||
|
{"role_evil_boom_run_postfix": "ok", "boom": _Boom()},
|
||||||
|
)
|
||||||
|
assert TRIP not in rendered
|
||||||
|
assert 'evil_{{ boom.run("postfix") }}' in rendered
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_safety_validator_rejects_future_expression_injection():
|
||||||
|
from jinjaturtle.template_safety import (
|
||||||
|
UnsafeTemplateError,
|
||||||
|
validate_generated_template,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(UnsafeTemplateError):
|
||||||
|
validate_generated_template("{{ item.__class__.__mro__[1] }}")
|
||||||
|
with pytest.raises(UnsafeTemplateError):
|
||||||
|
validate_generated_template("{{ boom.run('x') }}")
|
||||||
|
# Generated field names and loop.last remain allowed.
|
||||||
|
validate_generated_template(
|
||||||
|
"{% for item in role_items %}{{ item.jt_name_82a3537f }}"
|
||||||
|
"{% if not loop.last %},{% endif %}{% endfor %}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_defaults_generation_rejects_colliding_variable_names():
|
||||||
|
from jinjaturtle.core import generate_ansible_yaml
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="same variable name"):
|
||||||
|
generate_ansible_yaml("role", [(("a-b",), 1), (("a_b",), 2)])
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,9 @@ def test_json_direct_loop_generation_renders_valid_json_without_joined_objects()
|
||||||
|
|
||||||
env = Environment(keep_trailing_newline=True)
|
env = Environment(keep_trailing_newline=True)
|
||||||
env.filters["to_json"] = lambda value, **kwargs: json.dumps(value, **kwargs)
|
env.filters["to_json"] = lambda value, **kwargs: json.dumps(value, **kwargs)
|
||||||
rendered = env.from_string(template).render(fact_implementations=items)
|
rendered = env.from_string(template).render(
|
||||||
|
fact_implementations=candidate.safe_items()
|
||||||
|
)
|
||||||
|
|
||||||
assert "}, {" not in rendered
|
assert "}, {" not in rendered
|
||||||
assert json.loads(rendered) == parsed
|
assert json.loads(rendered) == parsed
|
||||||
|
|
|
||||||
|
|
@ -123,3 +123,15 @@ def test_process_directory_ini_union_marks_optional_sections_and_keys(tmp_path:
|
||||||
def test_process_directory_rejects_empty_folder(tmp_path: Path):
|
def test_process_directory_rejects_empty_folder(tmp_path: Path):
|
||||||
with pytest.raises(ValueError, match="No supported config files"):
|
with pytest.raises(ValueError, match="No supported config files"):
|
||||||
process_directory(tmp_path, recursive=False, role_prefix="role")
|
process_directory(tmp_path, recursive=False, role_prefix="role")
|
||||||
|
|
||||||
|
|
||||||
|
def test_directory_mode_skips_supported_symlinks(tmp_path: Path):
|
||||||
|
outside = tmp_path / "outside.yaml"
|
||||||
|
outside.write_text("secret: value\n", encoding="utf-8")
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "real.yaml").write_text("name: ok\n", encoding="utf-8")
|
||||||
|
(root / "link.yaml").symlink_to(outside)
|
||||||
|
|
||||||
|
files = iter_supported_files(root, recursive=False)
|
||||||
|
assert files == [root / "real.yaml"]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue