Skip to content

Fix/whitelist internal loaders - #70250

Open
dwoz wants to merge 10 commits into
saltstack:3008.xfrom
dwoz:dwoz/fix/whitelist-internal-loaders-3008.x
Open

Fix/whitelist internal loaders#70250
dwoz wants to merge 10 commits into
saltstack:3008.xfrom
dwoz:dwoz/fix/whitelist-internal-loaders-3008.x

Conversation

@dwoz

@dwoz dwoz commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Fix several internal only loaders to use a loader that is unrestricted by the whitelist.

dwoz added 7 commits September 8, 2026 13:52
``salt.utils.schedule.Schedule`` is an internal Salt subsystem that must
be able to call ``timezone.get_offset`` (in ``__singleton_init__``) and
``config.merge`` (in ``option``) for its own bookkeeping regardless of
the operator's ``whitelist_modules`` setting.  With a strict whitelist
that omits ``config`` or ``timezone``, both lookups previously
KeyError'd on the wire-filtered ``self.functions``.

``salt.loader.minion_mods`` already exposes the unfiltered inner
LazyLoader as ``ret._dunder_salt`` on the outer wire-filtered loader
(the substrate landed with PR saltstack#69983; PR saltstack#70192 extended it to the
state stack).  This patch continues the propagation into the scheduler:
a new ``Schedule._dunder_salt`` property reads through the inner loader
for the two internal-helper call sites while user-configured scheduled
jobs keep dispatching through ``self.functions`` (line 861 / 880 etc.)
and stay whitelist-gated.

The property falls back to ``self.functions`` when the inner loader
attribute is absent (salt-ssh ``FunctionWrapper``, tests that pass a
plain ``dict``, or any wire-loader that did not set ``_dunder_salt``),
so existing callers and the scheduler-test conftest fixture that hands
in ``{"test.ping": ping}`` are unaffected.  This also automatically
picks up the freshly-regenerated inner loader in
``handle_func``'s subprocess-spawn path (line 738-740), since the
property re-reads ``self.functions._dunder_salt`` on every access.

Regression tests in ``tests/pytests/unit/utils/scheduler/test_whitelist_dunder.py``
cover:
 * ``_dunder_salt`` returns the inner loader when present
 * ``_dunder_salt`` falls back to ``self.functions`` when the attribute
   is missing or ``None``
 * ``option()`` dispatches ``config.merge`` through the inner loader
   and works when the outer wire loader lacks ``config.merge``
 * ``option()`` falls back to ``opts.get`` when neither loader has
   ``config.merge``
 * ``time_offset`` is populated from the inner ``timezone.get_offset``
   and defaults to ``"0000"`` when the inner call raises
Companion to the unit tests in
``tests/pytests/unit/utils/scheduler/test_whitelist_dunder.py`` --
these exercise the real ``salt.loader.minion_mods`` two-loader split
(PR saltstack#69983) end-to-end with a real ``Schedule`` on top, so the fix is
proven to work against actual loaders rather than only against
plain-dict / mock fixtures.

The 6 functional tests cover:

  * Preconditions: with ``whitelist_modules: [test, grains]``, the real
    wire-facing outer loader omits ``config.merge`` and
    ``timezone.get_offset`` (and the inner unfiltered loader still has
    them).
  * ``Schedule._dunder_salt`` on a real Schedule resolves to the same
    inner loader object exposed by ``minion_mods``.
  * ``Schedule.__singleton_init__`` populates ``self.time_offset`` by
    actually calling ``timezone.get_offset`` through the inner loader
    (``os_family`` grain is seeded so the function progresses past its
    grain check and shells out to ``date +%z``; the 5-char
    sign-prefixed return value distinguishes a real read from the
    4-char ``"0000"`` fallback string that the ``try/except`` produces
    on failure).
  * ``Schedule.option`` dispatches ``config.merge`` through the inner
    loader end-to-end.
  * Salt-ssh / plain-dict backcompat: constructing Schedule with a
    plain ``dict`` (no ``_dunder_salt`` attribute) still works and
    exercises the ``self.opts.get`` / ``"0000"`` fallbacks.
Continues the two-loader model that shipped in ``salt.loader.minion_mods``
(PR saltstack#69983) and was extended to the state stack in PR saltstack#70192, applying
the same pattern to three more internal Salt subsystems.  Each of these
is internal machinery -- operator-configured minion/master daemons or a
hard-coded diagnostic path -- so requiring the operator to add helper
execution modules (``config``, ``status``, ``mine``, ``event``,
``pillar``, ``sys``, ...) to their ``whitelist_modules`` was a
maintenance treadmill that gave no security value, since these callers
are never dispatched over the wire in the first place.

Changes:

  * ``salt.loader.beacons`` -- the shipped beacons (status, sh, service,
    etc.) call ``__salt__[f"status.{func}"]``, ``status.procs()`` and
    friends.  The factory now packs ``functions._dunder_salt`` as
    ``__salt__`` when the outer wire loader carries it, falling back to
    ``functions`` for salt-ssh FunctionWrapper / plain-dict fixtures.

  * ``salt.loader.engines`` -- shipped engines (slack, webhook, sqs,
    etc.) call ``__salt__["event.send"]`` / ``__salt__["pillar.get"]``.
    Same getattr-with-fallback pattern.

  * ``sys.doc`` error paths in ``salt/cli/caller.py``, ``salt/minion.py``,
    ``salt/metaproxy/proxy.py``, and ``salt/metaproxy/deltaproxy.py`` --
    when a user-requested function is not on the wire loader, the
    "did you mean" documentation lookup uses ``sys.doc``.  Under a
    strict whitelist that omits ``sys``, this error path itself
    KeyError'd; the operator got a traceback instead of the intended
    fallback message.  All four callsites now resolve ``sys.doc``
    through the inner loader via ``_sys_loader = getattr(functions,
    "_dunder_salt", None) or functions``; the user-facing membership
    check (``fun not in self.minion.functions``) stays on the wire
    loader and continues to be whitelist-gated.

Zero caller-site changes required for beacons/engines: the loader
factory does the getattr internally.  Salt-ssh FunctionWrapper and
plain-dict test fixtures continue to work unchanged (the ``_dunder_salt``
attribute is simply absent, and the fallback returns the original
functions).

Regression tests in ``tests/pytests/unit/loader/test_subsystem_whitelist_dunder.py``
cover:

  * beacons factory: passthrough of inner loader when present, fallback
    to ``functions`` when the ``_dunder_salt`` attribute is missing or
    explicitly ``None``, and end-to-end verification that
    ``status.procs`` is NOT on the wire loader but IS on the beacon's
    ``__salt__`` pack.
  * engines factory: same three cases, verified with ``event.send``.
  * sys.doc error path: the getattr-and-dispatch pattern used by all
    four callsites is exercised (inner-loader path, missing-attr
    fallback, explicit-None fallback).
  * Anti-regression parametrised test: asserts each of the four
    source files (``salt/cli/caller.py``, ``salt/minion.py``,
    ``salt/metaproxy/proxy.py``, ``salt/metaproxy/deltaproxy.py``)
    contains the ``_sys_loader["sys.doc"](...)`` invocation and does
    NOT contain the pre-fix direct ``functions["sys.doc"](...)`` form.
    Guards against a future single-site revert.
``salt.minion.Minion.process_beacons`` re-reads the ``beacons`` config
on every scheduler tick via ``functions["config.merge"]`` -- another
minion-internal ``config.merge`` lookup that KeyError'd on the wire
loader under a strict ``whitelist_modules`` that omitted ``config``.
Same shape as the ``Schedule.option`` and sys.doc error-path fixes.

Add unit + functional coverage for the process_beacons routing:
  * unit: test_process_beacons_dispatches_config_merge_via_inner_loader
  * unit: test_process_beacons_config_merge_falls_back_when_dunder_missing
  * unit: test_process_beacons_callsite_routes_through_inner_loader
    (anti-regression: asserts the getattr-fallback pattern is present
    in salt/minion.py and the pre-fix direct dispatch is not)

Also add functional coverage for the beacons + engines + sys.doc fixes
already on this branch:
  * tests/pytests/functional/loader/test_subsystem_whitelist_dunder.py
    (9 tests) -- real minion_mods with narrow whitelist_modules,
    real salt.loader.beacons + salt.loader.engines, load real beacon
    and engine modules and verify their __salt__ reaches
    non-whitelisted execution modules (status.procs, cmd.run_bg,
    sys.doc).
``salt.minion.Minion.setup_scheduler`` (and its metaproxy variants)
inject four Salt-internal scheduled entries -- ``__mine_interval``,
``__master_alive_*``, ``__master_failback``, ``__ping_master`` -- into
every minion's scheduler at startup.  These entries are internal
machinery, not user-controlled dispatch, but on the current wire
loader they were still gated by ``whitelist_modules`` in two places:

  1. ``salt.utils.schedule.Schedule.handle_func`` looked up the
     scheduled function through ``self.functions[func]`` -- the
     wire-filtered outer loader.  When ``__mine_interval`` fired on a
     minion whose operator omitted ``mine`` from ``whitelist_modules``,
     the tick raised ``KeyError: 'mine.update'``.  Silent hourly
     failure.

  2. ``setup_scheduler`` gated the ``__mine_interval`` INJECTION on
     ``"mine.update" in self.functions`` (three sites -- ``salt/minion.py``,
     ``salt/metaproxy/proxy.py``, ``salt/metaproxy/deltaproxy.py``).
     Under the same whitelist, ``__mine_interval`` was never added at
     all -- ``mine.update`` simply never ran on that minion.

Fix uses the ``__``-prefix schedule-key convention that Salt already
follows for its internal entries: ``handle_func`` picks
``dispatch_functions = getattr(self.functions, "_dunder_salt", None) or
self.functions`` when ``data["name"]`` starts with ``__``, and keeps
the wire loader for operator-configured entries.  The three injection
gates apply the getattr-with-fallback pattern directly.

Operator-configured schedule entries (no ``__`` prefix) keep
dispatching through the wire-filtered outer loader, so
``whitelist_modules`` remains an effective defense-in-depth gate on
operator-controlled scheduling.  Salt-ssh ``FunctionWrapper`` and
plain-dict test fixtures are unaffected -- the fallback returns
``functions`` when ``_dunder_salt`` is absent.

Regression tests in
``tests/pytests/unit/utils/scheduler/test_whitelist_dunder.py``:

  * ``test_handle_func_internal_job_dispatches_via_inner_loader`` --
    ``__``-prefixed schedule key routes through inner loader.
  * ``test_handle_func_operator_configured_job_stays_on_wire_loader`` --
    non-``__`` key stays on outer; a non-whitelisted exec module on
    the inner loader is NOT reachable from operator-scheduled
    dispatch.
  * ``test_handle_func_internal_job_falls_back_when_dunder_missing`` --
    plain-dict backcompat.
  * ``test_handle_func_dispatch_selector_source_pattern_present`` --
    anti-regression: asserts the selector code is present in
    ``salt/utils/schedule.py``.
  * ``test_mine_update_injection_gates_route_through_inner_loader``
    (parametrised over the three files) -- anti-regression: asserts
    the getattr-fallback pattern is present in each injection file
    and the pre-fix direct dispatch is not.
Boots a real salt-master + salt-minion pair with a strict
``whitelist_modules`` that omits every one of the internal-composition
helpers our fix targets (``config``, ``status``, ``mine``, ``sys``,
``timezone``, ``event``, ``pillar``) and exercises the fix through
the CLI end-to-end.

  * ``tests/pytests/integration/loader/test_subsystem_whitelist_dunder.py``
    - test_whitelisted_minion_dispatches_test_ping: sanity that a
      minion under this narrow whitelist boots and can dispatch a
      whitelisted function through the master; if any of
      beacons-loader / process_beacons / setup_scheduler / engine
      loader / sys.doc error path had regressed on startup, the
      minion would either fail to start or drop off the master.
    - test_beacons_list_reflects_configured_status_beacon: a
      ``status`` beacon is configured; ``beacons.list`` must reflect
      it (proving the beacons factory + Minion.process_beacons +
      config.merge routing all worked end-to-end).
    - test_missing_function_error_path_does_not_traceback: dispatching
      a nonexistent function returns a docs / not-available message
      rather than a Python traceback naming sys.doc as the KeyError
      target.

  * ``tests/pytests/integration/utils/test_scheduler_whitelist_dunder.py``
    - test_schedule_list_succeeds_under_narrow_whitelist: schedule.list
      returns cleanly without traceback / KeyError, proving
      Schedule.option() / config.merge routing works over the wire.
    - test_saltutil_running_succeeds_under_narrow_whitelist:
      saltutil.running (touched by scheduler ticks) returns cleanly.
``Schedule.eval`` (invoked at 1Hz via the minion's ``schedule``
periodic callback) and ``Schedule.run_job`` both check whether the
scheduled function exists on ``self.functions`` before dispatching.
Pre-fix, Salt-internal ``__``-prefixed jobs like ``__mine_interval``
routed this check through the wire-filtered outer loader.  Under a
strict ``whitelist_modules`` that omitted ``mine`` / ``status``, the
check emitted ``log.info("Invalid function: mine.update in scheduled
job __mine_interval.")`` at every eval tick -- 1Hz log spam that
mimicked a KeyError but was actually just noisy INFO logging.  The
downstream dispatch through ``handle_func`` already routed correctly
via the previously-added ``__``-prefix selector, so the *actual*
scheduled function still fired; the spam was cosmetic but obscuring.

This patch mirrors the ``handle_func`` selector on both pre-dispatch
presence checks: for ``__``-prefixed schedule keys the presence check
routes through the unfiltered inner loader; operator-configured
entries stay on the wire loader so ``whitelist_modules`` remains an
effective defense-in-depth gate on operator-controlled scheduling.

Reported by Daniel: on the mgmt-vc, dropping ``mine`` from
``whitelist_modules`` produced a 1Hz stream of these Invalid-function
log lines from the ``MultiMinionProcessManager MinionProcessManager``
subprocess -- restoring ``mine`` silenced them.  Post-fix, the
whitelist can safely omit ``mine`` (plus the already-safe ``config``,
``status``, ``timezone``) without the log spam.

Regression tests in
``tests/pytests/unit/utils/scheduler/test_whitelist_dunder.py``:
  * test_eval_presence_check_internal_job_uses_inner_loader
  * test_eval_presence_check_operator_job_stays_on_wire_loader
  * test_eval_and_run_job_presence_checks_use_prefix_selector
    (parametrised anti-regression that greps for the selector in the
    source at both callsites).
@dwoz
dwoz requested a review from a team as a code owner September 8, 2026 23:35
@dwoz dwoz added the test:full Run the full test suite label Sep 8, 2026
…llseye

Cluster A (14 distros): tests/pytests/functional/loader/test_subsystem_
whitelist_dunder.py::test_beacon_module_can_reach_non_whitelisted_exec
force-loaded salt.beacons.sh to inspect its populated __globals__["__salt__"]
and prove the beacon loader packs the unfiltered inner dunder. salt.beacons.sh
has __virtual__() that returns False when strace isn't on PATH -- CI images
don't ship strace, so the module never lands in beacons._dict and the test
KeyErrored on "sh.beacon" across every Linux runner. Swap to salt.beacons.
status, whose __virtual__ unconditionally returns the virtualname and which
uses the same __salt__["status.<func>"] dispatch pattern the test exercises.

Cluster B (Debian 11 functional zeromq 2): tests/pytests/functional/modules/
test_aptpkg.py::test_aptpkg_remove_wildcard installs nginx-light 1.18.0-6.1+
deb11u8 from bullseye-security, but that .deb has been rewound off the
mirror while the Packages file still advertises it (404). Debian 11 reached
EOL 2026-08-31; add a cited-reason skipif gated to bullseye only so bookworm
and trixie continue to enforce.

Cluster C (Test Package Debian 11 Arm64 downgrade 3007.14): pkg-install of
salt-* .debs pulled net-tools_...+deb11u2 from the security pool which is
also 404 (same EOL rewind cause). The x86 twin debian-11-pkg was already
enabled: false in cicd/shared-gh-workflows-context.yml; flip the arm64
counterpart to match for symmetry.

Cluster A is PR-70250-introduced (the whitelist_dunder test file is new in
this PR). Clusters B and C are pre-existing 3008.x infrastructure failures
also visible on nightly runs; addressed here because saltstack#70250 owns the
test-hygiene surface those bullseye jobs live in.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test:full Run the full test suite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant