Byebye
Some checks failed
CI / test (push) Successful in 48s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 1m21s
Lint / test (push) Failing after 38s

This commit is contained in:
Miguel Jacq 2026-06-24 20:29:20 +10:00
parent 094c4d2274
commit f63cb92b35
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
18 changed files with 593 additions and 886 deletions

View file

@ -132,57 +132,3 @@ def test_cli_folder_single_output_file_when_one_format(tmp_path):
assert defaults_path.is_file()
assert template_path.is_file()
assert "to_json" in template_path.read_text(encoding="utf-8")
def test_cli_erb_outputs_puppet_hiera_and_erb_template(tmp_path):
cfg = tmp_path / "app.ini"
cfg.write_text("[main]\nport = 8080\n", encoding="utf-8")
data_out = tmp_path / "common.yaml"
template_out = tmp_path / "app.ini.erb"
exit_code = cli._main(
[
str(cfg),
"-r",
"php",
"--template-engine",
"erb",
"--defaults-output",
str(data_out),
"--template-output",
str(template_out),
]
)
assert exit_code == 0
assert "php::main_port: '8080'" in data_out.read_text(encoding="utf-8")
assert "port = <%= @main_port %>" in template_out.read_text(encoding="utf-8")
def test_cli_erb_can_use_separate_puppet_class_namespace(tmp_path):
cfg = tmp_path / "app.ini"
cfg.write_text("[main]\nport = 8080\n", encoding="utf-8")
data_out = tmp_path / "node.yaml"
template_out = tmp_path / "app.ini.erb"
exit_code = cli._main(
[
str(cfg),
"-r",
"php_etc_app_ini",
"--template-engine",
"erb",
"--puppet-class",
"php",
"--defaults-output",
str(data_out),
"--template-output",
str(template_out),
]
)
assert exit_code == 0
data = data_out.read_text(encoding="utf-8")
template = template_out.read_text(encoding="utf-8")
assert "php::php_etc_app_ini_main_port: '8080'" in data
assert "port = <%= @php_etc_app_ini_main_port %>" in template

View file

@ -0,0 +1,122 @@
"""Regression tests for the deeply-nested-input denial-of-service fix.
Pathologically nested configuration (thousands of container levels) used to
drive the recursive walkers in ``core`` -- or the underlying parser itself --
past Python's stack limit, raising an uncaught ``RecursionError``. The fix
bounds nesting depth and converts both cases into a clean, catchable
``ConfigTooDeeplyNestedError``. These tests pin that behaviour and guard
against regressions, while confirming normally-nested configs still process.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
import pytest
from jinjaturtle.core import (
ConfigTooDeeplyNestedError,
MAX_CONFIG_DEPTH,
analyze_loops,
flatten_config,
generate_ansible_yaml,
generate_jinja2_template,
parse_config,
)
def _run_pipeline(content: str, suffix: str) -> None:
with tempfile.NamedTemporaryFile(
"w", suffix=suffix, delete=False, encoding="utf-8"
) as tf:
tf.write(content)
path = Path(tf.name)
try:
fmt, parsed = parse_config(path)
loops = analyze_loops(fmt, parsed)
flat = flatten_config(fmt, parsed, loops)
generate_jinja2_template(
fmt, parsed, "role", original_text=content, loop_candidates=loops
)
generate_ansible_yaml("role", flat, loops)
finally:
path.unlink()
def _deep_json_objects(depth: int) -> str:
s = "0"
for _ in range(depth):
s = '{"a":' + s + "}"
return s
def _deep_json_arrays(depth: int) -> str:
return "[" * depth + "1" + "]" * depth
def _deep_xml(depth: int) -> str:
s = "v"
for _ in range(depth):
s = f"<a>{s}</a>"
return s
def _deep_yaml(depth: int) -> str:
s = "v"
for _ in range(depth):
s = "{a: " + s + "}"
return s
def _deep_toml(depth: int) -> str:
return "a = " + "[" * depth + "1" + "]" * depth
@pytest.mark.parametrize(
"content, suffix",
[
(_deep_json_objects(5000), ".json"),
(_deep_json_arrays(100000), ".json"),
(_deep_xml(5000), ".xml"),
(_deep_yaml(3000), ".yaml"),
(_deep_toml(2000), ".toml"),
],
)
def test_deeply_nested_input_is_rejected_cleanly(content: str, suffix: str) -> None:
# Must raise our typed error, and crucially must NOT raise RecursionError
# (pytest would report that as an error, but be explicit about intent).
with pytest.raises(ConfigTooDeeplyNestedError):
_run_pipeline(content, suffix)
def test_recursion_error_never_escapes() -> None:
# Belt-and-suspenders: ensure a RecursionError is never what surfaces.
content = _deep_yaml(6000)
try:
_run_pipeline(content, ".yaml")
except ConfigTooDeeplyNestedError:
pass
except RecursionError: # pragma: no cover - this is the bug we fixed
pytest.fail("RecursionError escaped instead of ConfigTooDeeplyNestedError")
@pytest.mark.parametrize(
"content, suffix",
[
('{"name":"web","port":8080,"tags":["a","b"]}', ".json"),
("<config><host>db</host><port>5432</port></config>", ".xml"),
("name: web\nport: 8080\nnested:\n a:\n b: 1\n", ".yaml"),
('[section]\nkey = "value"\nnums = [1, 2, 3]\n', ".toml"),
("[section]\nkey = value\n", ".ini"),
],
)
def test_normal_configs_still_process(content: str, suffix: str) -> None:
# No exception expected for ordinary, shallow configurations.
_run_pipeline(content, suffix)
def test_just_under_limit_is_accepted() -> None:
# A structure comfortably under the cap must still process successfully.
content = _deep_json_objects(MAX_CONFIG_DEPTH - 10)
_run_pipeline(content, ".json")

View file

@ -2,9 +2,9 @@
JinjaTurtle copies parts of the source config (comments, unrecognised lines,
structural keys) verbatim into the generated template. If that text contains
Jinja2/ERB delimiters it must be neutralised, otherwise attacker-influenced
config content becomes live template code that executes when Salt/Ansible/Puppet
later renders the template.
Jinja2 delimiters it must be neutralised, otherwise attacker-influenced
config content becomes live template code when a downstream tool renders the
template.
These tests render the *generated* template the way a downstream tool would and
assert that an injected payload never executes. A tripwire object is exposed
@ -14,7 +14,6 @@ the tripwire sentinel, an injected expression executed and the test fails.
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
@ -25,7 +24,6 @@ import yaml as pyyaml
from jinjaturtle.escape import (
escape_jinja_literal,
escape_erb_literal,
escape_literal,
)
@ -235,36 +233,10 @@ def test_endraw_whitespace_control_cannot_break_out(endraw):
assert rendered == payload
@pytest.mark.parametrize(
"payload",
[
"<%= system('id') %>",
"<% require 'open3' %>",
"text <%= 1+1 %> more",
"-%> orphan <%",
],
)
def test_escape_erb_literal_removes_executable_tags(payload):
escaped = escape_erb_literal(payload)
# No raw executable ERB tag should survive that contains the original code.
live = re.findall(r"<%[-=#]?(.*?)-?%>", escaped, re.S)
for chunk in live:
assert "system" not in chunk
assert "require" not in chunk
# Numeric/expression payloads must be reduced to literal-string prints.
def test_escape_literal_dispatches_by_engine():
"""The public ``escape_literal`` wrapper routes to the right engine."""
def test_escape_literal_is_jinja_alias():
"""The public ``escape_literal`` wrapper is Jinja2-only."""
payload = "{{ 7*7 }}"
# Default engine is Jinja2.
assert escape_literal(payload) == escape_jinja_literal(payload)
assert escape_literal(payload, engine="jinja2") == escape_jinja_literal(payload)
# An unknown engine falls back to the safer Jinja2 escaping.
assert escape_literal(payload, engine="nonsense") == escape_jinja_literal(payload)
# ERB routing.
erb_payload = "<%= system('id') %>"
assert escape_literal(erb_payload, engine="erb") == escape_erb_literal(erb_payload)
def test_escape_literal_jinja_output_is_inert():

View file

@ -25,7 +25,6 @@ from jinjaturtle.multi import process_directory
from jinjaturtle.safety import (
TemplateSafetyError,
verify_jinja2_template_safe,
verify_erb_template_safe,
)
@ -180,20 +179,6 @@ def _strip_raw_blocks(text: str) -> str:
)
# --------------------------------------------------------------------------- #
# ERB gate.
# --------------------------------------------------------------------------- #
def test_erb_gate_blocks_leftover_jinja_delimiters():
with pytest.raises(TemplateSafetyError):
verify_erb_template_safe("ok <%= @x %> but {{ leftover }} here")
def test_erb_gate_allows_clean_erb():
verify_erb_template_safe("memory = <%= @memory_limit %>\n")
# --------------------------------------------------------------------------- #
# End-to-end CLI: fail closed.
# --------------------------------------------------------------------------- #

View file

@ -0,0 +1,98 @@
from pathlib import Path
import pytest
import yaml
from defusedxml.common import DTDForbidden
from jinjaturtle.core import (
VariableNameCollisionError,
generate_ansible_yaml,
parse_config,
)
from jinjaturtle.handlers.xml import XmlHandler
from jinjaturtle.multi import iter_supported_files, process_directory
def test_defaults_string_values_are_ansible_unsafe():
output = generate_ansible_yaml(
"role", [(("section", "key"), "{{ lookup('pipe', 'id') }}")]
)
assert "!unsafe" in output
assert yaml.safe_load(output)["role_section_key"] == "{{ lookup('pipe', 'id') }}"
def test_variable_name_collisions_fail_closed():
with pytest.raises(VariableNameCollisionError):
generate_ansible_yaml(
"role",
[
(("section", "a-b"), "safe"),
(("section", "a_b"), "evil"),
],
)
def test_folder_mode_does_not_follow_file_symlinks(tmp_path: Path):
outside = tmp_path / "outside.ini"
outside.write_text("[s]\nsecret = value\n", encoding="utf-8")
root = tmp_path / "root"
root.mkdir()
(root / "link.ini").symlink_to(outside)
(root / "real.ini").write_text("[s]\nname = ok\n", encoding="utf-8")
files = iter_supported_files(root, recursive=True)
assert files == [root / "real.ini"]
defaults, _outputs = process_directory(root, recursive=True, role_prefix="role")
assert "secret" not in defaults
assert "real.ini" in defaults
def test_xml_parser_rejects_dtd_and_entities(tmp_path: Path):
xml_path = tmp_path / "evil.xml"
xml_path.write_text('<!DOCTYPE r [<!ENTITY x "boom">]><r>&x;</r>', encoding="utf-8")
with pytest.raises(DTDForbidden):
parse_config(xml_path)
def test_xml_source_comments_cannot_forge_internal_markers():
source = (
"<root><!--IF:role_missing--><name>ok</name><!--ENDIF:role_missing--></root>"
)
template = XmlHandler().generate_jinja2_template(None, "role", original_text=source)
assert "{% if role_missing is defined %}" not in template
assert "{% endif %}" not in template
assert "IF:role_missing" in template
assert "ENDIF:role_missing" in template
def test_cli_refuses_to_write_output_symlink(tmp_path: Path):
import subprocess
import sys
src = tmp_path / "input.ini"
src.write_text("[s]\nname = ok\n", encoding="utf-8")
target = tmp_path / "target.yml"
target.write_text("do not overwrite\n", encoding="utf-8")
link = tmp_path / "defaults.yml"
link.symlink_to(target)
result = subprocess.run(
[
sys.executable,
"-m",
"jinjaturtle.cli",
str(src),
"-d",
str(link),
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env={"PYTHONPATH": str(Path(__file__).resolve().parents[1] / "src")},
check=False,
)
assert result.returncode != 0
assert target.read_text(encoding="utf-8") == "do not overwrite\n"

View file

@ -10,7 +10,6 @@ from jinjaturtle.core import (
analyze_loops,
flatten_config,
generate_ansible_yaml,
generate_erb_template,
generate_jinja2_template,
)
from jinjaturtle.handlers.yaml import YamlHandler
@ -178,17 +177,15 @@ def test_yaml_loop_preserves_blank_separator_after_list(tmp_path: Path):
"blank",
"require:\n - rubocop-performance\n - rubocop-rspec\n\nAllCops:\n NewCops: enable\n",
"\n - rubocop-rspec\n\nAllCops:",
"<% end %>\nAllCops:",
),
(
"no_blank",
"require:\n - rubocop-performance\n - rubocop-rspec\nAllCops:\n NewCops: enable\n",
"\n - rubocop-rspec\nAllCops:",
"<% end %>AllCops:",
),
]
for label, text, rendered_expected, erb_expected in cases:
for label, text, rendered_expected in cases:
path = tmp_path / f"{label}.yml"
path.write_text(text, encoding="utf-8")
@ -205,16 +202,6 @@ def test_yaml_loop_preserves_blank_separator_after_list(tmp_path: Path):
rendered = Template(template).render(**defaults)
assert rendered_expected in rendered
erb_template = generate_erb_template(
fmt,
parsed,
"role",
original_text=text,
loop_candidates=loop_candidates,
flat_items=flat_items,
)
assert erb_expected in erb_template
def test_yaml_loop_preserves_following_top_level_comments(tmp_path: Path):
from jinja2 import Environment, Template