Skip to content

Powered by Grav

Verifying the drush extension filter

Verifying the drush extension filter

The *.drush.inc backend deny-filter is the kind of change you cannot sign off from the diff: its whole contract is who is allowed to load a command file, so the proof has to run as each identity, on a real box, through Drush's real discovery pipeline. This is the DRUSH-FILTER-TESTING.md runbook, re-cut for a maintainer who has read the filter source.

The filter lives in the classic Drush 8 fork at drush-evil-code/includes/boa_extension_filter.inc, wired into three discovery call sites (command.inc::_drush_add_commandfiles, annotationcommand_adapter.inc::annotationcommand_adapter_discover, preflight.inc::_drush_find_commandfiles_drush — the last two filter the array key, which is the path). It ships as a versioned tarball, so getting a change onto a box is a _DRUSH_VERSION bump plus a published tarball, not a branch push — the delivery mechanics and the disposable-VM gate are on Building & testing BOA changes. This page assumes the change is already on the box and proves both required properties:

  1. Protection holds — a backend identity (aegir master, or an Octopus instance user oN) refuses to load a tenant *.drush.inc, closing provision #762138 (tenant code auto-loaded with cross-site authority during a backend task).
  2. Limited-shell CLI is unaffected — a client running Drush as their own oN.ftp account loads their site's contrib commands normally. This is the regression the identity gate exists to prevent: the earlier unconditional filter over-blocked client CLI.

Both properties come straight from the entry point boa_drush_extension_allowed() (boa_extension_filter.inc:324): kill switch → backend-identity gate → path-deny → per-instance basename opt-in. The gate is boa_drush_extension_backend_identity() (:148), keyed on the process's effective uid resolved through posix_getpwuid() → passwd dir, not $HOME (BOA backend su without - leaves HOME=/root while euid is oN). Because the discriminator is the euid, a su-launched test invocation is faithful to the real session — the client's jailed oN.ftp lshell and a su -s /bin/bash - oN.ftp land on the same uid and get the same decision.

Placeholder convention

Run everything as root. Substitute real values in every snippet, including inside the here-documents:

  • <OCT> — the Octopus instance user (e.g. o1).
  • <OCT>.ftp — its limited-shell client user (e.g. o1.ftp).
  • <SITE> — a Drush 8 site alias on that instance (e.g. @mysite.com).

Preconditions

Assert a clean default state — no opt-in or kill-switch control files skewing the decision. These are the presence-only files boa_drush_extension_filter_disabled() / _civicrm_enabled() / _elysia_enabled() consult (boa_extension_filter.inc:185,203,223):

BASH
ls -1 /data/conf/drush_extension_filter_disabled.txt \
      /data/conf/<OCT>_civicrm.txt /data/conf/<OCT>_elysia_cron.txt 2>/dev/null

Expect no output. If any exist and you want the strict default test, move them aside for the run — the kill switch short-circuits allowed() to TRUE for every identity, and either opt-in would let a matching basename through the backend deny.

Test 1 — direct decision check (definitive, no side effects)

Ask the deployed filter, inside each identity's live Drush process, what it decides. drush php-script has already sourced the fork, so the filter's functions are in scope; this bypasses all bootstrap and discovery nuance and is the ground truth for the decision logic itself.

BASH
cat > /tmp/boa_check.php <<'PHP'
<?php
// Run via: drush php-script /tmp/boa_check.php  (drush has loaded the filter)
$tenant  = '/data/disk/<OCT>/distro/probe/sites/all/drush/evil.drush.inc'; // deny for backend
$boatool = '/data/disk/<OCT>/.drush/sys/provision/provision.drush.inc';    // BOA-managed -> allow for all
drush_print('patched           = ' . (function_exists('boa_drush_extension_backend_identity') ? 'yes' : 'NO -- OLD DRUSH STILL ACTIVE'));
drush_print('euid              = ' . posix_geteuid());
drush_print('backend_identity  = ' . var_export(boa_drush_extension_backend_identity(), true));
drush_print('allowed(tenant)   = ' . var_export(boa_drush_extension_allowed($tenant), true));
drush_print('allowed(BOA-tool) = ' . var_export(boa_drush_extension_allowed($boatool), true));
PHP

echo "--- as <OCT> (backend identity) ---"
su -s /bin/bash - <OCT> -c "drush php-script /tmp/boa_check.php"

echo "--- as <OCT>.ftp (limited shell) ---"
su -s /bin/bash - <OCT>.ftp -c "drush php-script /tmp/boa_check.php"

The two probe paths exercise both arms of boa_drush_extension_path_denied() (:286): the distro/… tenant path matches the deny pattern #^/data/disk/[^/]+/(static|distro|platforms)(/|$)#i (:67), while .drush/sys/provision/… matches the same .drush deny pattern but is rescued by the .drush/(sys|usr|xts) carve-out (:81) — so allowed(BOA-tool) must be true for both identities, proving the filter does not over-block BOA's own extensions.

Expected as <OCT> (backend identity):

  • patched = yes
  • backend_identity = true
  • allowed(tenant) = false — tenant *.drush.inc denied to the backend (#762138 held)
  • allowed(BOA-tool) = true — BOA's own extensions still load (no over-block)

Expected as <OCT>.ftp (limited shell):

  • patched = yes
  • backend_identity = false — euid is the .ftp uid; the dotted name fails the strpos($name,'.') === FALSE tie in boa_drush_extension_octopus_user() (:112)
  • allowed(tenant) = true — the client's own CLI is not filtered (allowed() returns early at the !backend_identity() guard, :328)
  • allowed(BOA-tool) = true

A patched = NO line means that identity is running an un-patched Drush — the function_exists() probe is the cheap tell that the tarball bump did not land on that install location. Fix the install before continuing; the rest of the run is meaningless against old code.

Test 2 — end-to-end through Drush's real discovery pipeline

Test 1 proves the decision function. Test 2 proves the wire-in: that the three discovery call sites actually consult it. Plant one harmless command file in a genuinely tenant-writable path, then watch the same file get denied to the backend and loaded for the client. The file's top-level statement writes a uid-stamped marker only when Drush actually loads it, so the marker — not the command's success — is ground truth.

BASH
# Drupal root must sit under /data/disk/<OCT>/{distro,static,platforms}/...
ROOT=$(su -s /bin/bash - <OCT> -c "drush <SITE> dd" 2>/dev/null); echo "ROOT=$ROOT"
# fallback if 'dd' is unavailable:
#   drush <SITE> status --fields=root --format=list

mkdir -p "$ROOT/sites/all/drush"
cat > "$ROOT/sites/all/drush/bprobe.drush.inc" <<'PHP'
<?php
// Harmless BOA filter probe — remove after testing.
@file_put_contents('/tmp/boa_probe_uid_' . posix_geteuid() . '.marker', date('c')."\n", FILE_APPEND);
function bprobe_drush_command() {
  return array('bprobe' => array('description' => 'BOA filter probe (harmless).',
                                  'bootstrap' => DRUSH_BOOTSTRAP_DRUSH));
}
function drush_bprobe() { drush_print('BOA-PROBE-RAN uid=' . posix_geteuid()); }
PHP
chown <OCT>:users "$ROOT/sites/all/drush/bprobe.drush.inc"
chmod 644 "$ROOT/sites/all/drush/bprobe.drush.inc"

sites/all/drush/ under a distro/static/platforms root is exactly the tenant-writable territory the deny patterns anchor on, and the file is owner-<OCT>, group-users — the ownership a site code contributor can produce. Note Drush hands the scanner this path relative to the Drupal root at bootstrap; boa_drush_extension_canonical_path() (:257) realpath()s it to absolute first, which is what lets the ^/…-anchored patterns match at all.

2a — the backend must NOT see it

BASH
rm -f /tmp/boa_probe_uid_*.marker
su -s /bin/bash - <OCT> -c "drush <SITE> cc drush >/dev/null 2>&1; drush <SITE> bprobe; echo EXIT=\$?"
ls /tmp/boa_probe_uid_$(id -u <OCT>).marker 2>/dev/null && echo "MARKER PRESENT (BAD)" || echo "no marker (GOOD)"
  • drush <SITE> bprobe reports the command is not found (non-zero EXIT) — the file was filtered out of discovery, so bprobe was never registered.
  • no marker (GOOD) — the backend neither ran nor even loaded the tenant file.

The cc drush first clears Drush's commandfile cache so discovery re-runs through the filter rather than replaying a cached command list.

2b — the limited shell MUST see it

BASH
rm -f /tmp/boa_probe_uid_*.marker
su -s /bin/bash - <OCT>.ftp -c "drush <SITE> cc drush >/dev/null 2>&1; drush <SITE> bprobe; echo EXIT=\$?"
ls /tmp/boa_probe_uid_$(id -u <OCT>.ftp).marker 2>/dev/null && echo "MARKER PRESENT (GOOD)" || echo "no marker (BAD)"
  • Prints BOA-PROBE-RAN uid=<the .ftp uid> (EXIT=0).
  • MARKER PRESENT (GOOD) — the client's own contrib command loads and runs.

This is the reporter's elysia_cron situation reproduced exactly: a contrib command file the client runs from their own oN.ftp shell, which the earlier unconditional filter wrongly blocked.

Test 3 — (optional) confirm via a real Ægir task

Tests 1–2 drive su -s /bin/bash - <OCT> directly. Test 3 proves the actual backend queue path is protected — the identity Hostmaster/Provision tasks run under — not just an interactive su. Leave the probe planted from Test 2:

BASH
rm -f /tmp/boa_probe_uid_*.marker
# Run a Verify on the site or its platform from the Aegir UI, or:
#   su -s /bin/bash - <OCT> -c "drush @hostmaster hosting-task <SITE> verify -y"
ls /tmp/boa_probe_uid_$(id -u <OCT>).marker 2>/dev/null && echo "BACKEND LOADED IT (BAD)" || echo "backend did not load it (GOOD)"
  • The task completes normally.
  • backend did not load it (GOOD) — the euid under the real task is oN, so the gate denies the tenant file on the queue path too.

Test 4 — Drupal 11 contributed command files stay on disk and never load for the backend

BOA no longer deletes contributed *.drush.inc files from codebases (the sweep that removed them on Drupal 11 platforms at lock time and in the platform permission helper is retired), so this test proves the property that replaced it: a command file inside an ENABLED contributed module's directory on a locked Drupal 11 composer codebase is found by Drush's recursive scan, denied for the backend, and the two paths that used to delete it now leave it in place. Use a site on a locked Drupal 11 composer codebase (modules/contrib exists only in that layout; a built-in platform keeps contrib under modules/o_contrib_eleven) and pick a contributed module that is enabled on it (token is a safe choice on most distributions). @<PLATFORM> below is the platform's context name, listed by ls /data/disk/<OCT>/.drush/platform_*.alias.drushrc.php. The probe has its own name so it can coexist with the Test 2 probe. The permission helper is run the way the fix_permissions task runs it, by the Octopus user through sudo; it refuses any other caller. Test 2b already covers the limited-shell identity, so this test has no <OCT>.ftp step.

For the maintainer: the deny decision here is taken in _drush_add_commandfiles() (command.inc:1641 at scan time, :1666 before the load), which is what makes a file that stays on disk harmless to the backend.

BASH
ROOT=$(su -s /bin/bash - <OCT> -c "drush <SITE> dd" 2>/dev/null)     # or: status --fields=root --format=list
MOD="$ROOT/modules/contrib/token"                         # any ENABLED contrib module dir
cat > "$MOD/cprobe.drush.inc" <<'PHP'
<?php
@file_put_contents('/tmp/boa_probe_uid_' . posix_geteuid() . '.marker', date('c') . "\n", FILE_APPEND);
function cprobe_drush_command() {
  return array('cprobe' => array('description' => 'BOA probe command', 'bootstrap' => DRUSH_BOOTSTRAP_DRUSH));
}
function drush_cprobe() { drush_print('BOA-PROBE-RAN'); }
PHP
chown <OCT>:users "$MOD/cprobe.drush.inc"; chmod 0644 "$MOD/cprobe.drush.inc"
rm -f /tmp/boa_probe_uid_*.marker

# 4a — backend: full site bootstrap after a cache clear; the probe must not appear or run
su -s /bin/bash - <OCT> -c "drush <SITE> cc drush >/dev/null 2>&1; drush <SITE> help 2>&1" | grep -c '^ *cprobe'   # expect 0
ls /tmp/boa_probe_uid_$(id -u <OCT>).marker 2>/dev/null && echo "BACKEND LOADED IT (BAD)" || echo "backend did not load it (GOOD)"

# 4b — the two paths that used to delete the file now leave it in place
su -s /bin/bash - <OCT> -c "drush @<PLATFORM> provision-dunlock && drush @<PLATFORM> provision-dlock" >/dev/null 2>&1; echo "lock cycle exit=$?"
su -s /bin/bash - <OCT> -c "sudo --non-interactive /usr/local/bin/fix-drupal-platform-permissions.sh --root=$ROOT" >/dev/null 2>&1; echo "helper exit=$?"
ls -la "$MOD/cprobe.drush.inc"                                      # still present
ls /tmp/boa_probe_uid_$(id -u <OCT>).marker 2>/dev/null && echo "BACKEND LOADED IT (BAD)" || echo "backend did not load it (GOOD)"
  • 4a: 0 and backend did not load it (GOOD)
  • 4b: both exit codes 0, the probe file still present, and backend did not load it (GOOD) again

A real contributed command file written for Drush 12 behaves the same way for the backend. For the unfiltered <OCT>.ftp identity it loads, and on a platform whose enabled modules ship such a file every Drush 8 command in the limited shell, cc drush included, ends at command discovery with the file's own exception. That is expected and is why clients use the site-local vdrush on Drupal 8+.

Cleanup

BASH
rm -f "$ROOT/sites/all/drush/bprobe.drush.inc"
rm -f "$ROOT/modules/contrib/token/cprobe.drush.inc"   # Test 4 probe, if planted
rmdir "$ROOT/sites/all/drush" 2>/dev/null   # only if we created it and it is now empty
rm -f /tmp/boa_probe_uid_*.marker /tmp/boa_check.php
su -s /bin/bash - <OCT> -c "drush <SITE> cc drush >/dev/null 2>&1"

Pass criteria

# Property Signal
1 Protection holds Test 1 as <OCT>: allowed(tenant)=false; Test 2a: bprobe not found and no marker; Test 3: no backend marker
2 Limited-shell CLI unaffected Test 1 as <OCT>.ftp: allowed(tenant)=true; Test 2b: BOA-PROBE-RAN and marker present
3 Files stay on disk, never load for the backend Test 4a: 0 and no backend marker; Test 4b: lock cycle and helper exit 0, probe still present, no backend marker
No over-block / patch active Both identities: patched=yes and allowed(BOA-tool)=true

A run is definitive only when all four rows hold. Row 1 without row 2 is the over-blocking regression; row 2 without row 1 is the open #762138; either without the last row means you tested the wrong (un-patched) Drush or the carve-out broke.

  • Building & testing BOA changes — why the Drush fork ships as a versioned tarball, the _DRUSH_VERSION / _DRUSH_EIGHT_VRN bump that makes a box re-fetch it, and the disposable-VM gate this runbook runs inside.
  • Drush fork internals — the classic Drush 8 fork the filter patches, its discovery pipeline, and the other BOA divergences from stock Drush.
  • Variables_DRUSH_VERSION, _DRUSH_EIGHT_VRN, and the other pins a test build sets.

© 2026 BOA Documentation. All rights reserved.