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):

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.

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 - <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.

# Drupal root must sit under /data/disk/<OCT>/{distro,static,platforms}/...
ROOT=$(su - <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

rm -f /tmp/boa_probe_uid_*.marker
su - <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

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 Aegir task

Tests 1–2 drive su - <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:

rm -f /tmp/boa_probe_uid_*.marker
# Run a Verify on the site or its platform from the Aegir UI, or:
#   su - <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.

Cleanup

rm -f "$ROOT/sites/all/drush/bprobe.drush.inc"
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 - <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
No over-block / patch active Both identities: patched=yes and allowed(BOA-tool)=true

A run is definitive only when all three 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.

Related

  • 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.