A forensic scanner for suspicious Laravel filesystem changes
Laravel Scalpel inspects a Laravel project’s filesystem for intrusion evidence and unauthorized changes.
When the problem is on disk
A Laravel application can keep serving requests normally even when someone has placed an executable file where it does not belong. A webshell under public, a modified .htaccess file, or a changed .env file will not necessarily leave an obvious trace in application logs. On a self-hosted server, there is often no security team or external platform watching every filesystem change. That is where an extra filesystem scan becomes useful.
Laravel Scalpel is a package that adds Artisan commands for finding signs of intrusion in a project. It does not inspect HTTP traffic or act as a firewall. Its scope is the files present in the application, their contents, and their differences from a known-good state. I find that especially relevant for Laravel instances running in a homelab, on a VPS, or on a server maintained by a single person.
What the package checks
The main command, scalpel:scan, combines several scanners with separate goals. It can flag PHP files in paths configured as locations where PHP should not exist, including extensions such as .phtml and double extensions like file.php.jpg. It also looks for common obfuscation patterns in PHP code and examines dangerous directives in .htaccess files. The package also scans .user.ini files and performs basic integrity checks on .env.
The point of these checks is not to assume every match is an attack. A finding should start an investigation: identify who deployed the file, compare it with Git, and inspect server permissions. That is why it is worth defining real project exceptions instead of broadly dismissing alerts. Allowed paths and non-PHP zones can be configured in config/scalpel.php.
composer require hryagstn/laravel-scalpel
php artisan vendor:publish --tag=scalpel-config
php artisan scalpel:scanUsing a baseline to identify changes
For a persistent server, the baseline feature is the most useful part. php artisan scalpel:baseline creates a filesystem snapshot, while php artisan scalpel:diff compares the current state against it. The package uses SHA-256 hashes to identify added, removed, and modified files. The baseline should be created after a deployment that is known to be clean, not before dependencies are installed or caches are rebuilt.
The default configuration excludes frequently changing locations, including parts of storage, but that list should be reviewed rather than accepted blindly. Every application may have its own upload, export, or cache directories that would otherwise create noise. At the same time, excluding a path reduces the ability to catch changes inside it. The practical rule is simple: exclude only paths that are genuinely volatile and have little forensic value.
// config/scalpel.php
'non_php_zones' => [
'public',
'storage',
],
'baseline_excluded_paths' => [
'storage/logs',
'storage/framework/cache',
'storage/framework/sessions',
],Running it after deployment
I would use the baseline as a post-deployment task, once the normal Laravel operations have completed. If the process runs php artisan optimize, it should do that before refreshing the snapshot because those files can change legitimately. An external cron job can then run scalpel:diff periodically on the server. This makes it possible to spot changes that happen between deployments, which are the ones most worth investigating.
It can also be useful in CI, but there are two different scenarios to keep separate. In a pull request, scalpel:scan can inspect the tree that is about to be deployed and generate machine-readable results. By contrast, scalpel:diff needs access to a valid baseline for the environment being monitored. Storing one in the repository can work in some workflows, but for a self-hosted server it often makes more sense to keep it outside the public directory and update it as part of deployment.
- name: Run Laravel Scalpel
run: php artisan scalpel:scan --format=sarif > scalpel.sarif
- name: Upload SARIF report
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: scalpel.sarifThe sarif format can send findings to GitHub code scanning. If I want annotations directly in a GitHub Actions run, the package provides --format=github. To fail a workflow when a finding reaches a specific severity, there is a --fail-on option that accepts CRITICAL, HIGH, MEDIUM, or LOW. The right threshold depends on how much noise the project can tolerate, not on a generic policy copied from somewhere else.
The important limitations
Laravel Scalpel is a detection tool, not a containment layer. It runs inside Laravel and will normally share the same operating-system user, permissions, and filesystem access as the application. If an attacker can already write arbitrary code on the server, they may be able to alter the package, its configuration, or the reports before they leave the machine. No Artisan command can solve that trust-boundary problem on its own.
It also does not replace keeping PHP, Laravel, and dependencies patched, or restricting write permissions. In a Docker deployment, keeping code directories on a read-only filesystem and allowing writes only where they are needed reduces the attack surface considerably. Running scans from cron or another external system, then sending results off the server, makes alerts more valuable. The package works best as an early warning signal, not as a replacement for a firewall, backups, or operating-system-level monitoring.
False positives are also part of the job. Legacy code, internal libraries, file-generation tasks, or legitimate Apache rules can trigger a finding. Before suppressing an alert, I would inspect the file, its origin, and the related change in version control. When there is no clear operational explanation for a result, treating it as a potential incident is usually the right call.
Source: Laravel News
Official documentation: Laravel Scalpel on GitHub