— Article — № 073

073 —Drupal

Drupal 7 end-of-life triage: the ten-minute audit checklist

You inherit a Drupal 7 site at 23:41 on a Sunday. The client wants a rebuild quote on Monday. Here is what to actually look at before you reply.

Overhead photo on bone linen: paper audit checklist, Drupal site-tree on graph paper, manila folder, brass triage plate, wax seal.
Hero · staged still№ 073

A client emails on a Sunday night. Their accountant's invoice page is down, the IT person who built the site moved to Berlin in 2019, and the contact form still works but the About page returns a fatal. The site is Drupal 7. You have not opened a D7 install since the Drupalgeddon 2 patches went out, and you need to send a rebuild quote on Monday morning.

Before you scope that quote, you owe yourself ten minutes of triage. Drupal 7 went past official end-of-life on 5 January 2025, which means the Drupal Security Team no longer publishes advisories for core or for contributed modules. Anything you find now you find on your own, and anything you miss the client pays for. This is the audit checklist we run on every Drupal 7 handover, in order.

Minute one to three: the front-door disclosures

Three commands tell you immediately whether the site has been quietly maintained or quietly rotting. You can run them from your own machine, no credentials needed.

curl -sI https://client-site.example/ | grep -i x-generator
curl -s  https://client-site.example/CHANGELOG.txt | head -3
curl -sI https://client-site.example/user/login | head -1

The first line returns the X-Generator header, which on a stock Drupal 7 install reads Drupal 7 (https://www.drupal.org). If it is present, version disclosure is on. If CHANGELOG.txt is readable from the public document root, an attacker can read the exact minor version from the top entry and look up which CVEs apply. If /user/login returns a flat 200 with no honeypot and no flood control, brute force is on the table.

Three things go on your patch list before you do anything else:

  • Block CHANGELOG.txt, INSTALL*.txt, UPGRADE.txt, MAINTAINERS.txt, COPYRIGHT.txt at the .htaccess layer.
  • Strip X-Generator via a custom module or at the web server.
  • Confirm flood control is actually firing in admin/config/people/ip-blocking.

The .htaccess block is one paste:

<FilesMatch "(CHANGELOG|INSTALL|UPGRADE|MAINTAINERS|COPYRIGHT)\.txt$">
  Require all denied
</FilesMatch>

Minute three to six: settings.php and the database surface

SSH or SFTP into the webroot. Open /sites/default/settings.php top to bottom. You are looking for four specific things, and a careful read takes about ninety seconds.

$databases['default']['default'] = array(
  'database' => 'd7_main',
  'username' => 'd7user',
  'password' => 'hunter2',     // 1. reused on staging? rotate.
  'host'     => 'localhost',
  'driver'   => 'mysql',
);

$update_free_access = FALSE;     // 2. MUST be FALSE in production
$drupal_hash_salt   = '...';     // 3. exists and is long
// 4. is $base_url hardcoded, and is it https?

While you are in there, point a SQL client at the database and look at the users table directly. The shape of Drupal 7's users is unchanged since 2011, so these three queries always work.

SELECT COUNT(*) FROM users WHERE status = 1;
SELECT name, mail, login FROM users WHERE uid IN (1,2,3,4,5);
SELECT name, FROM_UNIXTIME(login) AS last_login FROM users
  ORDER BY login DESC LIMIT 10;

uid 1 is the superuser. If it is named admin or root and the last login was in 2017, that account is a foothold and you should disable it before the next backup runs. The password column stores hashes prefixed with $S$, which is Drupal's portable phpass implementation. An older $P$ prefix on a legacy import means the hash never finished migrating and is weaker than the rest. The php.net password hashing docs are the right place to send a junior engineer who asks why this matters.

Add to the patch list: rotate the database password, rotate $drupal_hash_salt, rotate the uid 1 password, confirm $update_free_access is FALSE, and audit anyone with the administer site configuration permission.

Minute six to ten: the file system and the .htaccess

The two highest-yield directories on any Drupal 7 site are /sites/default/files and /sites/all/modules/contrib. The first tells you what users uploaded over the past decade. The second tells you which contributed modules need patches you can no longer get for free.

find sites/default/files -type f -name "*.php*" 2>/dev/null
find sites/default/files -type f -newer /tmp/marker | head -20
ls -la sites/all/modules/contrib/ | wc -l

Any PHP file under /files is a red flag. Drupal 7's default per-directory .htaccess blocks PHP execution inside the files tree, but if the webserver runs without AllowOverride, or if the directory was restored from a partial backup, that block is gone. The OWASP unrestricted file upload guide is the canonical reference for why this is the single most common foothold on legacy CMS installs.

The webroot .htaccess on a clean D7 install includes the canonical rewrite block and a set of php_value lines. Diff what is in production against the upstream you can pull from the Drupal 7 tarball:

diff .htaccess ~/drupal-7.103/.htaccess

Custom rules added below the canonical comment block are fine. Canonical rules removed from above it are not.

Finally, confirm which PHP version actually runs. Drupal 7 is supported up to PHP 8.1 with the D7-to-D8 compatibility patches; above that, you are on your own. If the site is on PHP 7.4 or older, the host's own EOL clock matters as much as Drupal's.

php -v
curl -s https://client-site.example/admin/reports/status | grep -i 'php version'

The quote you can now write

After ten minutes you know four things you did not know at 23:41. Which contributed modules are critically outdated. Whether the site is leaking its version to anyone with curl. Whether the database surface has reused credentials or stale superusers. Whether anyone has uploaded a PHP shell into /files. That is enough to write a rebuild quote with three honest line items: an immediate hardening pass billed by the hour, a frozen-in-time mirror for forensics, and the rebuild itself.

The pattern on Drupal 7 handovers is consistent. The client thinks they are paying for a rebuild, and you find out in week two that they were also paying, quietly, for an unpatched RCE. Triage first so the rebuild scope is the rebuild, not the cleanup with a rebuild attached.

When we built Pier we kept hitting the exact same friction on these triage passes: ten minutes of SSH plus phpMyAdmin plus the host's file manager plus a scratch notes file. The way we ended up handling it was to put the SFTP browser, the MySQL editor, and the version history in one pane so the audit and the patch live in the same trail.

Pick one legacy site you inherited this year and run the three curl commands above against it before you close your laptop tonight. Whatever they return, you will know more about it than you did this morning.

— Questions —

Is Drupal 7 still safe to run after end-of-life?

Not without a paid extended-support vendor. The Drupal Security Team stopped publishing advisories in January 2025, so any new core or contrib CVE is yours to patch alone.

What is the fastest way to remove the X-Generator header?

Strip it in a custom module via hook_page_attachments_alter, or at the web server with Header unset X-Generator in Apache, or more_clear_headers in nginx.

Should I patch before quoting the rebuild, or quote first?

Patch the obvious holes the same hour you find them. A leaking site billing the client during a four-week rebuild scope is a worse problem than a slightly delayed quote.

What PHP version can Drupal 7 actually run on?

Up to PHP 8.1 with the D7-to-D8 compatibility patches applied. PHP 8.2 and above are unsupported, and PHP 7.4 itself has been EOL since November 2022.