Some more hardening to not process raw jinja inside salt/ansible cmd. But, I think this is the end of the road
This commit is contained in:
parent
c3c3608049
commit
d96ad3dc02
9 changed files with 508 additions and 12 deletions
|
|
@ -2378,3 +2378,15 @@ def test_manifest_non_fqdn_refuses_existing_output(tmp_path: Path):
|
|||
|
||||
with pytest.raises(RuntimeError, match="already exists"):
|
||||
manifest.manifest(str(bundle), str(out), no_common_roles=True)
|
||||
|
||||
|
||||
def test_yaml_dump_mapping_emits_ansible_unsafe_tag_for_marked_values():
|
||||
from enroll.render_safety import ansible_unsafe_data
|
||||
|
||||
data = ansible_unsafe_data({"value": "{{ lookup('pipe','id') }}"})
|
||||
dumped = yaml_helpers.yaml_dump_mapping(data)
|
||||
|
||||
assert "value: !unsafe" in dumped
|
||||
assert "{{ lookup(''pipe'',''id'') }}" in dumped
|
||||
loaded = yaml_helpers.yaml_load_mapping(dumped)
|
||||
assert loaded["value"] == "{{ lookup('pipe','id') }}"
|
||||
|
|
|
|||
|
|
@ -89,3 +89,74 @@ def test_ansible_role_normalises_package_snapshot():
|
|||
assert role.files["/etc/curlrc"]["dest"] == "/etc/curlrc"
|
||||
assert role.services == {}
|
||||
assert role.origin_lines == ["package `curl` from role `curl`"]
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from state_helpers import write_schema_state
|
||||
|
||||
from enroll import manifest, yamlutil as yaml_helpers
|
||||
|
||||
|
||||
def _ansible_jinja_payload_state(payload: str) -> dict:
|
||||
return {
|
||||
"schema_version": 3,
|
||||
"host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"},
|
||||
"inventory": {"packages": {}},
|
||||
"roles": {
|
||||
"users": {
|
||||
"role_name": "users",
|
||||
"users": [
|
||||
{
|
||||
"name": "alice",
|
||||
"uid": 1000,
|
||||
"gid": 1000,
|
||||
"gecos": payload,
|
||||
"home": "/home/alice",
|
||||
"shell": "/bin/bash",
|
||||
"primary_group": "alice",
|
||||
"supplementary_groups": [],
|
||||
}
|
||||
],
|
||||
"managed_dirs": [],
|
||||
"managed_files": [],
|
||||
"managed_links": [],
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
},
|
||||
"services": [],
|
||||
"packages": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_ansible_static_marks_harvested_jinja_values_unsafe(tmp_path: Path):
|
||||
bundle = tmp_path / "bundle"
|
||||
out = tmp_path / "out"
|
||||
payload = "{{ lookup('pipe','touch /tmp/PWNED_BY_ENROLL_ANSIBLE') }}"
|
||||
write_schema_state(bundle, _ansible_jinja_payload_state(payload))
|
||||
|
||||
manifest.manifest(str(bundle), str(out), target="ansible")
|
||||
|
||||
defaults = out / "roles" / "users" / "defaults" / "main.yml"
|
||||
text = defaults.read_text(encoding="utf-8")
|
||||
assert "gecos: !unsafe" in text
|
||||
assert "lookup(''pipe'',''touch /tmp/PWNED_BY_ENROLL_ANSIBLE'')" in text
|
||||
loaded = yaml_helpers.yaml_load_mapping(text)
|
||||
assert loaded["users_users"][0]["gecos"] == payload
|
||||
|
||||
|
||||
def test_ansible_fqdn_marks_harvested_jinja_values_unsafe(tmp_path: Path):
|
||||
bundle = tmp_path / "bundle"
|
||||
out = tmp_path / "out"
|
||||
payload = "{{ lookup('pipe','touch /tmp/PWNED_BY_ENROLL_ANSIBLE') }}"
|
||||
write_schema_state(bundle, _ansible_jinja_payload_state(payload))
|
||||
|
||||
manifest.manifest(str(bundle), str(out), target="ansible", fqdn="host.example.test")
|
||||
|
||||
hostvars = out / "inventory" / "host_vars" / "host.example.test" / "users.yml"
|
||||
text = hostvars.read_text(encoding="utf-8")
|
||||
assert "gecos: !unsafe" in text
|
||||
assert "lookup(''pipe'',''touch /tmp/PWNED_BY_ENROLL_ANSIBLE'')" in text
|
||||
loaded = yaml_helpers.yaml_load_mapping(text)
|
||||
assert loaded["users_users"][0]["gecos"] == payload
|
||||
|
|
|
|||
|
|
@ -1408,3 +1408,72 @@ def test_manifest_puppet_user_gecos_with_newline_is_single_line(tmp_path: Path):
|
|||
assert 'comment => "Real Name\\ntouch /tmp/pwned"' in init_pp
|
||||
# And there must be no line that is just the injected command.
|
||||
assert "\ntouch /tmp/pwned\n" not in init_pp
|
||||
|
||||
|
||||
def _puppet_hiera_payload_state(payload: str) -> dict:
|
||||
return {
|
||||
"schema_version": 3,
|
||||
"host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"},
|
||||
"inventory": {"packages": {}},
|
||||
"roles": {
|
||||
"users": {
|
||||
"role_name": "users",
|
||||
"users": [
|
||||
{
|
||||
"name": "alice",
|
||||
"uid": 1000,
|
||||
"gid": 1000,
|
||||
"gecos": payload,
|
||||
"home": "/home/alice",
|
||||
"shell": "/bin/bash",
|
||||
"primary_group": "alice",
|
||||
"supplementary_groups": [],
|
||||
}
|
||||
],
|
||||
"managed_dirs": [],
|
||||
"managed_files": [],
|
||||
"managed_links": [],
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
},
|
||||
"services": [],
|
||||
"packages": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_manifest_puppet_static_quotes_template_like_harvested_values(
|
||||
tmp_path: Path,
|
||||
):
|
||||
bundle = tmp_path / "bundle"
|
||||
out = tmp_path / "puppet"
|
||||
payload = "%{lookup('enroll::classes')}"
|
||||
_write_state(bundle, _puppet_hiera_payload_state(payload))
|
||||
|
||||
manifest.manifest(str(bundle), str(out), target="puppet")
|
||||
|
||||
init_pp = (out / "modules" / "users" / "manifests" / "init.pp").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "comment => '%{lookup(\\'enroll::classes\\')}'" in init_pp
|
||||
|
||||
|
||||
def test_manifest_puppet_hiera_escapes_harvested_interpolation_tokens(
|
||||
tmp_path: Path,
|
||||
):
|
||||
bundle = tmp_path / "bundle"
|
||||
out = tmp_path / "puppet"
|
||||
payload = "%{lookup('enroll::classes')}"
|
||||
_write_state(bundle, _puppet_hiera_payload_state(payload))
|
||||
|
||||
manifest.manifest(str(bundle), str(out), target="puppet", fqdn="node.example")
|
||||
|
||||
node_yaml = out / "data" / "nodes" / "node.example.yaml"
|
||||
text = node_yaml.read_text(encoding="utf-8")
|
||||
assert payload not in text
|
||||
assert "%{literal(''%'')}{lookup(''enroll::classes'')}" in text
|
||||
data = yaml.safe_load(text)
|
||||
assert (
|
||||
data["users::users"]["alice"]["comment"]
|
||||
== "%{literal('%')}{lookup('enroll::classes')}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -964,3 +964,82 @@ def test_salt_names_are_sanitised_for_target_reserved_words() -> None:
|
|||
assert _salt_name("123") == "role_123"
|
||||
assert _salt_name("top") == "role_top"
|
||||
assert _salt_name("web-app") == "web_app"
|
||||
|
||||
|
||||
def test_manifest_salt_static_escapes_harvested_jinja_delimiters(tmp_path: Path):
|
||||
bundle = tmp_path / "bundle"
|
||||
out = tmp_path / "salt"
|
||||
state = _sample_state()
|
||||
payload = "{{ salt['cmd.run']('touch /tmp/PWNED_BY_ENROLL_SALT') }}"
|
||||
state["roles"]["users"]["users"][0]["gecos"] = payload
|
||||
_write_sample_artifacts(bundle)
|
||||
_write_state(bundle, state)
|
||||
|
||||
manifest.manifest(str(bundle), str(out), target="salt")
|
||||
|
||||
users_sls = (out / "states" / "roles" / "users" / "init.sls").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert payload not in users_sls
|
||||
assert "\\u007b\\u007b salt['cmd.run']" in users_sls
|
||||
|
||||
calls = []
|
||||
|
||||
class FakeCmd:
|
||||
def run(self, command):
|
||||
calls.append(command)
|
||||
return "EXECUTED"
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
rendered = Template(users_sls).render(salt={"cmd.run": FakeCmd().run})
|
||||
rendered_data = yaml.safe_load(rendered)
|
||||
assert calls == []
|
||||
user_state = next(
|
||||
state
|
||||
for state in rendered_data.values()
|
||||
if isinstance(state, dict) and "user.present" in state
|
||||
)
|
||||
attrs = user_state["user.present"]
|
||||
fullname = next(item["fullname"] for item in attrs if "fullname" in item)
|
||||
assert fullname == payload
|
||||
|
||||
|
||||
def test_manifest_salt_fqdn_escapes_harvested_jinja_delimiters_in_pillar(
|
||||
tmp_path: Path,
|
||||
):
|
||||
bundle = tmp_path / "bundle"
|
||||
out = tmp_path / "salt"
|
||||
state = _sample_state()
|
||||
payload = "{{ salt['cmd.run']('touch /tmp/PWNED_BY_ENROLL_SALT') }}"
|
||||
state["roles"]["users"]["users"][0]["gecos"] = payload
|
||||
_write_sample_artifacts(bundle)
|
||||
_write_state(bundle, state)
|
||||
|
||||
manifest.manifest(str(bundle), str(out), target="salt", fqdn="node.example")
|
||||
|
||||
pillar_top = yaml.safe_load(
|
||||
(out / "pillar" / "top.sls").read_text(encoding="utf-8")
|
||||
)
|
||||
node_sls = pillar_top["base"]["node.example"][0]
|
||||
pillar_path = out / "pillar" / Path(*node_sls.split("."))
|
||||
text = pillar_path.with_suffix(".sls").read_text(encoding="utf-8")
|
||||
assert payload not in text
|
||||
assert "\\u007b\\u007b salt['cmd.run']" in text
|
||||
|
||||
calls = []
|
||||
|
||||
class FakeCmd:
|
||||
def run(self, command):
|
||||
calls.append(command)
|
||||
return "EXECUTED"
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
rendered = Template(text).render(salt={"cmd.run": FakeCmd().run})
|
||||
rendered_data = yaml.safe_load(rendered)
|
||||
assert calls == []
|
||||
assert (
|
||||
rendered_data["enroll"]["roles"]["users"]["users"]["alice"]["fullname"]
|
||||
== payload
|
||||
)
|
||||
|
|
|
|||
Reference in a new issue