— Article — № 065

065 —Magento

Magento audit: 40-minute checklist for an inherited store

A 22-person agency just took over a Magento 2.3 store. The previous dev is unreachable. Here is the 40-minute audit we run before changing a single line.

Overhead photo on bone linen of an audit checklist, CLI run-sheet, ER blueprint, brass plate, stopwatch, ruler, wax seal.
Hero · staged still№ 065

A Dutch agency we work with took over a Magento 2.3.5 store last month. The previous developer left no documentation, stopped answering email in April, and the client passed across an FTP password on a sticky note. Their first instinct was to log in, click around the admin, and see what was there. That cost them two hours and a corrupted catalog cache before they had answered a single useful question.

The 40-minute audit below is what we run before touching anything on an inherited Magento store. It will not fix the store. It will tell you whether the store is worth fixing, where the landmines are, and what to quote. Every inherited legacy site we have walked into has failed in one of the six places this audit checks.

Minute 0 to 5: establish what you are actually looking at

Before SSH, before the admin, before opening the database, get three facts:

  1. Magento version and edition
  2. PHP version
  3. Whether Composer is in play or whether the codebase is a tarball

SSH in and run:

php bin/magento --version
php -v
ls -la composer.json composer.lock 2>/dev/null

You want output like Magento CLI 2.3.5-p2 and PHP 7.2.34. If you see Magento 1.x or PHP below 7.4, you are not auditing a store, you are auditing a security incident waiting to happen. Magento 1 reached end of life in June 2020. Magento 2.3 reached end of life in September 2022.

If composer.json is missing, the previous dev was hand-editing vendor/ and app/code/. Document it. Every later step gets harder.

Also grab the deploy mode:

php bin/magento deploy:mode:show

The developer mode in production is a slow leak. The default mode hides errors and makes debugging painful. You want production, and if it is not, that is the first sentence in your handover report.

Minute 5 to 15: map the customisation surface

Magento's real risk is not the core. It is the eighteen third-party modules whose authors went silent in 2019.

php bin/magento module:status | head -100
composer show --direct 2>/dev/null

Sort the output mentally into three buckets. Core modules under Magento_* can be ignored unless one is disabled. Known vendors (Amasty, Mageplaza, Mirasvit, Aheadworks) get checked against their site for the latest version. Anything under app/code/Vendor/Module is the bucket where you lose weekends. Read every line.

find app/code -maxdepth 2 -type d

For every custom module, open etc/module.xml and composer.json if present, then grep for the dangerous patterns:

grep -r "eval(" app/code/ | grep -v "// "
grep -r "shell_exec\|passthru\|system(" app/code/
grep -r "base64_decode" app/code/ | head -20
grep -r "file_get_contents.*http" app/code/

Any hit on the first two is a red flag. Hits on the last two are usually fine but worth reading in context. A previous developer once shipped a currency converter module on a store we inherited that pulled rates from a hard-coded IP address every cron tick. The IP was a residential connection in Belarus.

While you are there, check the events and plugins:

find app/code -name "events.xml" -exec grep -H "observer" {} \;
find app/code -name "di.xml" -exec grep -H "plugin\|preference" {} \;

A <preference> overriding a core class is how a small content tweak silently rewrites checkout. Note every one. The same goes for any custom controller routes under etc/frontend/routes.xml, which can quietly expose endpoints that bypass OWASP's standard CSRF and ACL expectations.

Minute 15 to 25: cron, indexers, queues

The two questions that get skipped: is cron actually running, and are the indexers healthy?

crontab -l
ls -la var/log/cron.log var/log/magento.cron.log 2>/dev/null
tail -50 var/log/cron.log

If crontab -l is empty for the web user, the store has been running without cron. Reindex, cache flush, catalog price rules, transactional emails, none of it has fired. Check the timestamps in var/log/cron.log. If the last entry is from November 2024, write that down in 18-point.

Then indexers:

php bin/magento indexer:status

You want every row to read Ready with Update by Schedule or Update on Save. Anything stuck in processing for hours is a dead indexer. Anything Invalid means the schedule has fallen behind. On a store with 40,000 SKUs and a broken catalog_product_price indexer, prices on the storefront can be six months stale.

If the store uses the message queue (most do for async emails and customer imports), peek at the backlog:

SELECT topic_name, COUNT(*) AS queued
FROM queue_message
GROUP BY topic_name
ORDER BY queued DESC
LIMIT 20;

A million unprocessed async.operations.all rows is not a queue, it is a graveyard. Decide whether to drain or truncate before the next deploy, and never both at the same time.

Minute 25 to 35: database health and PII exposure

Connect to the database read-only if you can. The first three queries are blunt:

SELECT
  table_schema AS db,
  ROUND(SUM(data_length + index_length) / 1024 / 1024, 1) AS mb
FROM information_schema.tables
WHERE table_schema = DATABASE()
GROUP BY table_schema;

SELECT
  table_name,
  ROUND((data_length + index_length) / 1024 / 1024, 1) AS mb,
  table_rows
FROM information_schema.tables
WHERE table_schema = DATABASE()
ORDER BY (data_length + index_length) DESC
LIMIT 20;

The usual suspects at the top: sales_order_grid, quote, quote_item, report_viewed_product_index, search_query. If quote has 14 million rows on a store doing 200 orders a month, the quote cleanup job has not run since the Obama administration.

Then the log tables. Magento ships with several that grow forever if you do not vacuum them:

SELECT COUNT(*) FROM customer_log;
SELECT COUNT(*) FROM customer_visitor;
SELECT MIN(last_visit_at), MAX(last_visit_at) FROM customer_visitor;

A customer_visitor table with 50 million rows and entries going back to 2018 is a normal finding on an unmaintained store. It is also a GDPR exposure if the previous dev never set a retention policy. Note dates, not just row counts.

While you are in the database, scan for the obvious credential leaks:

SELECT path, value FROM core_config_data
WHERE path LIKE '%password%'
   OR path LIKE '%api_key%'
   OR path LIKE '%secret%';

Some of these are encrypted, some are not. Document which.

Minute 35 to 40: the kill-switch list

The last five minutes are the most important. You are not fixing anything yet. You are writing down what would take the store offline tonight if you touched it wrong.

Open app/etc/env.php and note:

  • Database host: same server, or a managed cluster?
  • Cache backend: Redis, Varnish, or in-memory?
  • Session backend: where do live carts live?
  • The crypt.key value. Lose this and you cannot decrypt saved customer payment data or third-party credentials.

Then .htaccess. Magento ships a long one. Search for non-default rules. A snippet that gets bolted on without anyone noticing usually looks like this:

<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteCond %{REMOTE_ADDR} !^203\.0\.113\.
    RewriteRule ^admin/.*$ - [F,L]
</IfModule>

Anything bespoke (custom redirects, IP allowlists, a 410 block on /admin) gets copied into your notes verbatim. Apache's own mod_rewrite documentation is worth keeping open while you read these, especially if there are flag combinations you do not recognise.

Finally, look at the admin URL. Magento 2 randomises it by default:

php bin/magento info:adminuri

If the output is /admin and the store is internet-facing, the previous dev disabled the security feature, and the brute-force attempts in var/log/exception.log will tell you how that has been going.

What the 40-minute audit gives you

At the end of this, you have a one-page document that reads something like:

  • Magento 2.3.5-p2 on PHP 7.2, both end-of-life
  • 14 custom modules, 3 with eval calls, 1 calling out to an unknown IP
  • Cron has not run since November
  • customer_visitor has 47M rows back to 2018
  • crypt.key exists in env.php, not backed up anywhere we can find
  • Admin URL is /admin, exception log shows 3,000 brute-force attempts in 30 days

That is a quote. It is also a conversation with the client that is no longer "let me poke around for a few hours and see what I find." It is: here is the state of the store, here is the order I would fix things in, here is what each step costs and why. Clients pay invoices that look like that. They argue with invoices that do not.

What to do today

If you have an inherited Magento store sitting in your task list, do not start with a fix. Do the audit above on a Tuesday morning, write the one-page report, send it to the client before you open the admin panel. The fix list writes itself once that report exists.

When we built Pier we ran into this exact pattern so often that the audit queries above now live as one-click playbooks against the docked site's MySQL editor and SFTP at once. The version history makes the read-only rule enforceable instead of aspirational.

— Questions —

Can I run this audit on a Magento 1 store?

Most of it, yes. Skip the indexer:status and message-queue commands (Magento 1 has neither). The module, cron, database, and .htaccess steps all apply, and the security findings tend to be worse.

What if the previous developer left no SSH access?

Get FTP and database credentials, then run the file-system greps locally after pulling app/code and app/etc. The CLI commands have web-based equivalents in n98-magerun's browser fallback, but SSH is faster.

Should I run setup:upgrade once the audit is finished?

Not without a database snapshot and a staging clone. setup:upgrade on a store with broken custom modules can blow away admin access. Audit first, snapshot second, upgrade in staging third.

How often should the customer_visitor table be cleaned?

Magento's built-in log cleanup, configured under Stores > Configuration > Advanced > System > Log, defaults to 180 days. On an unmaintained store this is almost always disabled or set to the maximum.