Files
computer-playbook/tests/integration/test_sed_escape_usage.py
T
kevinveenbirkenbach 3b1032895a feat(security): introduce sed_escape filter and enforce safe sed replacements
- add sed_escape Jinja filter for sed replacement safety
  - escapes \, &, and the active sed delimiter
  - designed explicitly for sed s/// replacement context

- patch all sed-based config updates across roles
  - espocrm, friendica, gitea, matomo, moodle, wordpress
  - ensure every Jinja value in sed replacements uses sed_escape
  - use correct delimiter-specific escaping (/, |, #)

- add robust integration test to enforce sed_escape usage
  - scans all roles/*/tasks/**/*.yml
  - detects sed substitutions with Jinja in replacement part
  - ignores delimiters inside {{ ... }} blocks
  - fails CI if sed_escape is missing

- harden repository against sed injection, delimiter breakage,
  and credential corruption in config patching tasks

https://chatgpt.com/share/6974fb45-1ee8-800f-80bd-c71122e4ac02
2026-01-24 18:02:37 +01:00

230 lines
6.4 KiB
Python

# Integration test (unittest):
# Scan all Ansible task files under roles/*/tasks/**/*.yml and ensure that
# any sed substitution where the *replacement* part injects Jinja ({{ ... }})
# uses the `sed_escape` filter inside that replacement.
#
# Rationale:
# - sed replacement treats `&`, `\`, and the delimiter specially
# - secrets/passwords often contain these characters
#
# Notes:
# - Text-based scan (no YAML parsing) to handle Jinja + folded scalars robustly.
# - Enforces only when replacement contains `{{ ... }}`.
# - Ignores delimiter characters inside Jinja blocks {{ ... }} when parsing.
#
from __future__ import annotations
import re
import unittest
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, List, Optional, Tuple
REPO_ROOT = Path(__file__).resolve().parents[2]
TASK_FILES_GLOB = "roles/*/tasks/**/*.yml"
@dataclass(frozen=True)
class SedMatch:
file: Path
start: int
end: int
delimiter: str
expr: str # matched sed command snippet (best-effort)
def iter_task_files() -> Iterable[Path]:
yield from sorted(REPO_ROOT.glob(TASK_FILES_GLOB))
def find_sed_substitutions(text: str, file: Path) -> List[SedMatch]:
"""
Find sed substitution invocations like:
sed -i "s|PAT|REPL|"
sed -ri 's/..../..../'
across possibly multi-line YAML scalars.
Important:
- When looking for the closing delimiters, delimiter characters inside Jinja
blocks {{ ... }} are ignored so expressions like sed_escape('|') don't
confuse the parser.
"""
matches: List[SedMatch] = []
# Broad match for a sed command that contains a substitution starting with s<delim>
sed_cmd_re = re.compile(
r"""
sed # sed
[^\S\r\n]+ # whitespace
(?:-[^\n\r]+)*? # flags like -i, -r, -E, etc. (best-effort, non-greedy)
[^\S\r\n]+
(?P<q>["'])? # optional opening quote around expression
s(?P<d>[^A-Za-z0-9\s]) # 's' + delimiter (non-alnum non-space)
""",
re.VERBOSE | re.MULTILINE,
)
for m in sed_cmd_re.finditer(text):
d = m.group("d")
q = m.group("q")
# Find the third unescaped delimiter after "s<d>",
# but ignore delimiter chars inside Jinja blocks {{ ... }}.
i = m.end()
delim_count = 0
in_jinja = 0
while i < len(text):
if text.startswith("{{", i):
in_jinja += 1
i += 2
continue
if in_jinja > 0 and text.startswith("}}", i):
in_jinja -= 1
i += 2
continue
ch = text[i]
if ch == "\\":
i += 2
continue
if in_jinja == 0 and ch == d:
delim_count += 1
if delim_count == 3:
i += 1
break
i += 1
if delim_count < 3:
continue
end = i
# Extend to closing quote if there was an opening quote
if q:
while end < len(text):
ch = text[end]
if ch == "\\":
end += 2
continue
if ch == q:
end += 1
break
end += 1
snippet = text[m.start() : min(end, m.start() + 2000)]
matches.append(
SedMatch(file=file, start=m.start(), end=end, delimiter=d, expr=snippet)
)
return matches
def extract_replacement(expr: str) -> Optional[Tuple[str, str]]:
"""
Given a sed snippet that includes `s<d>`, extract (delimiter, replacement_part).
Ignores delimiter characters inside Jinja blocks {{ ... }}.
"""
m = re.search(r"s(?P<d>[^A-Za-z0-9\s])", expr)
if not m:
return None
d = m.group("d")
# Parse: s<d> PAT <d> REPL <d> ...
rest = expr[m.end() :]
parts: List[str] = []
buf: List[str] = []
i = 0
in_jinja = 0
while i < len(rest):
# Enter/exit Jinja blocks
if rest.startswith("{{", i):
in_jinja += 1
buf.append("{{")
i += 2
continue
if in_jinja > 0 and rest.startswith("}}", i):
in_jinja -= 1
buf.append("}}")
i += 2
continue
ch = rest[i]
# Preserve escaped characters
if ch == "\\" and i + 1 < len(rest):
buf.append(ch)
buf.append(rest[i + 1])
i += 2
continue
# Only treat delimiter as delimiter when NOT inside Jinja
if in_jinja == 0 and ch == d:
parts.append("".join(buf))
buf = []
if len(parts) == 3:
break
i += 1
continue
buf.append(ch)
i += 1
if len(parts) < 2:
return None
replacement = parts[1]
return d, replacement
class TestSedEscapeUsage(unittest.TestCase):
def test_all_sed_replacements_use_sed_escape_filter_for_jinja_in_replacement(
self,
) -> None:
violations: List[str] = []
for file in iter_task_files():
text = file.read_text(encoding="utf-8", errors="replace")
sed_matches = find_sed_substitutions(text, file)
for sm in sed_matches:
extracted = extract_replacement(sm.expr)
if not extracted:
continue
d, replacement = extracted
# Only enforce when replacement injects Jinja
if "{{" not in replacement:
continue
# Enforce sed_escape is used in the replacement segment (not just anywhere)
if "sed_escape" not in replacement:
ctx_start = max(0, sm.start - 200)
ctx_end = min(len(text), sm.end + 200)
context = text[ctx_start:ctx_end].strip()
violations.append(
"\n".join(
[
f"{file}: sed replacement injects Jinja but does not use sed_escape",
f" Detected delimiter: {d!r}",
" Context:",
context,
]
)
)
if violations:
self.fail("\n\n".join(violations))
if __name__ == "__main__":
unittest.main()