From d3f8182bf36a9ee7edcf5f3fbe7eb6db7ad44a60 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sat, 4 Jul 2026 16:11:39 -0400 Subject: [PATCH 1/2] Fix grain_pcre/glob matching against dictionary-valued grains subdict_match only applied regex/fnmatch patterns to list members and to literal dict keys, so a pattern like 'roles:(roleA|roleB|roleC)' matched a list-valued grain but not the equivalent dict-valued grain. Scan dict keys with the same _match() helper used for list members so key matching is consistent. Fixes #35567 --- changelog/35567.fixed.md | 1 + salt/utils/data.py | 7 +++++++ tests/pytests/unit/utils/test_data.py | 26 ++++++++++++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 changelog/35567.fixed.md diff --git a/changelog/35567.fixed.md b/changelog/35567.fixed.md new file mode 100644 index 000000000000..9e95ff814754 --- /dev/null +++ b/changelog/35567.fixed.md @@ -0,0 +1 @@ +Fixed grain_pcre and glob matching against dictionary-valued grains so patterns are applied to dict keys, not only list members. diff --git a/salt/utils/data.py b/salt/utils/data.py index a8760b57699c..7ec5d686a155 100644 --- a/salt/utils/data.py +++ b/salt/utils/data.py @@ -925,6 +925,13 @@ def _dict_match(target, pattern, regex_match=False, exact_match=False): if not ret and pattern in target: # We might want to search for a key ret = True + if not ret and any( + _match(key, pattern, regex_match=regex_match, exact_match=exact_match) + for key in target + ): + # The pattern may be a regex/glob that matches one of the keys, + # just like list members are matched below + ret = True if not ret and subdict_match( target, pattern, regex_match=regex_match, exact_match=exact_match ): diff --git a/tests/pytests/unit/utils/test_data.py b/tests/pytests/unit/utils/test_data.py index ad7c13a07b9f..0c52e503a485 100644 --- a/tests/pytests/unit/utils/test_data.py +++ b/tests/pytests/unit/utils/test_data.py @@ -142,6 +142,32 @@ def test_subdict_match(): assert salt.utils.data.subdict_match(test_three_level_dict, "a:*:c:v") +def test_subdict_match_regex_on_dict_keys(): + """ + Tests that regex/glob patterns are applied to dict keys, not only to + list members. Regression test for issue #35567. + """ + dict_grain = { + "roles": {"roleA": None, "roleB": ["envA", "envB"], "roleC": ["envA"]} + } + list_grain = {"roles": ["roleA", "roleB", "roleC"]} + + # The list-valued grain has always matched a regex alternation ... + assert salt.utils.data.subdict_match( + list_grain, "roles:(roleA|roleB|roleC)", regex_match=True + ) + # ... and the dict-valued grain should behave the same way against its keys. + assert salt.utils.data.subdict_match( + dict_grain, "roles:(roleA|roleB|roleC)", regex_match=True + ) + # Glob patterns should also match dict keys. + assert salt.utils.data.subdict_match(dict_grain, "roles:role*") + # Negative case: a pattern that matches none of the keys must fail. + assert not salt.utils.data.subdict_match( + dict_grain, "roles:(roleX|roleY)", regex_match=True + ) + + @pytest.mark.parametrize( "wildcard", [ From b5ef0767871a451472e944f387f642618433468f Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sun, 5 Jul 2026 23:21:35 -0400 Subject: [PATCH 2/2] Add direct and inverse regression tests for grain dict-key matching The PR's existing test already exercises subdict_match directly with regex_match=True (the exact flag grain_pcre_match passes) and the plain glob shape grain_match passes. This adds the inverse guard: the new key-matching branch runs for every caller, so exact_match=True (the shape pillar_exact_match and the minions cache check pass) must keep glob/regex metacharacters literal, a non-matching glob must not match, and a key-only match must not satisfy a deeper expression whose lower levels do not match. All inverse assertions hold with and without the fix applied. --- tests/pytests/unit/utils/test_data.py | 39 +++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/pytests/unit/utils/test_data.py b/tests/pytests/unit/utils/test_data.py index 0c52e503a485..4feaef9ba751 100644 --- a/tests/pytests/unit/utils/test_data.py +++ b/tests/pytests/unit/utils/test_data.py @@ -168,6 +168,45 @@ def test_subdict_match_regex_on_dict_keys(): ) +def test_subdict_match_dict_keys_no_overcorrection_35567(): + """ + Guards against overcorrection of the issue #35567 fix. The new + key-matching branch in subdict_match runs for every caller, so it must + not loosen matching for the paths the fix was not meant to change. + These assertions hold both with and without the fix applied. + """ + dict_grain = { + "roles": {"roleA": None, "roleB": ["envA", "envB"], "roleC": ["envA"]} + } + + # exact_match=True is the production shape passed by + # salt/matchers/pillar_exact_match.py and the master-side cache check in + # salt/utils/minions.py. Glob/regex metacharacters must stay literal: + # the new branch must not start wildcard-matching dict keys here. + assert not salt.utils.data.subdict_match( + dict_grain, "roles:role*", exact_match=True + ) + assert not salt.utils.data.subdict_match( + dict_grain, "roles:role.*", exact_match=True + ) + # A literal key still matches under exact_match=True. + assert salt.utils.data.subdict_match(dict_grain, "roles:roleA", exact_match=True) + + # Glob negative case (grain_match production shape, regex_match=False): + # a glob matching none of the keys must not match. + assert not salt.utils.data.subdict_match(dict_grain, "roles:bogus*") + + # Deeper expressions must still require the deeper levels to match; a + # key-only match on 'roleC' must not satisfy 'roles:roleC:envB' when + # envB is not present under roleC. + assert not salt.utils.data.subdict_match( + dict_grain, "roles:roleC:envB", regex_match=True + ) + assert salt.utils.data.subdict_match( + dict_grain, "roles:roleB:envB", regex_match=True + ) + + @pytest.mark.parametrize( "wildcard", [