Byebye
This commit is contained in:
parent
094c4d2274
commit
f63cb92b35
18 changed files with 593 additions and 886 deletions
|
|
@ -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
|
||||
|
|
|
|||
122
tests/test_deep_nesting_dos.py
Normal file
122
tests/test_deep_nesting_dos.py
Normal 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")
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
|
|||
98
tests/test_release_blockers.py
Normal file
98
tests/test_release_blockers.py
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue