diff --git a/_typos.toml b/_typos.toml index 25b9ce9d..e0c03542 100644 --- a/_typos.toml +++ b/_typos.toml @@ -4,4 +4,4 @@ extend-ignore-re = [ ] # Allow these as valid words -extend-words = { SOURCEN = "SOURCEN" } +extend-words = { SOURCEN = "SOURCEN", sover = "sover" } diff --git a/specfile/sanitizer.py b/specfile/sanitizer.py new file mode 100644 index 00000000..c6e03dd7 --- /dev/null +++ b/specfile/sanitizer.py @@ -0,0 +1,1062 @@ +# Copyright Contributors to the Packit project. +# SPDX-License-Identifier: MIT + +import argparse +import re +import shlex +from typing import Tuple + +from specfile.exceptions import UnterminatedMacroException +from specfile.value_parser import ( + BuiltinMacro, + ConditionalMacroExpansion, + EnclosedMacroSubstitution, + ExpressionExpansion, + MacroSubstitution, + ShellExpansion, + ValueParser, +) + +_RE_SUBSTR = re.compile( + r'^(\w+)\s*=\s*["\']?(%\{?\w+\}?)["\']?\s*;' + r'\s*echo\s+["\']?\$\{\1:(\d+)(?::(%\{?\w+\}?|\d+))?\}["\']?\s*$' +) +_RE_BASH_REPLACE = re.compile( + r'^(\w+)\s*=\s*["\']?(%\{?\w+\}?)["\']?\s*;' + r'\s*echo\s+["\']?\$\{\1(//?)([^/}]*)/([^}]*)\}["\']?$' +) +_RE_BASH_LOWER = re.compile( + r'^(\w+)\s*=\s*["\']?(%\{?\w+\}?)["\']?\s*;\s*echo\s+["\']?\$\{\1,,\}["\']?$' +) +_RE_BASH_UPPER = re.compile( + r'^(\w+)\s*=\s*["\']?(%\{?\w+\}?)["\']?\s*;\s*echo\s+["\']?\$\{\1\^\^\}["\']?$' +) +_RE_PIPE = re.compile(r"^echo\s+(.+?)\|\s*(.+)$", re.DOTALL) +_RE_HERESTRING = re.compile(r"^(.+?)<<<\s*(.+)$", re.DOTALL) +_RE_ECHO_CONCAT = re.compile(r"^echo\s+(%\{?\w+\}?)\s+([^|].*)$") +_RE_TR_LOWER = re.compile(r"tr\s+['\"]?\[:upper:\]['\"]?\s+['\"]?\[:lower:\]['\"]?$") +_RE_TR_UPPER = re.compile(r"tr\s+['\"]?\[:lower:\]['\"]?\s+['\"]?\[:upper:\]['\"]?$") +_RE_TR_DELETE = re.compile(r"tr\s+(?:--\s+)?-d\s+[\"'](.+?)[\"']$") +_RE_TR_DELETE_BARE = re.compile(r"tr\s+(?:--\s+)?-d\s+(\S)$") +_RE_TR_REPLACE = re.compile(r"tr\s+(?:--\s+)?['\"]?(.)['\"]?\s+['\"]?(.)['\"]?$") +_RE_AWK_F = re.compile(r"awk\s+-F['\"]?(.)['\"]?\s+['\"]?\{print\s+(.+)\}['\"]?$") +_RE_AWK_FIELDS = re.compile(r'\$(\d+)|"([^"]*)"') +_RE_CUT_FIELD = re.compile(r"(\d+)?-(\d+)$") +_RE_CUT_SINGLE = re.compile(r"(\d+)$") +_RE_CUT_BYTES_VAL = re.compile(r"^(%\{?\w+\}?|\d+)$") +_RE_PRINTF_TRUNC = re.compile( + r"""^\s*printf\s+['"]?%%?\.(\d+)s['"]?\s+['"]?(%\{?\w+\}?)['"]?\s*$""" +) +_RE_PRINTF_FLOAT = re.compile( + r"""^\s*(?:(?:LC_ALL|LANG)=\S+[;\s]+\s*)?""" + r"""printf\s+['"]%%?\.(\d+)f['"]?\s+['"]?(%\{?\w+\}?)['"]?\s*$""" +) +_RE_BASH_ARRAY_FIELD = re.compile( + r"^(\w+)\s*=\s*[\"']?(%\{?\w+\}?)[\"']?\s*;\s*" + r"(\w+)=\(\$\{\1//(.)/ \}\)\s*;\s*" + r"echo\s+\$\{\3\[(\d+)\]\}\s*$" +) +_RE_VAR_PIPE = re.compile( + r'^(\w+)\s*=\s*["\']?(%\{?\w+\}?)["\']?\s*;\s*' + r'echo\s+["\']?\$\{?\1\}?["\']?\s*\|\s*(.+)$', + re.DOTALL, +) +_RE_VAR_HERESTRING = re.compile( + r'^(\w+)\s*=\s*["\']?(%\{?\w+\}?)["\']?\s*;\s*' + r'(.+?)<<<\s*["\']?\$\{?\1\}?["\']?\s*$', + re.DOTALL, +) +_RE_BASH_SUFFIX = re.compile( + r'^(\w+)\s*=\s*["\']?(%\{?\w+\}?)["\']?\s*;\s*' + r'echo\s+["\']?\$\{\1(%%%%|%%|%)([^}]+)\}["\']?\s*$' +) +_RE_BASH_PREFIX = re.compile( + r'^(\w+)\s*=\s*["\']?(%\{?\w+\}?)["\']?\s*;\s*' + r'echo\s+["\']?\$\{\1(##|#)([^}]+)\}["\']?\s*$' +) +_RE_DATE_SIMPLE = re.compile(r"^\s*date\s+(?:-u\s+)?\+[\"']?([^\"']+?)[\"']?\s*$") +_RE_DATE_UTC = re.compile(r"^\s*date\s+-u\s+") +_RE_ARITHMETIC = re.compile(r"^\s*echo\s+\$\(\((.+)\)\)\s*$") +_RE_BASENAME = re.compile(r"^\s*basename\s+(%\{\w+\}|%\w+)\s*$") +_RE_DIRNAME = re.compile(r"^\s*dirname\s+(%\{\w+\}|%\w+)\s*$") +_RE_TEST_STR = re.compile( + r"""^\s*test\s+["']?(.+?)["']?\s+(!=|==|=)\s+["']?(.+?)["']?""" + r"""\s*&&\s*echo\s+["']?(.+?)["']?\s*\|\|\s*echo\s+["']?(.+?)["']?\s*$""" +) +_RE_TEST_EMPTY = re.compile( + r"""^\s*\[\s+-z\s+["']?(.+?)["']?\s*\]""" + r"""\s*&&\s*echo\s+["']?(.+?)["']?\s*\|\|\s*echo\s+["']?(.+?)["']?\s*$""" +) + +_LUA_STRING_LITERAL_RE = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'') + + +def _strip_lua_comments(code): + """Strip Lua comments while preserving string literals. + + Processes code left-to-right so that ``--`` inside a quoted string + is never mistaken for a comment start. + """ + result = [] + i = 0 + while i < len(code): + if code[i] in ('"', "'"): + quote = code[i] + result.append(code[i]) + i += 1 + while i < len(code) and code[i] != quote: + if code[i] == "\\" and i + 1 < len(code): + result.append(code[i : i + 2]) + i += 2 + else: + result.append(code[i]) + i += 1 + if i < len(code): + result.append(code[i]) + i += 1 + elif code[i : i + 2] == "--": + j = i + 2 + if j < len(code) and code[j] == "[": + level = 0 + k = j + 1 + while k < len(code) and code[k] == "=": + level += 1 + k += 1 + if k < len(code) and code[k] == "[": + close = "]" + "=" * level + "]" + end = code.find(close, k + 1) + if end != -1: + i = end + len(close) + else: + i = len(code) + result.append(" ") + continue + end = code.find("\n", i) + if end != -1: + result.append(" ") + i = end + else: + result.append(" ") + i = len(code) + else: + result.append(code[i]) + i += 1 + return "".join(result) + + +_UNSAFE_LUA_BRACKET_RE = re.compile(r"\[\s*(?![#\d])") + +_UNSAFE_LUA_STRING_CONTENT_RE = re.compile( + r"%\(|%\[|%\{(?:lua|load|include|uncompress|expand|define|global|undefine)\s*:" + r"|%(?:load|include|define|global|undefine)\s" +) + +_EXPRESSION_LUA_PREFIX_RE = re.compile(r"lua\s*:") + + +def _decode_lua_escapes(s): + """Decode all Lua string escape sequences to their character values.""" + _SIMPLE = { + "a": "\a", + "b": "\b", + "f": "\f", + "n": "\n", + "r": "\r", + "t": "\t", + "v": "\v", + "\\": "\\", + '"': '"', + "'": "'", + } + result = [] + i = 0 + while i < len(s): + if s[i] != "\\" or i + 1 >= len(s): + result.append(s[i]) + i += 1 + continue + c = s[i + 1] + if c in _SIMPLE: + result.append(_SIMPLE[c]) + i += 2 + elif c == "z": + i += 2 + while i < len(s) and s[i] in " \t\n\r": + i += 1 + elif c == "x" and i + 3 < len(s): + try: + result.append(chr(int(s[i + 2 : i + 4], 16))) + i += 4 + except ValueError: + result.append(s[i]) + i += 1 + elif c == "u" and i + 2 < len(s) and s[i + 2] == "{": + end = s.find("}", i + 3) + if end != -1: + try: + result.append(chr(int(s[i + 3 : end], 16))) + i = end + 1 + except (ValueError, OverflowError): + result.append(s[i]) + i += 1 + else: + result.append(s[i]) + i += 1 + elif c.isdigit(): + j = i + 1 + while j < len(s) and j < i + 4 and s[j].isdigit(): + j += 1 + num = int(s[i + 1 : j]) + result.append(chr(num % 256)) + i = j + else: + result.append(s[i]) + i += 1 + return "".join(result) + + +_UNSAFE_LUA_IDENTIFIERS = frozenset( + { + "_G", + "_ENV", + "rawget", + "rawset", + "rawequal", + "getfenv", + "setfenv", + "getmetatable", + "setmetatable", + "debug", + "package", + "require", + "module", + "load", + "loadstring", + "loadfile", + "dofile", + "io", + "collectgarbage", + "newproxy", + "coroutine", + "pcall", + "xpcall", + "os", + "rpm", + "string", + } +) + +_SAFE_LUA_DOTTED_MODULES = frozenset({"string", "table", "math"}) + +_SAFE_LUA_DOTTED = frozenset( + { + "rpm.expand", + "os.date", + "os.clock", + "os.time", + "os.difftime", + } +) + +_LUA_DOTTED_RE = re.compile(r"\b([a-zA-Z_]\w*)\s*\.\s*([a-zA-Z_]\w*)") +_LUA_COLON_UNSAFE_METHOD_RE = re.compile(r":\s*(dump|char|sub|find|reverse|rep)\s*\(") +_LUA_IDENT_RE = re.compile(r"\b([a-zA-Z_]\w*)\b") + +_FORMAT_CALL_RE = re.compile(r"(?:\bstring\s*\.\s*format|:\s*format)\s*\(") +_FORMAT_LITERAL_ARG_RE = re.compile(r'\s*"([^"\\]*(?:\\.[^"\\]*)*)"') +_SAFE_FORMAT_SPEC_RE = re.compile(r"%%|%[-+ #0]*\d*\.?\d*[diouxXeEfgG]") + +_RPM_EXPAND_REF_RE = re.compile(r"\brpm\s*\.\s*expand\b") +_RPM_EXPAND_SAFE_CALL_RE = re.compile(r'\s*\(\s*""\s*\)') + + +_UNESCAPED_NEWLINE_RE = re.compile(r"(? str: + """ + Sanitize a shell expansion body. Replaces commonly used patterns with builtin macros + or Lua expressions. Covered patterns are: + + - Substring extraction: + `%(c=%{commit}; echo ${c:0:7})` → `%{sub %{commit}, 1, 7}` + - Bash string replacement: + `%(v=%{version}; echo ${v//./_})` → `%{lua: + print((rpm.expand("%{version}"):gsub("%.", "_")))}` + - Case conversion: + `%(v=%{name}; echo ${v,,})` → `%{lower:%{name}}` + - Suffix removal: + `%(v=%{version}; echo ${v%%%%.*})` → `%{lua: + local v=rpm.expand("%{version}") + print(v:match("^(.-)%.") or v)}` + - Prefix removal: + `%(v=%{version}; echo ${v##*.})` → `%{lua: + local v=rpm.expand("%{version}") + print(v:match(".*%.(.*)") or v)}` + - Pipe to tr: + `%(echo %{name} | tr [:upper:] [:lower:])` → `%{lower:%{name}}` + - Pipe to sed: + `%(echo %{version} | sed 's/\\./-/g')` → `%{lua: + print((rpm.expand("%{version}"):gsub("%.", "-")))}` + - Pipe to awk: + `%(echo %{version} | awk -F. '{print $1}')` → `%{lua: + local v=rpm.expand("%{version}") local t={} + for f in v:gmatch("[^%.]+") do t[#t+1]=f end + print(t[1])}` + - Pipe to cut: + `%(echo %{version} | cut -d. -f3)` → `%{lua: + local v=rpm.expand("%{version}") local i=0 + for f in v:gmatch("[^%.]+") do i=i+1 + if i==3 then print(f) break end end}` + - Herestring variants of the above: + `%(cut -d. -f3 <<< %{version})` → same as above + - Bash array field extraction: + `%(v=%{version}; a=(${v//./ }); echo ${a[2]})` → same as above + - Date formatting: + `%(date +"%Y%m%d")` → `%{lua:print(os.date("%Y%m%d"))}` + - Arithmetic: + `%(echo $((%{__isa_bits}+2)))` → `%[%{__isa_bits}+2]` + - Basename / dirname: + `%(basename %{_python3_include})` → `%{lua: + print((rpm.expand("%{_python3_include}"):match("[^/]+$")))}` + - String comparison: + `%(test "%{_libdir}" != "%{_prefix}/lib" && echo 1 || echo 0)` → `%{lua: + print(rpm.expand("%{_libdir}") ~= rpm.expand("%{_prefix}/lib") and "1" or "0")}` + - Printf truncation: + `%(printf %%.7s %commit)` → `%{sub %{commit}, 1, 7}` + - Printf float formatting: + `%(LANG=C printf "%.4f" %{cpan_ver})` → `%{lua: + print(string.format("%%.4f", tonumber(rpm.expand("%{cpan_ver}"))))}` + - Simple echo concatenation: + `%(echo %{version} -beta)` → `%{version} -beta` + + Args: + body: Shell expansion body to sanitize. + + Returns: + Sanitized shell expansion body, or %{nil} if sanitization is not possible. + """ + + def strip_quotes(s): + s = s.strip() + if len(s) >= 2 and s[0] in "'\"" and s[-1] == s[0]: + return s[1:-1] + return s + + def normalize_macro(expr): + expr = strip_quotes(expr.strip()) + if expr.startswith("%") and not expr.startswith("%{"): + expr = "%{" + expr[1:] + "}" + if not re.match(r"^%\{\w+\}$", expr): + return None + return expr + + def is_safe_for_expand(s): + return not re.search(r"%(\{\w+[\s:]|\(|\[)", s) + + def lua_pattern_escape(s): + return "".join(f"%{c}" if c in ".+-*?()[]^$%" else c for c in s) + + def lua_string_escape(s): + s = s.replace("\\", "\\\\") + s = s.replace('"', '\\"') + s = s.replace("\0", "\\0") + s = s.replace("\a", "\\a") + s = s.replace("\b", "\\b") + s = s.replace("\f", "\\f") + s = s.replace("\n", "\\n") + s = s.replace("\r", "\\r") + s = s.replace("\t", "\\t") + s = s.replace("\v", "\\v") + return s + + def sed_pattern_to_lua(s): + _SED_C_ESCAPES = {"n": "\n", "t": "\t", "a": "\a"} + result = [] + i = 0 + while i < len(s): + if s[i] == "\\" and i + 1 < len(s): + c = s[i + 1] + if c in _SED_C_ESCAPES: + result.append(_SED_C_ESCAPES[c]) + else: + result.append(f"%{c}") + i += 2 + elif s[i] in "%+?()": + result.append(f"%{s[i]}") + i += 1 + else: + result.append(s[i]) + i += 1 + return "".join(result) + + def lua_gsub_repl_escape(s): + """Escape ``%`` in gsub replacement strings. + + In Lua's ``string.gsub``, ``%`` is magic in the replacement + (``%1`` = capture, ``%%`` = literal ``%``). + """ + return s.replace("%", "%%") + + def build_lua_gsub(expr, pattern, repl, count=None): + count_arg = f", {count}" if count is not None else "" + esc_repl = lua_string_escape(lua_gsub_repl_escape(repl)) + return ( + f'%{{lua:print((rpm.expand("{lua_string_escape(expr)}")' + f':gsub("{lua_string_escape(pattern)}"' + f', "{esc_repl}"{count_arg})))}}' + ) + + def build_lua_field(expr, delim, field): + esc = lua_string_escape(lua_pattern_escape(delim)) + return ( + f'%{{lua:local v=rpm.expand("{lua_string_escape(expr)}") ' + f"local i=0 " + f'for f in v:gmatch("[^{esc}]+") do ' + f"i=i+1 if i=={field} then print(f) break end " + f"end}}" + ) + + def build_lua_field_range(expr, delim, start, stop): + esc = lua_string_escape(lua_pattern_escape(delim)) + return ( + f'%{{lua:local v=rpm.expand("{lua_string_escape(expr)}") ' + f"local t={{}} " + f'for f in v:gmatch("[^{esc}]+") do t[#t+1]=f end ' + f'print(table.concat(t,"{lua_string_escape(delim)}",{start},{stop}))}}' + ) + + def parse_sed_substs(cmd): + results = [] + i = 0 + while i < len(cmd): + if ( + cmd[i] == "s" + and i + 1 < len(cmd) + and not cmd[i + 1].isalnum() + and cmd[i + 1] not in " \t" + ): + if i > 0 and (cmd[i - 1].isalnum() or cmd[i - 1] == "_"): + i += 1 + continue + delim = cmd[i + 1] + j = i + 2 + parts = [] + current = [] + while j < len(cmd) and len(parts) < 2: + if cmd[j] == "\\" and j + 1 < len(cmd): + current.append(cmd[j : j + 2]) + j += 2 + elif cmd[j] == delim: + parts.append("".join(current)) + current = [] + j += 1 + else: + current.append(cmd[j]) + j += 1 + if len(parts) == 2: + flags = [] + while j < len(cmd) and cmd[j].isalpha(): + flags.append(cmd[j]) + j += 1 + is_global = "g" in "".join(flags) + results.append((parts[0], parts[1], is_global)) + i = j + else: + i += 1 + else: + i += 1 + return results if results else None + + def parse_cut(cmd): + """Parse a cut command. Returns (mode, start, end, delim) or None.""" + try: + tokens = shlex.split(cmd) + except ValueError: + return None + if not tokens or tokens[0] != "cut": + return None + try: + parsed, _ = _CUT_PARSER.parse_known_args(tokens[1:]) + except (argparse.ArgumentError, SystemExit): + return None + + byte_spec = parsed.c or parsed.b + if byte_spec: + if "-" in byte_spec: + left, right = byte_spec.split("-", 1) + s = left if left else "1" + e = right if right else None + else: + s = e = byte_spec + if _RE_CUT_BYTES_VAL.match(s) and ( + e is None or _RE_CUT_BYTES_VAL.match(e) + ): + return ("bytes", s, e, None) + return None + + if parsed.d and parsed.f: + m2 = _RE_CUT_FIELD.match(parsed.f) + if m2: + s = int(m2.group(1)) if m2.group(1) else 1 + return ("range", s, int(m2.group(2)), parsed.d) + m2 = _RE_CUT_SINGLE.match(parsed.f) + if m2: + n = int(m2.group(1)) + return ("field", n, n, parsed.d) + + return None + + def build_lua_char_class(chars): + """Build a Lua pattern character class matching any of the given chars.""" + ordered = "" + if "]" in chars: + ordered += "]" + chars = chars.replace("]", "") + chars = chars.replace("%", "%%") + if "^" in chars: + chars = chars.replace("^", "") + ordered += chars + "^" + else: + ordered += chars + if "-" in ordered and len(ordered) > 1: + ordered = ordered.replace("-", "") + "-" + return f"[{ordered}]" + + def convert_string_op(expr, cmd): + # -- cut -- + cut = parse_cut(cmd) + if cut: + mode, start, end, delim = cut + if mode == "bytes": + if end is not None: + return f"%{{sub {expr}, {start}, {end}}}" + return f"%{{sub {expr}, {start}}}" + elif mode == "field": + return build_lua_field(expr, delim, start) + elif mode == "range": + return build_lua_field_range(expr, delim, start, end) + + # -- tr case conversion -- + if _RE_TR_LOWER.match(cmd): + return f"%{{lower:{expr}}}" + if _RE_TR_UPPER.match(cmd): + return f"%{{upper:{expr}}}" + + # -- tr -d (quoted multi-char or bare single-char) -- + m = _RE_TR_DELETE.match(cmd) + if m: + chars = m.group(1) + if len(chars) == 1: + return build_lua_gsub(expr, lua_pattern_escape(chars), "") + return build_lua_gsub(expr, build_lua_char_class(chars), "") + m = _RE_TR_DELETE_BARE.match(cmd) + if m: + return build_lua_gsub(expr, lua_pattern_escape(m.group(1)), "") + + # -- tr A B -- + m = _RE_TR_REPLACE.match(cmd) + if m: + return build_lua_gsub(expr, lua_pattern_escape(m.group(1)), m.group(2)) + + # -- awk -F field extraction -- + m = _RE_AWK_F.match(cmd) + if m: + delim = m.group(1) + print_args = m.group(2) + parts = _RE_AWK_FIELDS.findall(print_args) + if parts: + if not all(is_safe_for_expand(sep) for _, sep in parts if sep): + return None + lua_parts = [] + for field_num, separator in parts: + if field_num: + lua_parts.append(f"t[{field_num}]") + elif separator is not None: + lua_parts.append(f'"{lua_string_escape(separator)}"') + if lua_parts: + lua_expr = " .. ".join(lua_parts) + esc = lua_string_escape(lua_pattern_escape(delim)) + return ( + f'%{{lua:local v=rpm.expand("{lua_string_escape(expr)}") ' + f"local t={{}} " + f'for f in v:gmatch("[^{esc}]+") do t[#t+1]=f end ' + f"print({lua_expr})}}" + ) + + # -- sed substitution (handles chained sed commands) -- + substs = parse_sed_substs(cmd) + if substs: + if all(is_safe_for_expand(repl) for _, repl, _ in substs): + if len(substs) == 1: + pattern, repl, is_global = substs[0] + return build_lua_gsub( + expr, + sed_pattern_to_lua(pattern), + repl, + None if is_global else 1, + ) + esc_expr = lua_string_escape(expr) + gsub_calls = [] + for pattern, repl, is_global in substs: + esc_pat = lua_string_escape(sed_pattern_to_lua(pattern)) + esc_repl = lua_string_escape(lua_gsub_repl_escape(repl)) + count_arg = "" if is_global else ", 1" + gsub_calls.append( + f':gsub("{esc_pat}", "{esc_repl}"{count_arg})' + ) + lua_code = f'local v=(rpm.expand("{esc_expr}"){gsub_calls[0]})' + for gsub in gsub_calls[1:-1]: + lua_code += f" v=(v{gsub})" + lua_code += f" print((v{gsub_calls[-1]}))" + return f"%{{lua:{lua_code}}}" + + return None + + def convert_glob_removal(expr, op, pat): + """Convert bash ${var} (suffix/prefix removal) to Lua.""" + is_suffix = "%" in op + is_longest = len(op) >= 2 + esc_expr = lua_string_escape(expr) + + if pat == "*": + if is_longest: + return '%{lua:print("")}' + return f'%{{lua:print(rpm.expand("{esc_expr}"))}}' + + # suffix removal: pattern is STR* (literal then star) + m_cs = re.match(r"^(.+?)(\*|\\?\*)$", pat) + # prefix removal: pattern is *STR (star then literal) + m_sc = re.match(r"^(\*|\\?\*)(.+)$", pat) + + if is_suffix and m_cs: + esc = lua_string_escape(lua_pattern_escape(m_cs.group(1))) + if is_longest: + lua_pat = f"^(.-){esc}" + else: + lua_pat = f"^(.*){esc}" + return ( + f'%{{lua:local v=rpm.expand("{lua_string_escape(expr)}") ' + f'print(v:match("{lua_pat}") or v)}}' + ) + + if not is_suffix and m_sc: + esc = lua_string_escape(lua_pattern_escape(m_sc.group(2))) + if is_longest: + lua_pat = f".*{esc}(.*)" + else: + lua_pat = f"{esc}(.*)" + return ( + f'%{{lua:local v=rpm.expand("{lua_string_escape(expr)}") ' + f'print(v:match("{lua_pat}") or v)}}' + ) + + return None + + # --- var=macro; echo ${var:off[:len]} → %{sub} --- + m = _RE_SUBSTR.match(body) + if m: + expr = normalize_macro(m.group(2)) + if expr is not None: + offset = int(m.group(3)) + length_raw = m.group(4) + start = offset + 1 + if length_raw is not None: + if length_raw.isdigit(): + return f"%{{sub {expr}, {start}, {offset + int(length_raw)}}}" + length_macro = normalize_macro(length_raw) + if length_macro is not None: + if offset == 0: + return f"%{{sub {expr}, {start}, {length_macro}}}" + return f"%{{sub {expr}, {start}, %[{length_macro} + {offset}]}}" + else: + return f"%{{sub {expr}, {start}}}" + + # --- var=macro; echo ${var//PAT/REPL} → Lua gsub --- + m = _RE_BASH_REPLACE.match(body) + if m: + expr = normalize_macro(m.group(2)) + if expr is not None: + is_global = m.group(3) == "//" + pat = m.group(4) + repl = strip_quotes(m.group(5)) + if is_safe_for_expand(repl): + lua_pat = lua_pattern_escape(pat) + return build_lua_gsub(expr, lua_pat, repl, None if is_global else 1) + + # --- var=macro; echo ${var,,} → %{lower:} --- + m = _RE_BASH_LOWER.match(body) + if m: + expr = normalize_macro(m.group(2)) + if expr is not None: + return f"%{{lower:{expr}}}" + + # --- var=macro; echo ${var^^} → %{upper:} --- + m = _RE_BASH_UPPER.match(body) + if m: + expr = normalize_macro(m.group(2)) + if expr is not None: + return f"%{{upper:{expr}}}" + + # --- echo EXPR | CMD (pipe pattern) --- + m = _RE_PIPE.match(body) + if m: + expr = normalize_macro(m.group(1)) + if expr is not None: + cmd = m.group(2).strip() + result = convert_string_op(expr, cmd) + if result: + return result + + # --- CMD <<< EXPR (herestring pattern) --- + m = _RE_HERESTRING.match(body) + if m: + cmd = m.group(1).strip() + expr = normalize_macro(m.group(2)) + if expr is not None: + result = convert_string_op(expr, cmd) + if result: + return result + + # --- var=macro; echo $var | CMD → delegate to convert_string_op --- + m = _RE_VAR_PIPE.match(body) + if m: + expr = normalize_macro(m.group(2)) + if expr is not None: + cmd = m.group(3).strip() + result = convert_string_op(expr, cmd) + if result: + return result + + # --- var=macro; CMD <<< $var → delegate to convert_string_op --- + m = _RE_VAR_HERESTRING.match(body) + if m: + expr = normalize_macro(m.group(2)) + if expr is not None: + cmd = m.group(3).strip() + result = convert_string_op(expr, cmd) + if result: + return result + + # --- var=macro; echo ${var%%PAT} / ${var%PAT} → suffix removal --- + m = _RE_BASH_SUFFIX.match(body) + if m: + expr = normalize_macro(m.group(2)) + if expr is not None: + op = m.group(3) + pat = m.group(4) + result = convert_glob_removal(expr, op, pat) + if result: + return result + + # --- var=macro; echo ${var##PAT} / ${var#PAT} → prefix removal --- + m = _RE_BASH_PREFIX.match(body) + if m: + expr = normalize_macro(m.group(2)) + if expr is not None: + op = m.group(3) + pat = m.group(4) + result = convert_glob_removal(expr, op, pat) + if result: + return result + + # --- date +FORMAT → %{lua:print(os.date(FMT))} --- + m = _RE_DATE_SIMPLE.match(body) + if m: + fmt = m.group(1) + if is_safe_for_expand(fmt): + utc_prefix = "!" if _RE_DATE_UTC.match(body) else "" + return ( + f'%{{lua:print(os.date("{lua_string_escape(utc_prefix + fmt)}"))}}' + ) + + # --- echo $((EXPR)) → %[EXPR] --- + m = _RE_ARITHMETIC.match(body) + if m: + inner = m.group(1).strip() + if is_safe_for_expand(inner): + return f"%[{inner}]" + + # --- basename %{macro} → Lua --- + m = _RE_BASENAME.match(body) + if m: + expr = normalize_macro(m.group(1)) + if expr is not None: + return f'%{{lua:print((rpm.expand("{lua_string_escape(expr)}"):match("[^/]+$")))}}' + + # --- dirname %{macro} → Lua --- + m = _RE_DIRNAME.match(body) + if m: + expr = normalize_macro(m.group(1)) + if expr is not None: + return f'%{{lua:print((rpm.expand("{lua_string_escape(expr)}"):match("^(.*)/")))}}' + + # --- test "A" OP "B" && echo X || echo Y → Lua --- + m = _RE_TEST_STR.match(body) + if m: + a, op, b, x, y = m.group(1), m.group(2), m.group(3), m.group(4), m.group(5) + if all(is_safe_for_expand(v) for v in (a, b, x, y)): + lua_op = "~=" if op == "!=" else "==" + return ( + f'%{{lua:print(rpm.expand("{lua_string_escape(a)}") {lua_op}' + f' rpm.expand("{lua_string_escape(b)}")' + f' and "{lua_string_escape(x)}" or "{lua_string_escape(y)}")}}' + ) + + # --- [ -z "EXPR" ] && echo A || echo B → Lua --- + m = _RE_TEST_EMPTY.match(body) + if m: + expr_val, a, b = m.group(1), m.group(2), m.group(3) + if all(is_safe_for_expand(v) for v in (expr_val, a, b)): + return ( + f'%{{lua:print(rpm.expand("{lua_string_escape(expr_val)}") == ""' + f' and "{lua_string_escape(a)}" or "{lua_string_escape(b)}")}}' + ) + + # --- printf %.Ns MACRO → %{sub MACRO, 1, N} --- + m = _RE_PRINTF_TRUNC.match(body) + if m: + n = int(m.group(1)) + expr = normalize_macro(m.group(2)) + if expr is not None: + return f"%{{sub {expr}, 1, {n}}}" + + # --- printf %.Nf MACRO → Lua string.format --- + m = _RE_PRINTF_FLOAT.match(body) + if m: + n = int(m.group(1)) + expr = normalize_macro(m.group(2)) + if expr is not None: + return ( + f'%{{lua:print(string.format("%%.{n}f", ' + f'tonumber(rpm.expand("{lua_string_escape(expr)}"))))}}' + ) + + # --- var=MACRO; arr=(${var//DELIM/ }); echo ${arr[N]} → field extraction --- + m = _RE_BASH_ARRAY_FIELD.match(body) + if m: + expr = normalize_macro(m.group(2)) + if expr is not None: + delim = m.group(4) + field = int(m.group(5)) + 1 + return build_lua_field(expr, delim, field) + + # --- echo %{macro} EXTRA (simple concatenation, no pipe) --- + m = _RE_ECHO_CONCAT.match(body) + if m and "|" not in m.group(2): + expr = normalize_macro(m.group(1)) + extra = m.group(2).strip() + if expr is not None and is_safe_for_expand(extra): + return f"{expr} {extra}" + + return "%{nil}" + + @staticmethod + def is_lua_safe(code): + def has_safe_format_specs(fmt): + """ + Check that a format string only uses safe specifiers. + + Accounts for RPM ``%%`` → ``%`` expansion before checking + Lua ``string.format`` specifiers. Allows numeric types + and ``%%``; blocks ``%c``, ``%s``, ``%q`` and anything + else that could produce a ``%`` character. + """ + expanded = fmt.replace("%%", "%") + cleaned = _SAFE_FORMAT_SPEC_RE.sub("", expanded) + return "%" not in cleaned + + stripped = _strip_lua_comments(code) + string_spans = [] + for m in _LUA_STRING_LITERAL_RE.finditer(stripped): + content = m.group(0)[1:-1] + if _UNSAFE_LUA_STRING_CONTENT_RE.search(content): + return False + decoded = _decode_lua_escapes(content) + if _UNSAFE_LUA_STRING_CONTENT_RE.search(decoded): + return False + if decoded.endswith("%"): + return False + string_spans.append((m.start(), m.end())) + for m in _FORMAT_CALL_RE.finditer(stripped): + if any(s <= m.start() < e for s, e in string_spans): + continue + fmt_match = _FORMAT_LITERAL_ARG_RE.match(stripped, m.end()) + if not fmt_match or not has_safe_format_specs(fmt_match.group(1)): + return False + stripped = _LUA_STRING_LITERAL_RE.sub('""', stripped) + if _UNSAFE_LUA_BRACKET_RE.search(stripped): + return False + if _LUA_COLON_UNSAFE_METHOD_RE.search(stripped): + return False + for m in _RPM_EXPAND_REF_RE.finditer(stripped): + if not _RPM_EXPAND_SAFE_CALL_RE.match(stripped, m.end()): + return False + safe_spans = [] + for m in _LUA_DOTTED_RE.finditer(stripped): + mod, member = m.group(1), m.group(2) + if mod in _SAFE_LUA_DOTTED_MODULES: + if mod == "string" and member in ( + "dump", + "char", + "sub", + "find", + "reverse", + "rep", + ): + return False + if mod == "string" and member == "format": + rest = stripped[m.end() :].lstrip() + if not rest.startswith("("): + return False + safe_spans.append((m.start(), m.end())) + continue + if f"{mod}.{member}" in _SAFE_LUA_DOTTED: + safe_spans.append((m.start(), m.end())) + continue + return False + for m in _LUA_IDENT_RE.finditer(stripped): + if m.group(1) in _UNSAFE_LUA_IDENTIFIERS: + if not any(s <= m.start() and m.end() <= e for s, e in safe_spans): + return False + return True + + _MAX_SANITIZE_DEPTH = 64 + + @classmethod + def sanitize(cls, value: str, _depth: int = 0) -> Tuple[str, int, int]: + """ + Sanitizes a spec file content or an expression by removing shell expansions + and impure Lua macros, replacing them with safe equivalents or `%{nil}`. + + Also removes `%include`, `%load` and `%uncompress` directives since + they reference external files or run external commands. + + Args: + value: String to be sanitized, can be a spec file content + or an arbitrary macro expression. + + Returns: + Tuple of (sanitized string, number of converted shell expansions, + number of removed unsafe constructs). + """ + if _depth >= cls._MAX_SANITIZE_DEPTH: + return "%{nil}", 0, 1 + + converted = 0 + removed = 0 + + def is_name_safe(name): + try: + sanitized, _, _ = cls.sanitize(name, _depth + 1) + except UnterminatedMacroException: + return False + return sanitized == name + + def sanitize_nodes(nodes): + nonlocal converted, removed + result = [] + i = 0 + while i < len(nodes): + node = nodes[i] + if isinstance( + node, (MacroSubstitution, EnclosedMacroSubstitution, BuiltinMacro) + ) and node.name in ("include", "load", "uncompress"): + removed += 1 + # %include/%load followed by whitespace and argument + if isinstance(node, MacroSubstitution): + i += 1 + while i < len(nodes): + s = str(nodes[i]) + i += 1 + m = _UNESCAPED_NEWLINE_RE.search(s) + if m: + result.append(s[m.start() :]) + break + else: + i += 1 + continue + if isinstance(node, ConditionalMacroExpansion): + if not is_name_safe(node.name): + removed += 1 + result.append("%{nil}") + else: + body = sanitize_nodes(node.body) + result.append(f"%{{{node.prefix}{node.name}:{body}}}") + elif isinstance(node, ExpressionExpansion): + sanitized_body, c, r = cls.sanitize(node.body, _depth + 1) + converted += c + removed += r + m = _EXPRESSION_LUA_PREFIX_RE.match(sanitized_body) + if m: + lua_code = sanitized_body[m.end() :] + if not cls.is_lua_safe(lua_code): + removed += 1 + result.append("%{nil}") + else: + result.append(f"%[{sanitized_body}]") + else: + result.append(f"%[{sanitized_body}]") + elif type(node) is ShellExpansion: + replacement = cls.sanitize_shell_expansion(node.body) + if replacement == "%{nil}": + removed += 1 + elif ( + replacement.startswith("%{lua:") + and replacement.endswith("}") + and not cls.is_lua_safe(replacement[6:-1]) + ): + replacement = "%{nil}" + removed += 1 + else: + converted += 1 + result.append(replacement) + elif isinstance(node, BuiltinMacro): + if not is_name_safe(node.name): + removed += 1 + result.append("%{nil}") + elif node.name == "lua": + if not cls.is_lua_safe(node.body): + removed += 1 + result.append("%{nil}") + else: + result.append(str(node)) + else: + sanitized_body, c, r = cls.sanitize(node.body, _depth + 1) + converted += c + removed += r + result.append(f"%{{{node.name}:{sanitized_body}}}") + elif isinstance(node, EnclosedMacroSubstitution): + if not is_name_safe(node.name): + removed += 1 + result.append("%{nil}") + elif node.args: + sanitized_args = [] + for arg in node.args: + try: + sanitized_arg, c, r = cls.sanitize(arg, _depth + 1) + except UnterminatedMacroException: + sanitized_arg = "%{nil}" + c, r = 0, 1 + converted += c + removed += r + sanitized_args.append(sanitized_arg) + args_str = " " + " ".join(sanitized_args) + result.append(f"%{{{node.prefix}{node.name}{args_str}}}") + else: + result.append(str(node)) + else: + result.append(str(node)) + i += 1 + return "".join(result) + + sanitized = sanitize_nodes(ValueParser.parse(value)) + return sanitized, converted, removed diff --git a/specfile/specfile.py b/specfile/specfile.py index 112f29f9..f9491e8d 100644 --- a/specfile/specfile.py +++ b/specfile/specfile.py @@ -40,6 +40,7 @@ ) from specfile.macros import Macro, Macros from specfile.prep import Prep +from specfile.sanitizer import Sanitizer from specfile.sections import Section, Sections from specfile.sourcelist import Sourcelist from specfile.sources import Patches, Sources @@ -76,6 +77,7 @@ def __init__( autosave: bool = False, macros: Optional[List[Tuple[str, Optional[str]]]] = None, force_parse: bool = False, + sanitize: bool = False, ) -> None: """ Initializes a specfile object. You can specify either a path to the spec file, @@ -93,6 +95,8 @@ def __init__( sources required to be present at parsing time are not available. Such sources include sources referenced from shell expansions in tag values and sources included using the _%include_ directive. + sanitize: Whether to remove potentially unsafe constructs such as shell expansions + and impure Lua macros before parsing and macro expansion. """ # count mutually exclusive arguments if sum([file is not None, path is not None, content is not None]) > 1: @@ -115,9 +119,10 @@ def __init__( "`sourcedir` is required when providing `content` or file object without a name" ) self.autosave = autosave + self.sanitize = sanitize self._lines, self._trailing_newline = self._read_lines(self._file) self._parser = SpecParser(Path(sourcedir), macros, force_parse) - self._parser.parse(str(self)) + self._parse(str(self)) self._dump_debug_info("After initial parsing") def __eq__(self, other: object) -> bool: @@ -125,6 +130,7 @@ def __eq__(self, other: object) -> bool: return NotImplemented return ( self.autosave == other.autosave + and self.sanitize == other.sanitize and self.path == other.path and self._lines == other._lines and self._parser == other._parser @@ -134,7 +140,7 @@ def __eq__(self, other: object) -> bool: def __repr__(self) -> str: return ( f"Specfile({self.path!r}, {self._parser.sourcedir!r}, {self.autosave!r}, " - f"{self._parser.macros!r}, {self._parser.force_parse!r})" + f"{self._parser.macros!r}, {self._parser.force_parse!r}, {self.sanitize!r})" ) def __str__(self) -> str: @@ -192,6 +198,18 @@ def _dump_debug_info(self, message) -> None: f" {self._parser.spec!r} @ 0x{id(self._parser.spec):012x}" ) + def _parse( + self, + content: str, + extra_macros: Optional[List[Tuple[str, Optional[str]]]] = None, + ) -> None: + removed_constructs = 0 + if self.sanitize: + content, _, removed_constructs = Sanitizer.sanitize(content) + self._parser.parse(content, extra_macros) + if removed_constructs > 0: + self._parser.tainted = True + @classmethod def _read_lines(cls, file: IO) -> Tuple[List[str], bool]: file.seek(0) @@ -256,14 +274,14 @@ def tainted(self) -> bool: sources required to be present at parsing time were not available and were replaced with dummy files. """ - self._parser.parse(str(self)) + self._parse(str(self)) return self._parser.tainted @property def rpm_spec(self) -> rpm.spec: """Underlying `rpm.spec` instance.""" self._dump_debug_info("`rpm_spec` property, before parsing") - self._parser.parse(str(self)) + self._parse(str(self)) self._dump_debug_info("`rpm_spec` property, after parsing") return self._parser.spec @@ -306,7 +324,9 @@ def expand( Expanded expression. """ if not skip_parsing or extra_macros is not None: - self._parser.parse(str(self), extra_macros) + self._parse(str(self), extra_macros) + if self.sanitize: + expression, _, _ = Sanitizer.sanitize(expression) return Macros.expand(expression) def get_active_macros(self) -> List[Macro]: @@ -319,7 +339,7 @@ def get_active_macros(self) -> List[Macro]: Returns: List of `Macro` objects. """ - self._parser.parse(str(self)) + self._parse(str(self)) return Macros.dump() @ContextManager diff --git a/tests/constants.py b/tests/constants.py index 97c92651..47419d0f 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -21,5 +21,6 @@ SPEC_NO_TRAILING_NEWLINE = DATA_DIR / "spec_no_trailing_newline" SPEC_CONDITIONALIZED_CHANGELOG = DATA_DIR / "spec_conditionalized_changelog" SPEC_CONDITIONALIZED_VERSION = DATA_DIR / "spec_conditionalized_version" +SPEC_UNSAFE = DATA_DIR / "spec_unsafe" SPECFILE = "test.spec" diff --git a/tests/data/spec_unsafe/test.spec b/tests/data/spec_unsafe/test.spec new file mode 100644 index 00000000..2b4ceb64 --- /dev/null +++ b/tests/data/spec_unsafe/test.spec @@ -0,0 +1,23 @@ +%global upstream_version 2.18.4 +%global upstream_majorver %(v=%{upstream_version}; echo ${v%%%%.*}) +%global upstream_majorminorver %(echo %{upstream_version} | cut -d. -f1-2) +%global upstream_patchver %(awk -F. '{print $3}' <<< %{upstream_version}) +%global patchver %(echo $((%{upstream_patchver}+2))) +%global datestring %(date +%Y%m%d) + + +Name: test +Version: %{upstream_majorver}.%{?upstream_minorver}%{!?upstream_minorver:0}.%{patchver}^%{datestring} +Release: 1%{?dist} +Summary: Test package + +License: MIT + + +%description +Test package + + +%changelog +* Thu Jun 07 2018 Nikola Forró - 2.0.6^20180607-1 +- first version diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index dace7ba2..ce99bb38 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -23,6 +23,7 @@ SPEC_RPMAUTOSPEC, SPEC_SHELL_EXPANSIONS, SPEC_TRADITIONAL, + SPEC_UNSAFE, SPECFILE, ) @@ -139,6 +140,13 @@ def spec_conditionalized_version(tmp_path): return specfile_path +@pytest.fixture(scope="function") +def spec_unsafe(tmp_path): + specfile_path = tmp_path / SPECFILE + shutil.copyfile(SPEC_UNSAFE / SPECFILE, specfile_path) + return specfile_path + + @pytest.fixture( params=[ "file_path", diff --git a/tests/integration/test_specfile.py b/tests/integration/test_specfile.py index fd69c3a7..26411243 100644 --- a/tests/integration/test_specfile.py +++ b/tests/integration/test_specfile.py @@ -755,3 +755,24 @@ def test_save_after_inode_change(specfile_factory, spec_minimal): for line in spec_minimal.read_text().splitlines() if line.startswith("Version:") ) + + +@pytest.mark.skipif( + rpm.__version__ < "4.16", + reason="expression expansions require rpm 4.16 or higher", +) +def test_sanitize(specfile_factory, spec_unsafe): + spec1 = specfile_factory(spec_unsafe) + spec2 = specfile_factory(spec_unsafe, sanitize=True) + assert not spec2.tainted + assert spec1.version == spec2.version + assert spec1.expanded_version == spec2.expanded_version + for expr in [ + "%(whoami)", + "%{expand:%(whoami)}", + "%{shrink:%(whoami)}", + "%{lua:print(os.execute('cat /etc/fstab'))}", + "%{lua:for line in io.lines('/etc/os-release') do print(line .. '\\n') end}", + ]: + assert spec1.expand(expr) != "" + assert spec2.expand(expr) == "" diff --git a/tests/unit/test_sanitizer.py b/tests/unit/test_sanitizer.py new file mode 100644 index 00000000..b00fafb9 --- /dev/null +++ b/tests/unit/test_sanitizer.py @@ -0,0 +1,1307 @@ +# Copyright Contributors to the Packit project. +# SPDX-License-Identifier: MIT + +import pytest + +from specfile.sanitizer import Sanitizer + + +@pytest.mark.parametrize( + "body, expected", + [ + ( + "c=%{commit}; echo ${c:0:7}", + "%{sub %{commit}, 1, 7}", + ), + ( + "c=%{commit};echo ${c:0:7}", + "%{sub %{commit}, 1, 7}", + ), + ( + "c=%{commit0}; echo ${c:0:7}", + "%{sub %{commit0}, 1, 7}", + ), + ( + "foo=%{version}; echo ${foo:0:5}", + "%{sub %{version}, 1, 5}", + ), + ( + "foo=%{version}; echo ${foo:6}", + "%{sub %{version}, 7}", + ), + ( + "l=%{_lib}; echo ${l:3}", + "%{sub %{_lib}, 4}", + ), + ( + "c=%{version}; echo ${c:12:4}", + "%{sub %{version}, 13, 16}", + ), + ( + "c=%{commit}; echo ${c:0:%{commit_abbrev}}", + "%{sub %{commit}, 1, %{commit_abbrev}}", + ), + ( + 'c="%{git_commit}"; echo "${c:0:8}"', + "%{sub %{git_commit}, 1, 8}", + ), + ( + "n=%{modname}; echo ${n:0:1}", + "%{sub %{modname}, 1, 1}", + ), + ], +) +def test_substring_extraction(body, expected): + assert Sanitizer.sanitize_shell_expansion(body) == expected + + +@pytest.mark.parametrize( + "body, expected", + [ + ( + "v=%{version}; echo ${v//./_}", + '%{lua:print((rpm.expand("%{version}"):gsub("%.", "_")))}', + ), + ( + "v=%{version}; echo ${v//./}", + '%{lua:print((rpm.expand("%{version}"):gsub("%.", "")))}', + ), + ( + "n=%{name}; echo ${n//-/_}", + '%{lua:print((rpm.expand("%{name}"):gsub("%-", "_")))}', + ), + ( + 'b=%{built_tag_strip}; echo ${b/-/"~"}', + '%{lua:print((rpm.expand("%{built_tag_strip}"):gsub("%-", "~", 1)))}', + ), + ( + "n=%{srcname}; echo ${n//-/.}", + '%{lua:print((rpm.expand("%{srcname}"):gsub("%-", ".")))}', + ), + ( + "v=%{version}; echo ${v//./-}", + '%{lua:print((rpm.expand("%{version}"):gsub("%.", "-")))}', + ), + ( + "d=%{inkscape_date}; echo ${d//-/}", + '%{lua:print((rpm.expand("%{inkscape_date}"):gsub("%-", "")))}', + ), + ( + 'rel="%{releasenum}"; echo "${rel//-/}"', + '%{lua:print((rpm.expand("%{releasenum}"):gsub("%-", "")))}', + ), + # % in replacement must be escaped as %% for Lua gsub + ( + "v=%{version}; echo ${v//./%2F}", + '%{lua:print((rpm.expand("%{version}"):gsub("%.", "%%2F")))}', + ), + ], +) +def test_bash_replacement(body, expected): + assert Sanitizer.sanitize_shell_expansion(body) == expected + + +@pytest.mark.parametrize( + "body, expected", + [ + ( + 't="%{Bg_Name}";echo ${t,,}', + "%{lower:%{Bg_Name}}", + ), + ( + "v=%{name}; echo ${v^^}", + "%{upper:%{name}}", + ), + ], +) +def test_case_conversion(body, expected): + assert Sanitizer.sanitize_shell_expansion(body) == expected + + +@pytest.mark.parametrize( + "body, expected", + [ + ( + "ver=%{version}; echo ${ver%%%%.*}", + '%{lua:local v=rpm.expand("%{version}") print(v:match("^(.-)%.") or v)}', + ), + ( + "v=%{version}; echo ${v%.*}", + '%{lua:local v=rpm.expand("%{version}") print(v:match("^(.*)%.") or v)}', + ), + # bare * with longest match → empty string + ( + "v=%{version}; echo ${v%%%%*}", + '%{lua:print("")}', + ), + # bare * with shortest match → original value + ( + "v=%{version}; echo ${v%*}", + '%{lua:print(rpm.expand("%{version}"))}', + ), + ], +) +def test_suffix_removal(body, expected): + assert Sanitizer.sanitize_shell_expansion(body) == expected + + +@pytest.mark.parametrize( + "body, expected", + [ + ( + "v=%{version}; echo ${v##*.}", + '%{lua:local v=rpm.expand("%{version}") print(v:match(".*%.(.*)") or v)}', + ), + ( + "v=%{version}; echo ${v#*.}", + '%{lua:local v=rpm.expand("%{version}") print(v:match("%.(.*)") or v)}', + ), + # pattern containing # + ( + "v=%{version}; echo ${v#*#}", + '%{lua:local v=rpm.expand("%{version}") print(v:match("#(.*)") or v)}', + ), + ( + "v=%{version}; echo ${v##*#}", + '%{lua:local v=rpm.expand("%{version}") print(v:match(".*#(.*)") or v)}', + ), + # bare * with longest match → empty string + ( + "v=%{version}; echo ${v##*}", + '%{lua:print("")}', + ), + # bare * with shortest match → original value + ( + "v=%{version}; echo ${v#*}", + '%{lua:print(rpm.expand("%{version}"))}', + ), + ], +) +def test_prefix_removal(body, expected): + assert Sanitizer.sanitize_shell_expansion(body) == expected + + +@pytest.mark.parametrize( + "body, expected", + [ + ( + "echo %{version} | cut -d. -f3", + '%{lua:local v=rpm.expand("%{version}") local i=0' + ' for f in v:gmatch("[^%.]+") do i=i+1' + " if i==3 then print(f) break end end}", + ), + ( + "echo %{version} | cut -d. -f1-2", + '%{lua:local v=rpm.expand("%{version}") local t={}' + ' for f in v:gmatch("[^%.]+") do t[#t+1]=f end' + ' print(table.concat(t,".",1,2))}', + ), + ( + "echo %{version} | cut -d. -f1", + '%{lua:local v=rpm.expand("%{version}") local i=0' + ' for f in v:gmatch("[^%.]+") do i=i+1' + " if i==1 then print(f) break end end}", + ), + ( + "echo %{version} | cut -d. -f1-2", + '%{lua:local v=rpm.expand("%{version}") local t={}' + ' for f in v:gmatch("[^%.]+") do t[#t+1]=f end' + ' print(table.concat(t,".",1,2))}', + ), + ( + "echo %{version} | cut -d. -f-2", + '%{lua:local v=rpm.expand("%{version}") local t={}' + ' for f in v:gmatch("[^%.]+") do t[#t+1]=f end' + ' print(table.concat(t,".",1,2))}', + ), + ( + "echo %{version} | cut -d '^' -f 1", + '%{lua:local v=rpm.expand("%{version}") local i=0' + ' for f in v:gmatch("[^%^]+") do i=i+1' + " if i==1 then print(f) break end end}", + ), + ( + "echo %{version} | cut -d'-' -f 1", + '%{lua:local v=rpm.expand("%{version}") local i=0' + ' for f in v:gmatch("[^%-]+") do i=i+1' + " if i==1 then print(f) break end end}", + ), + ( + "echo %{version} | cut -d. -f1-3", + '%{lua:local v=rpm.expand("%{version}") local t={}' + ' for f in v:gmatch("[^%.]+") do t[#t+1]=f end' + ' print(table.concat(t,".",1,3))}', + ), + ], +) +def test_pipe_to_cut(body, expected): + assert Sanitizer.sanitize_shell_expansion(body) == expected + + +@pytest.mark.parametrize( + "body, expected", + [ + ( + "echo %{git_commit} | cut -c -8", + "%{sub %{git_commit}, 1, 8}", + ), + ( + "echo %{gitcommit} | cut -c 1-8", + "%{sub %{gitcommit}, 1, 8}", + ), + ( + "echo %{snapshot_rev} | cut -c1-6", + "%{sub %{snapshot_rev}, 1, 6}", + ), + ], +) +def test_pipe_to_cut_bytes(body, expected): + assert Sanitizer.sanitize_shell_expansion(body) == expected + + +@pytest.mark.parametrize( + "body, expected", + [ + ( + "echo \"%{name}\" | tr '[:upper:]' '[:lower:]'", + "%{lower:%{name}}", + ), + ( + "echo %{upstream_prever} | tr '[:upper:]' '[:lower:]'", + "%{lower:%{upstream_prever}}", + ), + ( + "echo %{version} | tr . _", + '%{lua:print((rpm.expand("%{version}"):gsub("%.", "_")))}', + ), + ( + "echo '%{version}' | tr '^' '.'", + '%{lua:print((rpm.expand("%{version}"):gsub("%^", ".")))}', + ), + ( + "echo '%{version}' | tr -d .", + '%{lua:print((rpm.expand("%{version}"):gsub("%.", "")))}', + ), + ( + "echo '%{version}' | tr -d '~'", + '%{lua:print((rpm.expand("%{version}"):gsub("~", "")))}', + ), + ( + "echo %{date} | tr -d -", + '%{lua:print((rpm.expand("%{date}"):gsub("%-", "")))}', + ), + ], +) +def test_pipe_to_tr(body, expected): + assert Sanitizer.sanitize_shell_expansion(body) == expected + + +@pytest.mark.parametrize( + "body, expected", + [ + ( + "echo %{version} | sed 's/\\./-/g'", + '%{lua:print((rpm.expand("%{version}"):gsub("%.", "-")))}', + ), + ( + "echo %{version} | sed 's|\\.|_|g'", + '%{lua:print((rpm.expand("%{version}"):gsub("%.", "_")))}', + ), + ( + "echo %{py3_shebang_flags} | sed 's|s||'", + '%{lua:print((rpm.expand("%{py3_shebang_flags}"):gsub("s", "", 1)))}', + ), + ( + 'echo %{version} | sed "s/\\./_/g"', + '%{lua:print((rpm.expand("%{version}"):gsub("%.", "_")))}', + ), + ( + "echo %{unversion} | sed 's/_/./g'", + '%{lua:print((rpm.expand("%{unversion}"):gsub("_", ".")))}', + ), + ], +) +def test_pipe_to_sed(body, expected): + assert Sanitizer.sanitize_shell_expansion(body) == expected + + +def test_chained_sed(): + assert Sanitizer.sanitize_shell_expansion( + "echo %{tag} | sed -e 's|.00$||' | sed -e 's|\\.||g'" + ) == ( + '%{lua:local v=(rpm.expand("%{tag}"):gsub(".00$", "", 1))' + ' print((v:gsub("%.", "")))}' + ) + + +@pytest.mark.parametrize( + "body, expected", + [ + ( + "echo %{version} | awk -F. '{print $1\".\"$2}'", + '%{lua:local v=rpm.expand("%{version}") local t={}' + ' for f in v:gmatch("[^%.]+") do t[#t+1]=f end' + ' print(t[1] .. "." .. t[2])}', + ), + ( + "echo %{version} | awk -F. '{print $1}'", + '%{lua:local v=rpm.expand("%{version}") local t={}' + ' for f in v:gmatch("[^%.]+") do t[#t+1]=f end' + " print(t[1])}", + ), + ], +) +def test_pipe_to_awk(body, expected): + assert Sanitizer.sanitize_shell_expansion(body) == expected + + +@pytest.mark.parametrize( + "body, expected", + [ + ( + "tr . _ <<< %{version}", + '%{lua:print((rpm.expand("%{version}"):gsub("%.", "_")))}', + ), + ( + "tr - . <<< %{upstreamver}", + '%{lua:print((rpm.expand("%{upstreamver}"):gsub("%-", ".")))}', + ), + ( + "cut -d. -f1 <<< %{version}", + '%{lua:local v=rpm.expand("%{version}") local i=0' + ' for f in v:gmatch("[^%.]+") do i=i+1' + " if i==1 then print(f) break end end}", + ), + ( + "cut -d. -f1-2 <<< %{version}", + '%{lua:local v=rpm.expand("%{version}") local t={}' + ' for f in v:gmatch("[^%.]+") do t[#t+1]=f end' + ' print(table.concat(t,".",1,2))}', + ), + ( + "cut -b -7 <<< %{emacscommit}", + "%{sub %{emacscommit}, 1, 7}", + ), + ( + "tr -d . <<< %{version}", + '%{lua:print((rpm.expand("%{version}"):gsub("%.", "")))}', + ), + ( + "sed 's/\\.//g' <<<%{version}", + '%{lua:print((rpm.expand("%{version}"):gsub("%.", "")))}', + ), + ], +) +def test_herestring(body, expected): + assert Sanitizer.sanitize_shell_expansion(body) == expected + + +@pytest.mark.parametrize( + "body, expected", + [ + ( + "c=%{version}; echo $c | cut -d. -f1", + '%{lua:local v=rpm.expand("%{version}") local i=0' + ' for f in v:gmatch("[^%.]+") do i=i+1' + " if i==1 then print(f) break end end}", + ), + ( + "v=%{version}; tr . _ <<< $v", + '%{lua:print((rpm.expand("%{version}"):gsub("%.", "_")))}', + ), + ], +) +def test_variable_indirection(body, expected): + assert Sanitizer.sanitize_shell_expansion(body) == expected + + +@pytest.mark.parametrize( + "body, expected", + [ + ( + "foo=%{version}; a=(${foo//./ }); echo ${a[0]} ", + '%{lua:local v=rpm.expand("%{version}") local i=0' + ' for f in v:gmatch("[^%.]+") do i=i+1' + " if i==1 then print(f) break end end}", + ), + ( + "foo=%{version}; a=(${foo//./ }); echo ${a[1]} ", + '%{lua:local v=rpm.expand("%{version}") local i=0' + ' for f in v:gmatch("[^%.]+") do i=i+1' + " if i==2 then print(f) break end end}", + ), + ], +) +def test_bash_array_field(body, expected): + assert Sanitizer.sanitize_shell_expansion(body) == expected + + +@pytest.mark.parametrize( + "body, expected", + [ + ( + 'date +"%Y%m%d"', + '%{lua:print(os.date("%Y%m%d"))}', + ), + ( + "date +'%Y%m%d'", + '%{lua:print(os.date("%Y%m%d"))}', + ), + ( + "date -u +'%Y-%m-%dT%H:%M:%SZ'", + '%{lua:print(os.date("!%Y-%m-%dT%H:%M:%SZ"))}', + ), + ( + 'date +"%Y-%d-%m"', + '%{lua:print(os.date("%Y-%d-%m"))}', + ), + ], +) +def test_date_formatting(body, expected): + assert Sanitizer.sanitize_shell_expansion(body) == expected + + +@pytest.mark.parametrize( + "body, expected", + [ + ("echo $((%{__isa_bits}+0))", "%[%{__isa_bits}+0]"), + ("echo $((%{__isa_bits}+2))", "%[%{__isa_bits}+2]"), + ("echo $((%{ver_minor}+1))", "%[%{ver_minor}+1]"), + ("echo $((%{sover}-1))", "%[%{sover}-1]"), + (" echo $(( %majorversion + 1 )) ", "%[%majorversion + 1]"), + ], +) +def test_arithmetic(body, expected): + assert Sanitizer.sanitize_shell_expansion(body) == expected + + +@pytest.mark.parametrize( + "body, expected", + [ + ( + "basename %{_python3_include}", + '%{lua:print((rpm.expand("%{_python3_include}"):match("[^/]+$")))}', + ), + ( + "dirname %{bashcompdir}", + '%{lua:print((rpm.expand("%{bashcompdir}"):match("^(.*)/")))}', + ), + ( + "dirname %{compdir}", + '%{lua:print((rpm.expand("%{compdir}"):match("^(.*)/")))}', + ), + ], +) +def test_basename_dirname(body, expected): + assert Sanitizer.sanitize_shell_expansion(body) == expected + + +def test_string_comparison_not_equal(): + assert Sanitizer.sanitize_shell_expansion( + 'test "%{_libdir}" != "/usr/lib" && echo 1 || echo 0' + ) == ( + '%{lua:print(rpm.expand("%{_libdir}") ~= rpm.expand("/usr/lib")' + ' and "1" or "0")}' + ) + + +def test_string_comparison_equal(): + assert Sanitizer.sanitize_shell_expansion( + 'test "%{OTHER}" == "1" && echo fedora || echo redhat' + ) == ( + '%{lua:print(rpm.expand("%{OTHER}") == rpm.expand("1")' + ' and "fedora" or "redhat")}' + ) + + +def test_empty_test(): + assert Sanitizer.sanitize_shell_expansion( + '[ -z "%{?flag}" ] && echo A || echo B' + ) == ('%{lua:print(rpm.expand("%{?flag}") == ""' ' and "A" or "B")}') + + +def test_printf_truncation(): + assert ( + Sanitizer.sanitize_shell_expansion("printf %%.7s %commit") + == "%{sub %{commit}, 1, 7}" + ) + + +def test_printf_float(): + assert Sanitizer.sanitize_shell_expansion( + 'LANG=C printf "%.4f" %{cpan_version}' + ) == ( + '%{lua:print(string.format("%%.4f",' + ' tonumber(rpm.expand("%{cpan_version}"))))}' + ) + + +@pytest.mark.parametrize( + "body, expected", + [ + ( + "echo %{optflags} -fno-strict-aliasing", + "%{optflags} -fno-strict-aliasing", + ), + ( + "echo %{build_ldflags} -fuse-ld=lld", + "%{build_ldflags} -fuse-ld=lld", + ), + ( + "echo %{optflags} -D_DEFAULT_SOURCE", + "%{optflags} -D_DEFAULT_SOURCE", + ), + ( + "echo %{optflags} -Wno-error=dangling-reference", + "%{optflags} -Wno-error=dangling-reference", + ), + ], +) +def test_echo_concat(body, expected): + assert Sanitizer.sanitize_shell_expansion(body) == expected + + +def test_cut_bytes_with_macro_offset(): + assert ( + Sanitizer.sanitize_shell_expansion("cut -b %{rmprefix}- <<<'%{_bindir}'") + == "%{sub %{_bindir}, %{rmprefix}}" + ) + + +@pytest.mark.parametrize( + "body", + [ + "octave-config -p VERSION || echo 0", + "python3-config --abiflags", + "pkg-config --modversion qwt", + "/usr/bin/getconf _NPROCESSORS_ONLN", + "uname -m", + "hostname", + "id -un", + "mktemp --directory", + "ruby -rrbconfig -e \"puts RbConfig::CONFIG['vendorlibdir']\"", + ], +) +def test_unconvertible_returns_nil(body): + assert Sanitizer.sanitize_shell_expansion(body) == "%{nil}" + + +@pytest.mark.parametrize( + "code", + [ + 'print(rpm.expand("%{version}"))', + 'print((rpm.expand("%{version}"):gsub("%.", "_")))', + 'print(os.date("%Y%m%d"))', + 'print(os.date("!%Y-%m-%dT%H:%M:%SZ"))', + "print(os.clock())", + "print(os.time())", + "print(os.difftime(os.time(), 0))", + 'local v=rpm.expand("%{version}") print(v:match("^(.-)%.") or v)', + 'local v=rpm.expand("%{version}") local t={}' + ' for f in v:gmatch("[^%.]+") do t[#t+1]=f end' + ' print(table.concat(t,".",1,2))', + 'print(string.len("hello"))', + "print(math.floor(3.7))", + "print(math.max(1, 2, 3))", + 'print(table.concat({"a", "b"}, ","))', + "print(tostring(42))", + 'print(tonumber("42"))', + 'print(type("hello"))', + "local x = 1 print(x)", + 'print(string.format("%.4f", tonumber(rpm.expand("%{version}"))))', + 'print(string.format("%d", 42))', + 'print(string.format("%%.4f", tonumber(rpm.expand("%{version}"))))', + # Comments inside safe code + 'print(rpm.expand("%{version}")) -- a comment', + "local x = 1 --[[ block comment ]] print(x)", + ], +) +def test_lua_safe_code(code): + assert Sanitizer.is_lua_safe(code) + + +@pytest.mark.parametrize( + "code", + [ + 'os.execute("id")', + 'os.remove("/tmp/foo")', + 'os.rename("/tmp/a", "/tmp/b")', + "os.exit(0)", + 'io.open("/etc/passwd")', + 'io.popen("id")', + 'io.lines("/etc/passwd")', + "io.tmpfile()", + 'require("os")', + 'dofile("/tmp/evil.lua")', + 'loadfile("/tmp/evil.lua")', + "loadstring(\"os.execute('id')\")()", + "load(\"os.execute('id')\")()", + "debug.getinfo(1)", + "debug.getregistry()", + 'package.loadlib("/lib/libc.so.6", "system")', + "collectgarbage()", + "coroutine.create(function() end)", + 'rpm.define("evil_macro 1")', + 'rpm.undefine("Name")', + "rpm.register(function() end)", + 'rpm.execute("id")', + 'rpm.redirect2macro("foo")', + 'posix.exec("/bin/sh")', + 'fedora.rpm.vercmp("1", "2")', + "string.dump(print)", + "string.char(37, 40)", + 'string.sub("hello", 1, 3)', + 'string.find("hello", "l")', + 'string.reverse("hello")', + 'string.rep("x", 5)', + 'string.format("%c", 37)', + 'string.format("%s", "hello")', + 'string.format("%q", "hello")', + "string.format(var)", + ], +) +def test_lua_unsafe_direct_calls(code): + assert not Sanitizer.is_lua_safe(code) + + +@pytest.mark.parametrize( + "code", + [ + '_G["os"]["execute"]("id")', + "_G['os']['execute']('id')", + 'rawget(_G, "os")', + 'rawset(_G, "evil", function() end)', + "rawequal(_G, _G)", + "getfenv(0)", + "setfenv(1, {})", + 'getmetatable("")', + "setmetatable({}, {})", + "newproxy(true)", + 'module("evil")', + "pcall(load, \"os.execute('id')\")", + "xpcall(load, print, \"os.execute('id')\")", + "pcall(function() end)", + "xpcall(function() end, print)", + ], +) +def test_lua_unsafe_identifiers(code): + assert not Sanitizer.is_lua_safe(code) + + +@pytest.mark.parametrize( + "code", + [ + 'local x = {}; x["os"] = true', + "local x = {}; x['cmd'] = 'id'", + 'os["\\".."]', + "os[string.char(101,120,101,99,117,116,101)]()", + "os[[[execute]]]()", + "os[var]", + ], +) +def test_lua_unsafe_bracket_notation(code): + assert not Sanitizer.is_lua_safe(code) + + +@pytest.mark.parametrize( + "code", + [ + 'local e = _G e.os.execute("id")', + "local r = rawget print(r)", + "local d = debug d.getinfo(1)", + 'local i = io i.popen("id")', + 'local l = loadstring l("print(1)")()', + 'local p = package p.loadlib("x", "y")', + ], +) +def test_lua_unsafe_variable_aliasing(code): + assert not Sanitizer.is_lua_safe(code) + + +@pytest.mark.parametrize( + "code", + [ + 'os . execute("id")', + 'os . execute("id")', + 'os\t.\texecute("id")', + ], +) +def test_lua_unsafe_spaced_dot(code): + assert not Sanitizer.is_lua_safe(code) + + +@pytest.mark.parametrize( + "code", + [ + '(os).execute("id")', + '((os)).execute("id")', + '(rpm).define("evil 1")', + "(string).dump(print)", + ], +) +def test_lua_unsafe_paren_bypass(code): + assert not Sanitizer.is_lua_safe(code) + + +@pytest.mark.parametrize( + "code", + [ + 'os:execute("id")', + "os:exit(0)", + 'rpm:define("evil 1")', + ], +) +def test_lua_unsafe_colon_access(code): + assert not Sanitizer.is_lua_safe(code) + + +@pytest.mark.parametrize( + "code", + [ + 'os.--[[comment]]execute("id")', + "os.--[[x]]exit(0)", + ], +) +def test_lua_unsafe_comment_bypass(code): + assert not Sanitizer.is_lua_safe(code) + + +@pytest.mark.parametrize( + "code", + [ + '--[=[ ]=] os.execute("id")', + '--[==[ ]==] os.execute("id")', + 'print("safe" --[=[ ]=]) os.execute("id")', + '--[===[ ]===] io.popen("id")', + ], +) +def test_lua_unsafe_long_comment_bypass(code): + assert not Sanitizer.is_lua_safe(code) + + +@pytest.mark.parametrize( + "code", + [ + 'rpm.expand("%(whoami)")', + "rpm.expand(\"%{lua:os.execute('id')}\")", + 'print(rpm.expand("%(cat /etc/passwd)"))', + "rpm.expand('%(id)')", + 'local x = rpm.expand("%(uname -a)") print(x)', + 'rpm.expand("%{load:/tmp/evil.lua}")', + 'rpm.expand("%{include:/tmp/evil.spec}")', + 'rpm.expand("%{uncompress:/tmp/file.gz}")', + 'print(rpm.expand("%{load: /tmp/evil.lua}"))', + 'rpm.expand("%{uncompress: file}")', + 'rpm.expand("%{expand:%(whoami)}")', + "rpm.expand(\"%{expand:%{lua:os.execute('id')}}\")", + 'rpm.expand("%{define:evil %(whoami)}")', + 'rpm.expand("%{global:evil %(whoami)}")', + 'rpm.expand("%{define:Name evil}")', + 'rpm.expand("%{global:_prefix /tmp/evil}")', + 'rpm.expand("%{undefine:Name}")', + 'rpm.expand("%{define :__spec_check_post exit 0}")', + 'rpm.expand("%load /tmp/evil.lua")', + 'rpm.expand("%include /tmp/evil.spec")', + 'rpm.expand("%define __spec_check_post exit 0")', + 'rpm.expand("%global _prefix /tmp/evil")', + 'rpm.expand("%undefine Name")', + ], +) +def test_lua_unsafe_rpm_expand_bypass(code): + assert not Sanitizer.is_lua_safe(code) + + +@pytest.mark.parametrize( + "code", + [ + "local e = _ENV", + '_ENV.os.execute("id")', + ], +) +def test_lua_unsafe_env(code): + assert not Sanitizer.is_lua_safe(code) + + +def test_lua_rpm_expand_shell_bypass_in_sanitize(): + value = '%{lua:print(rpm.expand("%(whoami)"))}' + sanitized, converted, removed = Sanitizer.sanitize(value) + assert sanitized == "%{nil}" + assert removed == 1 + + +def test_lua_rpm_expand_nested_lua_bypass_in_sanitize(): + value = """%{lua:rpm.expand("%{lua:os.execute('id')}")}""" + sanitized, converted, removed = Sanitizer.sanitize(value) + assert sanitized == "%{nil}" + assert removed == 1 + + +@pytest.mark.parametrize( + "code", + [ + 'rpm.expand(string.char(37, 40) .. "whoami)")', + 'rpm.expand(string.format("%%") .. "(whoami)")', + 'rpm.expand(string.reverse(")imaohw(%"))', + 'rpm.expand(("%%"):format() .. "(whoami)")', + 'rpm.expand("" .. "")', + "local f = rpm.expand", + "x = rpm.expand", + "rpm.expand(x)", + ], +) +def test_lua_unsafe_rpm_expand_dynamic_args(code): + assert not Sanitizer.is_lua_safe(code) + + +@pytest.mark.parametrize( + "value", + [ + '%{lua:rpm.expand(string.char(37, 40) .. "whoami)")}', + '%{lua:rpm.expand(string.format("%%") .. "(whoami)")}', + '%{lua:rpm.expand(string.reverse(")imaohw(%"))}', + '%{lua:rpm.expand(("%%"):format() .. "(whoami)")}', + ], +) +def test_lua_rpm_expand_string_construction_bypass_in_sanitize(value): + sanitized, converted, removed = Sanitizer.sanitize(value) + assert sanitized == "%{nil}" + assert removed == 1 + + +def test_lua_unsafe_in_sanitize(): + value = '%{lua:os.execute("id")}' + sanitized, converted, removed = Sanitizer.sanitize(value) + assert sanitized == "%{nil}" + assert removed == 1 + + +def test_lua_safe_in_sanitize(): + value = '%{lua:print(rpm.expand("%{version}"))}' + sanitized, converted, removed = Sanitizer.sanitize(value) + assert sanitized == value + assert removed == 0 + + +def test_lua_bracket_bypass_in_sanitize(): + value = '%{lua:_G["os"]["execute"]("id")}' + sanitized, converted, removed = Sanitizer.sanitize(value) + assert sanitized == "%{nil}" + assert removed == 1 + + +@pytest.mark.parametrize( + "value", + [ + '%{lua:print(rpm.expand("%{load:/tmp/evil.lua}"))}', + '%{lua:print(rpm.expand("%{include:/tmp/evil.spec}"))}', + '%{lua:print(rpm.expand("%{uncompress:/tmp/file.gz}"))}', + ], +) +def test_lua_load_include_uncompress_bypass_in_sanitize(value): + sanitized, converted, removed = Sanitizer.sanitize(value) + assert sanitized == "%{nil}" + assert removed == 1 + + +@pytest.mark.parametrize( + "value", + [ + '%{lua:print(rpm.expand("%{expand:%(whoami)}"))}', + "%{lua:print(rpm.expand(\"%{expand:%{lua:os.execute('id')}}\"))}", + '%{lua:print(rpm.expand("%{define:evil %(whoami)}"))}', + '%{lua:print(rpm.expand("%{global:evil %(whoami)}"))}', + ], +) +def test_lua_rpm_expand_define_global_bypass_in_sanitize(value): + sanitized, converted, removed = Sanitizer.sanitize(value) + assert sanitized == "%{nil}" + assert removed == 1 + + +@pytest.mark.parametrize( + "value", + [ + '%{lua:rpm.expand("%load /tmp/evil.lua")}', + '%{lua:rpm.expand("%include /tmp/evil.spec")}', + '%{lua:rpm.expand("%define __spec_check_post exit 0")}', + '%{lua:rpm.expand("%global _prefix /tmp/evil")}', + '%{lua:rpm.expand("%undefine Name")}', + ], +) +def test_lua_rpm_expand_braceless_directive_bypass_in_sanitize(value): + sanitized, converted, removed = Sanitizer.sanitize(value) + assert sanitized == "%{nil}" + assert removed == 1 + + +@pytest.mark.parametrize( + "value, expected_sanitized, expected_removed", + [ + ( + "%{expand:%(whoami)}", + "%{expand:%{nil}}", + 1, + ), + ( + "%{lower:%(whoami)}", + "%{lower:%{nil}}", + 1, + ), + ( + "%{upper:%(whoami)}", + "%{upper:%{nil}}", + 1, + ), + ( + '%{expand:%{lua:os.execute("id")}}', + "%{expand:%{nil}}", + 1, + ), + ( + "%{lower:%{name}}", + "%{lower:%{name}}", + 0, + ), + ( + "%{quote:%(cat /etc/passwd)}", + "%{quote:%{nil}}", + 1, + ), + ( + "%{uncompress:/tmp/file.gz}", + "", + 1, + ), + ], +) +def test_builtin_macro_body_sanitized(value, expected_sanitized, expected_removed): + sanitized, _, removed = Sanitizer.sanitize(value) + assert sanitized == expected_sanitized + assert removed == expected_removed + + +@pytest.mark.parametrize( + "value, expected_sanitized, expected_removed", + [ + ( + "%{upper %(whoami)}", + "%{upper %{nil}}", + 1, + ), + ( + "%{macro %(whoami) %{version}}", + "%{macro %{nil} %{version}}", + 1, + ), + ( + "%{macro %(whoami) %(id)}", + "%{macro %{nil} %{nil}}", + 2, + ), + ( + "%{foo %{version}}", + "%{foo %{version}}", + 0, + ), + ], +) +def test_enclosed_macro_args_sanitized(value, expected_sanitized, expected_removed): + sanitized, _, removed = Sanitizer.sanitize(value) + assert sanitized == expected_sanitized + assert removed == expected_removed + + +@pytest.mark.parametrize( + "body", + [ + "v=%{version}; echo ${v//./%(whoami)}", + "v=%{version}; echo ${v//./%{lua:os.execute('id')}}", + "v=%{name}; echo ${v//-/%(cat /etc/passwd)}", + ], +) +def test_bash_replace_rejects_unsafe_repl(body): + assert Sanitizer.sanitize_shell_expansion(body) == "%{nil}" + + +@pytest.mark.parametrize( + "body", + [ + 'test "a" == "a" && echo "%(whoami)" || echo "no"', + 'test "a" != "b" && echo "safe" || echo "%(id)"', + 'test "%(whoami)" == "a" && echo "yes" || echo "no"', + 'test "a" == "%(whoami)" && echo "yes" || echo "no"', + ], +) +def test_test_str_rejects_unsafe_values(body): + assert Sanitizer.sanitize_shell_expansion(body) == "%{nil}" + + +@pytest.mark.parametrize( + "body", + [ + '[ -z "%{?flag}" ] && echo "%(whoami)" || echo "safe"', + '[ -z "%{?flag}" ] && echo "safe" || echo "%(whoami)"', + ], +) +def test_test_empty_rejects_unsafe_values(body): + assert Sanitizer.sanitize_shell_expansion(body) == "%{nil}" + + +@pytest.mark.parametrize( + "body", + [ + "date +%(whoami)", + 'date +"%(whoami)%Y"', + ], +) +def test_date_rejects_unsafe_format(body): + assert Sanitizer.sanitize_shell_expansion(body) == "%{nil}" + + +@pytest.mark.parametrize( + "body", + [ + "echo %{version} | sed 's/\\./ %(whoami)/g'", + "echo %{version} | sed 's/x/%(id)/g'", + ], +) +def test_sed_rejects_unsafe_replacement(body): + assert Sanitizer.sanitize_shell_expansion(body) == "%{nil}" + + +@pytest.mark.parametrize( + "body", + [ + """echo %{version} | awk -F. '{print $1"%(whoami)"$2}'""", + ], +) +def test_awk_rejects_unsafe_separator(body): + assert Sanitizer.sanitize_shell_expansion(body) == "%{nil}" + + +@pytest.mark.parametrize( + "code", + [ + 'print("\\037(whoami)")', + 'print("\\037{lua:os.execute(\\"id\\")}")', + 'print("\\x25(whoami)")', + 'print("\\u{25}(whoami)")', + 'print("\\037\\z (whoami)")', + ], +) +def test_lua_unsafe_escape_sequence_bypass(code): + assert not Sanitizer.is_lua_safe(code) + + +@pytest.mark.parametrize( + "code", + [ + 'print(string.format("%c(whoami)", 37))', + 'print(string.format("%c", 37) .. "(whoami)")', + ], +) +def test_lua_unsafe_string_format_bypass(code): + assert not Sanitizer.is_lua_safe(code) + + +@pytest.mark.parametrize( + "code", + [ + 'print(("%c(whoami)"):format(37))', + 'print((""):format(37))', + 'print((""):char(37))', + 'print((""):dump())', + ], +) +def test_lua_unsafe_colon_method_bypass(code): + assert not Sanitizer.is_lua_safe(code) + + +@pytest.mark.parametrize( + "code", + [ + 'print("%" .. "(whoami)")', + 'print("x%" .. "(whoami)")', + 'print("\\037" .. "(whoami)")', + ], +) +def test_lua_unsafe_concatenation_bypass(code): + assert not Sanitizer.is_lua_safe(code) + + +@pytest.mark.parametrize( + "code", + [ + # string.sub can extract % from a string + 'print(string.sub("% ", 1, 1) .. "(whoami)")', + # colon variant + 'print(("% "):sub(1, 1) .. "(whoami)")', + # string.find can capture % + 'local _,_,c = string.find("% x", "^(.)"); print(c .. "(whoami)")', + # string.reverse can rearrange + 'print(string.reverse("X%") .. "(whoami)")', + # string.rep can reproduce + 'print(string.rep("% ", 1):sub(1,1) .. "(whoami)")', + ], +) +def test_lua_unsafe_string_extraction_bypass(code): + assert not Sanitizer.is_lua_safe(code) + + +@pytest.mark.parametrize( + "code", + [ + # Aliasing string.format to a variable bypasses format validation + 'local f=string.format print(f("%%%c(whoami)", 40))', + 'local f=string.format; print(f("%%%c", 40) .. "whoami)")', + # Assigning to table field + 'local t={f=string.format} print(t.f("%%%c", 40))', + ], +) +def test_lua_unsafe_string_format_alias_bypass(code): + assert not Sanitizer.is_lua_safe(code) + + +@pytest.mark.parametrize( + "code", + [ + # Comment injection: --[[ inside a string tricks regex-based comment stripping + 'x = "--[["; os.execute("id"); x = "--]]"', + # Variant with single-line comment syntax + "x = '--[['; os.execute('id'); x = '--]]'", + # With level-1 long brackets + 'x = "--[=["; os.execute("id"); x = "--]=]"', + ], +) +def test_lua_unsafe_comment_injection_bypass(code): + assert not Sanitizer.is_lua_safe(code) + + +def test_lua_safe_real_comment_not_confused_with_string(): + """Real comments should still be stripped correctly.""" + assert Sanitizer.is_lua_safe('print(rpm.expand("%{version}")) -- safe comment') + assert Sanitizer.is_lua_safe("local x = 1 --[[ block comment ]] print(x)") + + +@pytest.mark.parametrize( + "value, expected_sanitized", + [ + ("%[1+1]", "%[1+1]"), + ("%[%{version}]", "%[%{version}]"), + ( + '%[%{lua:os.execute("id")}]', + "%[%{nil}]", + ), + ( + "%[%(whoami)]", + "%[%{nil}]", + ), + # lua: prefix in expression expansion (RPM 4.16+) + ("%[lua:os.execute('id')]", "%{nil}"), + ("%[lua: os.execute('id')]", "%{nil}"), + # safe lua: expression + ( + '%[lua:rpm.expand("%{version}")]', + '%[lua:rpm.expand("%{version}")]', + ), + ], +) +def test_expression_expansion_sanitized(value, expected_sanitized): + sanitized, _, _ = Sanitizer.sanitize(value) + assert sanitized == expected_sanitized + + +@pytest.mark.parametrize( + "value, expected_sanitized", + [ + # EnclosedMacroSubstitution without args - unsafe name + ("%{%(whoami)}", "%{nil}"), + # EnclosedMacroSubstitution with args - unsafe name + ("%{%(whoami) arg1 arg2}", "%{nil}"), + # BuiltinMacro - unsafe name + ("%{%(whoami):body}", "%{nil}"), + # BuiltinMacro - nested macro in name (splits at : giving unterminated name) + ('%{%{lua:os.execute("id")}}', "%{nil}"), + # ConditionalMacroExpansion - unsafe name + ("%{?%(whoami):body}", "%{nil}"), + ("%{!%(whoami):body}", "%{nil}"), + # safe names pass through + ("%{version}", "%{version}"), + ("%{?dist}", "%{?dist}"), + ("%{?prerel:0.}", "%{?prerel:0.}"), + ], +) +def test_macro_name_sanitized(value, expected_sanitized): + sanitized, _, _ = Sanitizer.sanitize(value) + assert sanitized == expected_sanitized + + +@pytest.mark.parametrize( + "code", + [ + "print(rpm.expand(\"%[lua:os.execute('id')]\"))", + 'print("%[1+1]")', + ], +) +def test_lua_unsafe_expression_expansion_in_string(code): + assert not Sanitizer.is_lua_safe(code) + + +def test_sed_pattern_newline_escape(): + result = Sanitizer.sanitize_shell_expansion(r"echo %{desc} | sed -e 's/\n/ /g'") + assert result is not None + assert "\\n" in result + + +def test_sed_pattern_parentheses_escaped(): + result = Sanitizer.sanitize_shell_expansion("echo %{version} | sed -e 's/(dev)//g'") + assert result is not None + assert "%(" in result + assert "%)" in result + + +def test_sanitize_idempotent_printf_float(): + """Sanitizing already-sanitized printf float output should not break it.""" + first_pass = Sanitizer.sanitize('%(LANG=C printf "%.4f" %{cpan_version})')[0] + assert "string.format" in first_pass + second_pass = Sanitizer.sanitize(first_pass)[0] + assert "string.format" in second_pass + assert second_pass == first_pass + + +@pytest.mark.parametrize( + "code", + [ + # \040 = ( in Lua decimal escape + 'rpm.expand("%\\040whoami)")', + # \091 = [ , \058 = : + "rpm.expand(\"%\\091lua\\058os.execute('id')\\093\")", + # hex escapes for ( and { + 'rpm.expand("%\\x28whoami)")', + "rpm.expand(\"%\\x7blua\\x3aos.execute('id')}\")", + # unicode escapes + 'rpm.expand("%\\u{28}whoami)")', + ], +) +def test_lua_unsafe_char_escape_bypass(code): + assert not Sanitizer.is_lua_safe(code) + + +def test_decode_lua_escapes_out_of_range(): + """Decimal escapes > 255 are truncated to fit in a byte, matching Lua behavior.""" + from specfile.sanitizer import _decode_lua_escapes + + assert _decode_lua_escapes(r"\300") == "," # 300 % 256 == 44 == ',' + assert _decode_lua_escapes(r"\256") == "\x00" # 256 % 256 == 0 + assert _decode_lua_escapes(r"\512") == "\x00" # 512 % 256 == 0 + assert _decode_lua_escapes(r"\293") == "%" # 293 % 256 == 37 == '%' + + +def test_sanitize_depth_limit(): + """Deeply nested macros hit the depth limit instead of RecursionError.""" + nested = "%{version}" + for _ in range(100): + nested = f"%{{expand:{nested}}}" + sanitized, _, removed = Sanitizer.sanitize(nested) + assert removed > 0 + assert "%{nil}" in sanitized