mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-12 05:36:40 +00:00
Extend password-quoting integration test to: - fail when password variables in shell tasks miss the `| quote` filter - fail when `| quote` is used but the Jinja expression is wrapped in quotes (double-quoting) This ensures shell-safe password handling and prevents quoted secrets from being passed literally to CLI tools. https://chatgpt.com/share/6974dd33-9470-800f-b469-d31fabcd59e1
165 lines
4.9 KiB
Python
165 lines
4.9 KiB
Python
# tests/integration/test_password_quote_in_shell_tasks.py
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import unittest
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Iterable, List
|
|
|
|
|
|
JINJA_EXPR_RE = re.compile(r"{{(.*?)}}", re.DOTALL)
|
|
PASSWORD_TOKEN_RE = re.compile(r"(?i)\b[a-z0-9_]*password[a-z0-9_]*\b")
|
|
QUOTE_FILTER_RE = re.compile(r"\|\s*quote\b", re.IGNORECASE)
|
|
|
|
SHELL_KEY_RE = re.compile(r"^\s*(?:ansible\.builtin\.)?shell\s*:\s*(.*)$")
|
|
|
|
QUOTE_CHARS = {"'", '"'}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Finding:
|
|
file: Path
|
|
line: int
|
|
reason: str
|
|
snippet: str
|
|
|
|
def format(self) -> str:
|
|
return f"{self.file.as_posix()}:{self.line}: {self.reason}: {self.snippet}"
|
|
|
|
|
|
def _iter_roles_yml_files(repo_root: Path) -> Iterable[Path]:
|
|
roles_dir = repo_root / "roles"
|
|
if not roles_dir.is_dir():
|
|
return []
|
|
return roles_dir.rglob("*.yml")
|
|
|
|
|
|
def _indent_level(s: str) -> int:
|
|
return len(s) - len(s.lstrip(" "))
|
|
|
|
|
|
def _collect_shell_blocks(text: str) -> List[tuple[int, str]]:
|
|
"""
|
|
Return list of (start_line_no, block_text) for each shell: block.
|
|
Best-effort indentation-based collector (no YAML parsing).
|
|
"""
|
|
lines = text.splitlines()
|
|
blocks: List[tuple[int, str]] = []
|
|
|
|
i = 0
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
m = SHELL_KEY_RE.match(line)
|
|
if not m:
|
|
i += 1
|
|
continue
|
|
|
|
start_line_no = i + 1
|
|
base_indent = _indent_level(line)
|
|
|
|
collected = [line]
|
|
i += 1
|
|
while i < len(lines):
|
|
nxt = lines[i]
|
|
|
|
# Keep blank lines inside the block
|
|
if nxt.strip() == "":
|
|
collected.append(nxt)
|
|
i += 1
|
|
continue
|
|
|
|
# Stop when indentation returns to base or less (next YAML key/item)
|
|
if _indent_level(nxt) <= base_indent:
|
|
break
|
|
|
|
collected.append(nxt)
|
|
i += 1
|
|
|
|
blocks.append((start_line_no, "\n".join(collected)))
|
|
|
|
return blocks
|
|
|
|
|
|
def _is_directly_wrapped_by_quotes(block: str, start: int, end: int) -> bool:
|
|
"""
|
|
Heuristic: Treat as "double-quoted" when the Jinja expression is directly
|
|
adjacent to a quote char, e.g.:
|
|
--pass "{{ pw | quote }}"
|
|
-p"{{ pw | quote }}"
|
|
foo '{{ pw | quote }}'
|
|
This usually indicates the value will contain quotes literally.
|
|
"""
|
|
pre = block[start - 1] if start > 0 else ""
|
|
post = block[end] if end < len(block) else ""
|
|
return (pre in QUOTE_CHARS) or (post in QUOTE_CHARS)
|
|
|
|
|
|
def _scan_shell_block(file_path: Path, start_line: int, block: str) -> List[Finding]:
|
|
findings: List[Finding] = []
|
|
|
|
for m in JINJA_EXPR_RE.finditer(block):
|
|
expr = (m.group(1) or "").strip()
|
|
if not PASSWORD_TOKEN_RE.search(expr):
|
|
continue
|
|
|
|
# Approximate line number within block
|
|
rel_line = block.count("\n", 0, m.start())
|
|
line_no = start_line + rel_line
|
|
|
|
snippet = "{{ " + " ".join(expr.split()) + " }}"
|
|
|
|
# 1) Hard fail if missing | quote
|
|
if not QUOTE_FILTER_RE.search(expr):
|
|
findings.append(
|
|
Finding(
|
|
file=file_path,
|
|
line=line_no,
|
|
reason="In shell tasks, password expressions must include '| quote'",
|
|
snippet=snippet,
|
|
)
|
|
)
|
|
continue
|
|
|
|
# 2) Hard fail if | quote is used but the whole Jinja expression is wrapped in quotes
|
|
# -> typical double-quoting like "--pass \"{{ pw | quote }}\""
|
|
if _is_directly_wrapped_by_quotes(block, m.start(), m.end()):
|
|
findings.append(
|
|
Finding(
|
|
file=file_path,
|
|
line=line_no,
|
|
reason=(
|
|
"Double-quoting detected: password expression uses '| quote' but is "
|
|
"directly wrapped by quotes (remove the surrounding quotes)"
|
|
),
|
|
snippet=snippet,
|
|
)
|
|
)
|
|
|
|
return findings
|
|
|
|
|
|
class TestPasswordQuoteInShellTasks(unittest.TestCase):
|
|
def test_passwords_are_quoted_in_shell_tasks(self) -> None:
|
|
repo_root = (
|
|
Path(__file__).resolve().parents[2]
|
|
) # tests/integration/ -> repo root
|
|
|
|
all_findings: List[Finding] = []
|
|
for yml in _iter_roles_yml_files(repo_root):
|
|
text = yml.read_text(encoding="utf-8", errors="replace")
|
|
for start_line, block in _collect_shell_blocks(text):
|
|
all_findings.extend(_scan_shell_block(yml, start_line, block))
|
|
|
|
if all_findings:
|
|
msg = "\n".join(f.format() for f in all_findings)
|
|
self.fail(
|
|
"Violations found in shell tasks (password expressions must use '| quote' "
|
|
"and must not be double-quoted):\n"
|
|
f"{msg}\n"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|