From f74250d8a3955e18f5f7590ff3fba35fba1fc772 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Mon, 13 Apr 2026 09:56:20 -0400 Subject: [PATCH] feat: add UNDF-2026-000001266 (salt-0004) and UNDF-2026-000001267 (ansible-0004) CWE-1333 Salt pcre/grain_pcre targeting passes user-controlled regex to re.match/re.compile without timeout. Ansible inventory manager passes tilde-prefixed host patterns directly to re.compile. Both fixed with ThreadPoolExecutor 1s timeout wrapper. --- UNDF-REGISTRY.json | 4 +- ...4-inventory-regex-redos-safe-wrapper.patch | 68 ++++++++++ .../salt-0004-pcre-redos-safe-wrapper.patch | 117 ++++++++++++++++++ 3 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 defects/ansible/patch/ansible-0004-inventory-regex-redos-safe-wrapper.patch create mode 100644 defects/salt/patch/salt-0004-pcre-redos-safe-wrapper.patch diff --git a/UNDF-REGISTRY.json b/UNDF-REGISTRY.json index daddcb0cf..531546ee0 100644 --- a/UNDF-REGISTRY.json +++ b/UNDF-REGISTRY.json @@ -1263,5 +1263,7 @@ "lean4-0004": "UNDF-2026-000001262", "lean4-0005": "UNDF-2026-000001263", "lean4-0006": "UNDF-2026-000001264", - "lean4-0007": "UNDF-2026-000001265" + "lean4-0007": "UNDF-2026-000001265", + "salt-0004": "UNDF-2026-000001266", + "ansible-0004": "UNDF-2026-000001267" } diff --git a/defects/ansible/patch/ansible-0004-inventory-regex-redos-safe-wrapper.patch b/defects/ansible/patch/ansible-0004-inventory-regex-redos-safe-wrapper.patch new file mode 100644 index 000000000..40be1658c --- /dev/null +++ b/defects/ansible/patch/ansible-0004-inventory-regex-redos-safe-wrapper.patch @@ -0,0 +1,68 @@ +# UNDF: UNDF-2026-000001267 +# CWE-1333: Inefficient Regular Expression Complexity. ReDoS via ~-prefix inventory pattern. +# +# Defect: re.compile(pattern[1:]) called with user-supplied ~-prefix inventory pattern. +# Passes raw user pattern directly to the backtracking NFA engine. +# Attack vector: ansible-playbook --limit '~^(a+)+$' or ~-prefixed host in inventory/playbook. +# Stalls the controller process. +# +# Fix: wrap re.compile/re.search with a ThreadPoolExecutor 1s timeout. Fail-closed. +# A pattern that times out or fails to compile returns no matches (empty list). +# +# Complexity gate: pattern ~^(a+)+$ against input 'a'*25+'b' must complete in <3s (vs infinite without fix). +--- a/lib/ansible/inventory/manager.py ++++ b/lib/ansible/inventory/manager.py +@@ -1,7 +1,9 @@ + import fnmatch + import re + import typing as t ++import concurrent.futures ++import logging ++ ++log = logging.getLogger(__name__) ++ ++_REGEX_EXECUTOR = concurrent.futures.ThreadPoolExecutor(max_workers=2) ++_REGEX_TIMEOUT = 1.0 # seconds ++ ++ ++def _safe_regex_compile(pattern): ++ """ ++ Compile a regex pattern in a worker thread with a 1s timeout. ++ Returns the compiled pattern on success, None on timeout or re.error. ++ Fail-closed: a pattern that stalls or fails returns None. ++ """ ++ try: ++ future = _REGEX_EXECUTOR.submit(re.compile, pattern) ++ return future.result(timeout=_REGEX_TIMEOUT) ++ except concurrent.futures.TimeoutError: ++ log.warning( ++ "inventory regex compile timed out after %ss: pattern=%r", ++ _REGEX_TIMEOUT, pattern ++ ) ++ return None ++ except re.error as exc: ++ log.warning("invalid inventory regex %r: %s", pattern, exc) ++ return None ++ except Exception as exc: # pylint: disable=broad-except ++ log.warning("inventory regex error pattern=%r: %s", pattern, exc) ++ return None +@@ -330,13 +370,15 @@ class InventoryManager: + def _match_list(self, items, pattern_str): + # compile patterns + try: + if not pattern_str[0] == '~': + pattern = re.compile(fnmatch.translate(pattern_str)) + else: +- pattern = re.compile(pattern_str[1:]) ++ pattern = _safe_regex_compile(pattern_str[1:]) ++ if pattern is None: ++ raise AnsibleError('Regex pattern timed out or invalid: %s' % pattern_str) + except Exception: + raise AnsibleError('Invalid host list pattern: %s' % pattern_str) + + # apply patterns + results = [] + for item in items: + if pattern.match(item): + results.append(item) + return results diff --git a/defects/salt/patch/salt-0004-pcre-redos-safe-wrapper.patch b/defects/salt/patch/salt-0004-pcre-redos-safe-wrapper.patch new file mode 100644 index 000000000..c334f1419 --- /dev/null +++ b/defects/salt/patch/salt-0004-pcre-redos-safe-wrapper.patch @@ -0,0 +1,117 @@ +# UNDF: UNDF-2026-000001266 +# CWE-1333: Inefficient Regular Expression Complexity. ReDoS via user-controlled grain_pcre pattern. +# +# Defect: re.match/re.compile receives tgt from job payload when tgt_type=pcre or grain_pcre. +# A crafted pattern like ^(a+)+$ against adversarial input causes exponential backtracking. +# Attack vector: authenticated Salt API caller POST /run with tgt_type=pcre. +# Stalls the master process indefinitely. +# +# Fix: route re.match/re.compile through a ThreadPoolExecutor worker with 1s timeout. +# Fail-closed. timeout or re.error returns False (minion excluded from target). +# +# Complexity gate: pattern ^(a+)+$ against input 'a'*25+'b' must complete in <3s (vs infinite without fix). +--- a/salt/matchers/pcre_match.py ++++ b/salt/matchers/pcre_match.py +@@ -1,17 +1,56 @@ + """ + This is the default pcre matcher. + """ + + import re ++import concurrent.futures ++import logging ++ ++log = logging.getLogger(__name__) ++ ++_PCRE_EXECUTOR = concurrent.futures.ThreadPoolExecutor(max_workers=4) ++_PCRE_TIMEOUT = 1.0 # seconds ++ ++ ++def _safe_pcre_match(pattern, text, timeout=_PCRE_TIMEOUT): ++ """ ++ Run re.match(pattern, text) in a worker thread with a timeout. ++ Returns the match object on success, False on timeout or re.error. ++ Fail-closed: a pattern that stalls or fails to compile returns False. ++ """ ++ try: ++ future = _PCRE_EXECUTOR.submit(re.match, pattern, text) ++ return future.result(timeout=timeout) ++ except concurrent.futures.TimeoutError: ++ log.warning( ++ "pcre match timed out after %ss: pattern=%r target=%r", ++ timeout, pattern, text ++ ) ++ return False ++ except re.error as exc: ++ log.warning("invalid pcre pattern %r: %s", pattern, exc) ++ return False ++ except Exception as exc: # pylint: disable=broad-except ++ log.warning("pcre match error pattern=%r: %s", pattern, exc) ++ return False + + + def match(tgt, opts=None, minion_id=None): + """ + Returns true if the passed pcre regex matches + """ + if not opts: + opts = __opts__ + if not minion_id: + minion_id = opts.get("id") + +- return bool(re.match(tgt, minion_id)) ++ return bool(_safe_pcre_match(tgt, minion_id)) +--- a/salt/utils/minions.py ++++ b/salt/utils/minions.py +@@ -1,6 +1,8 @@ + import re + import fnmatch + import logging ++import concurrent.futures ++from salt.matchers.pcre_match import _safe_pcre_match, _PCRE_EXECUTOR, _PCRE_TIMEOUT +@@ -320,11 +322,17 @@ class CkMinions: + def _check_pcre_minions( + self, expr, greedy, minions=None + ): # pylint: disable=unused-argument + """ + Return the minions found by looking via regular expressions + """ +- reg = re.compile(expr) +- + if not minions: + minions = self._pki_minions() + +- return { +- "minions": [m for m in minions if reg.match(m)], +- "missing": [], +- } ++ try: ++ future = _PCRE_EXECUTOR.submit(re.compile, expr) ++ reg = future.result(timeout=_PCRE_TIMEOUT) ++ except concurrent.futures.TimeoutError: ++ log.warning( ++ "pcre compile timed out after %ss: expr=%r", _PCRE_TIMEOUT, expr ++ ) ++ return {"minions": [], "missing": []} ++ except re.error as exc: ++ log.warning("invalid pcre expr %r: %s", expr, exc) ++ return {"minions": [], "missing": []} ++ ++ return { ++ "minions": [m for m in minions if _safe_pcre_match(expr, m)], ++ "missing": [], ++ } +--- a/salt/utils/data.py ++++ b/salt/utils/data.py +@@ -1,5 +1,6 @@ + import re + import fnmatch ++from salt.matchers.pcre_match import _safe_pcre_match +@@ -145,9 +146,9 @@ def subdict_match( + if regex_match: + try: +- return re.match(pattern, target) ++ return _safe_pcre_match(pattern, target) + except Exception: # pylint: disable=broad-except + log.error("Invalid regex '%s' in match", pattern) + return False