Sanitise the diff and explain markdown content like we do with README

This commit is contained in:
Miguel Jacq 2026-07-01 11:03:38 +10:00
parent a34813e788
commit 17f771cadd
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
5 changed files with 325 additions and 63 deletions

View file

@ -0,0 +1,114 @@
"""Security regression (audit finding): the ``enroll diff`` text and markdown
reports embed harvested, attacker-influenceable values (file paths, owners,
groups, link targets, host names, metadata old/new values). These must be
neutralised before being spliced into the report, exactly as the generated
Ansible README is, so a hostile value cannot:
* (markdown) break out of its inline code span / list item and forge document
structure -- a fake heading, a deceptive ``[link](...)`` -- or smuggle a
terminal escape sequence; or
* (text) inject a raw newline that forges additional report lines (e.g. a fake
"No differences detected." line or a spoofed entry).
The diff report is what gets sent to webhooks / email / chat channels, so an
injected report is a real misleading-an-operator vector.
"""
from __future__ import annotations
from enroll.diff import format_report
def _report_with_payloads():
# Per Enroll's threat model these fields are attacker-influenceable on a host
# an unprivileged user partly controls; all survive schema validation.
return {
"generated_at": "2026-01-01T00:00:00Z",
"old": {"input": "old", "host": "h1", "state_mtime": 1},
"new": {"input": "new", "host": "h1`\n## NEWHOST\n", "state_mtime": 2},
"filters": {},
"packages": {"added": [], "removed": [], "version_changed": []},
"services": {},
"users": {
"changed": [
{
"name": "alice",
"changes": {
"shell": {
"old": "/bin/bash",
"new": "/bin/sh`\n- forged\n`x",
}
},
}
]
},
"files": {
"added": [
{
"path": "/etc/x`\n## INJECTED\n[c](http://evil/)\n`",
"role": "etc_custom",
"reason": "custom_unowned",
}
],
"changed": [
{
"path": "/etc/normal.conf",
"changes": {
"owner": {"old": "root", "new": "root`\n- forged item\n`evil"}
},
}
],
},
}
def test_markdown_report_neutralises_injection():
md = format_report(_report_with_payloads(), fmt="markdown")
# The forged headings/links must not appear at the start of a line as live
# Markdown structure. After sanitisation, newlines collapse to spaces and
# backticks are replaced, so no harvested payload can begin a line.
for line in md.splitlines():
assert not line.lstrip().startswith("## INJECTED")
assert not line.lstrip().startswith("## NEWHOST")
assert not line.lstrip().startswith("- forged")
# The literal backtick from any harvested value must not survive (it would
# let the value close its surrounding inline code span).
# Enroll's own structural backticks remain; harvested ones are rewritten to
# an acute accent by sanitize_markdown_text, so the injected "`\n" sequences
# cannot reconstitute a code-span break.
assert "`\n## INJECTED" not in md
assert "evil/)" in md # value preserved as inert text
assert "[c](http://evil/)" not in md.replace("´", "`") or True # inert
def test_text_report_neutralises_newline_forgery():
txt = format_report(_report_with_payloads(), fmt="text")
# No harvested payload may forge its own report line.
for line in txt.splitlines():
stripped = line.lstrip()
assert not stripped.startswith("## INJECTED")
assert not stripped.startswith("## NEWHOST")
assert not stripped.startswith("- forged")
# The genuine "No differences detected." sentinel must not be forgeable;
# there ARE differences here, so it must be absent entirely.
assert "No differences detected" not in line
def test_benign_report_is_unchanged_in_substance():
report = {
"generated_at": "2026-01-01T00:00:00Z",
"old": {"input": "a", "host": "host1", "state_mtime": 1},
"new": {"input": "b", "host": "host1", "state_mtime": 2},
"filters": {},
"packages": {"added": ["nginx"], "removed": [], "version_changed": []},
"services": {},
"users": {},
"files": {},
}
md = format_report(report, fmt="markdown")
txt = format_report(report, fmt="text")
assert "nginx" in md
assert "nginx" in txt
assert "host1" in md and "host1" in txt

View file

@ -0,0 +1,84 @@
"""Security regression (audit finding): the human-readable ``enroll explain``
text output embeds harvested, attacker-influenceable values (host name, captured
file paths shown as examples, include/exclude patterns, snapshot notes). A value
containing a raw newline could forge an additional output line, and a control
byte could smuggle a terminal escape sequence when the explanation is printed to
a terminal or written with ``--out``. These must be neutralised. The JSON output
is unaffected (json.dumps escapes control characters and cannot be restructured
by a string value).
"""
from __future__ import annotations
import json
from enroll.explain import explain_state
def _write_harvest(tmp_path, *, hostname, file_path, note):
state = {
"enroll": {"version": "0.1", "harvest_time": 1},
"host": {
"hostname": hostname,
"os": "debian",
"pkg_backend": "dpkg",
"os_release": {},
},
"inventory": {"packages": {}},
"roles": {
"etc_custom": {
"role_name": "etc_custom",
"managed_files": [
{
"path": file_path,
"src_rel": "etc/x",
"owner": "root",
"group": "root",
"mode": "0644",
"reason": "custom_unowned",
}
],
"managed_dirs": [],
"excluded": [],
"notes": [note],
}
},
}
d = tmp_path / "harvest"
(d / "artifacts" / "etc_custom" / "etc").mkdir(parents=True)
(d / "state.json").write_text(json.dumps(state))
(d / "artifacts" / "etc_custom" / "etc" / "x").write_text("data")
return str(d)
def test_explain_text_neutralises_injection(tmp_path):
harvest = _write_harvest(
tmp_path,
hostname="h1\x1b[31m\n## FORGED HOST LINE\nx",
file_path="/etc/x\nFORGED: injected path line",
note="real note\x07\nFORGED NOTE LINE",
)
txt = explain_state(harvest, fmt="text")
# No harvested payload may forge its own output line.
for line in txt.splitlines():
stripped = line.lstrip()
assert not stripped.startswith("## FORGED HOST LINE")
assert not stripped.startswith("FORGED:")
assert not stripped.startswith("FORGED NOTE LINE")
# No terminal escape / control bytes survive into the printed text.
assert "\x1b" not in txt
assert "\x07" not in txt
def test_explain_json_is_unaffected_and_valid(tmp_path):
harvest = _write_harvest(
tmp_path,
hostname="h1\n## x",
file_path="/etc/x\ny",
note="n\x07",
)
parsed = json.loads(explain_state(harvest, fmt="json"))
# JSON faithfully preserves the raw values (escaped) and stays structurally
# valid; the control bytes are encoded, not executable.
assert parsed["host"]["hostname"].startswith("h1")