SIGIL is what
composer auditwould be if it covered your entire infrastructure — Nginx, Docker, PHP, and your database not just your dependencies.
Sigil scans your actual server configuration files, scores findings by severity, generates executable patches, and tracks configuration drift between deploys. It is a CLI-first tool designed for developers and backend engineers managing self-hosted LEMP stacks.
- Requirements
- Installation
- Commands
- How Fixes Work
- Severity Model
- Rule Library
- Project Structure
- Contributing
- License
- PHP 8.2+
- Composer
Sigil uses four standalone Symfony components not the full framework. It runs on any Linux server that has PHP in $PATH.
Install globally via Composer:
composer global require jcadima/sigilFind the Composer global bin directory
composer global config bin-dir --absoluteYou'll get a path like:
/home/yourusername/.config/composer/vendor/binYou need to add this directory to your PATH so your shell can find sigil anywhere.
Add this line at the end of your shell profile: ~/.zshrc or ~/.bashrc, replacing yourusername with your actual username
export PATH="$PATH:/home/yourusername/.config/composer/vendor/bin"Apply the changes
source ~/.bashrc or source ~/.zshrcVPS / server alternative, symlink to /usr/local/bin (no shell profile changes needed):
sudo ln -sf "$(composer global config bin-dir)/sigil" /usr/local/bin/sigilThis makes sigil available to all users and works in cron jobs, scripts, and CI pipelines without any PATH configuration.
Verify the installation:
sigil --versionAudits your project against the full rule library. Sigil auto-detects your stack (framework, web server, database engine) from .env, docker-compose.yml, and the file system no flags required in most cases.
sigil scan [path] [options]Arguments
| Argument | Default | Description |
|---|---|---|
path |
current directory | Absolute or relative path to the project root being scanned |
Options
| Option | Description |
|---|---|
--output=cli |
Human-readable terminal output with colored severity labels (default) |
--output=json |
Machine-readable JSON to stdout suitable for CI/CD pipelines and the dashboard |
--output=patch |
Generates unified diff files to .sigil/patches/ for all patchable findings |
--env=production |
Override the detected environment context. Affects which rules fire and severity thresholds |
--stack=laravel-docker-nginx |
Skip auto-detection and declare the stack explicitly |
--compat=mariadb |
Force the MariaDB rule pack when .env has DB_CONNECTION=mysql but the image is actually MariaDB |
Examples
# Scan the current directory, display results in terminal
sigil scan
# Scan a specific project path
sigil scan /var/www/myapp
# Scan and output JSON (pipe to jq, send to CI, etc.)
sigil scan /var/www/myapp --output=json | jq '.findings[] | select(.severity == "CRITICAL")'
# Generate patch files for reviewable fixes
sigil scan /var/www/myapp --output=patch
# Force environment context if .env detection is ambiguous
sigil scan /var/www/myapp --env=productionSample output
SIGIL v1.0.0
Detected: laravel · docker · nginx · mysql · production
─────────────────────────────────────────────────────────
LARAVEL / PHP
✖ [CRITICAL] L001 APP_DEBUG=true (.env line 2)
→ sigil enforce --rule=L001
✖ [HIGH] L012 disable_functions not set exec, shell_exec exposed
→ /etc/php/8.3/fpm/php.ini line 312
✓ [PASS] L003 APP_KEY set
✓ [PASS] L005 CSRF middleware present
NGINX
✖ [CRITICAL] N010 .env not blocked in nginx config
→ Patch available: sigil scan --output=patch
✖ [MEDIUM] N004 HSTS header missing
✓ [PASS] N007 TLSv1/1.1 disabled
DOCKER
✖ [CRITICAL] D002 Docker socket mounted in php-fpm container
✖ [HIGH] D001 php-fpm running as root
✖ [HIGH] D007 Port 3306 bound to 0.0.0.0 on host
MYSQL
✖ [CRITICAL] M001 DB_USERNAME=root app connecting as superuser
✓ [PASS] M004 MySQL 8.0 within support window
──────────────────────────────────────────────────────────
Score: 44/100 Critical: 4 High: 3 Medium: 1 Low: 0
Auto-fixable: 3 findings | run 'sigil enforce'
Applies auto-fixes for supported findings. Sigil always writes a backup before modifying any file. CRITICAL and HIGH findings are never auto-applied see How Fixes Work for the full breakdown.
sigil enforce [options]Options
| Option | Description |
|---|---|
--dry-run |
Preview every change that would be made without writing anything |
--rule=ID |
Apply only the fix for a single rule (e.g. --rule=L001) |
Examples
# Preview all pending auto-fixes without applying them
sigil enforce --dry-run
# Apply all eligible fixes interactively (prompts for confirmation)
sigil enforce
# Apply a single specific rule fix
sigil enforce --rule=L010
# Target a specific project path
sigil enforce /var/www/myapp --dry-runInteractive flow
$ sigil enforce
Backup written to .sigil/backups/php.ini.20260307_174800
Backup written to .sigil/backups/.env.20260307_174800
Pending changes:
L001 .env → APP_DEBUG=false
L010 php.ini → expose_php=Off
L011 php.ini → display_errors=Off
L012 php.ini → disable_functions=exec,shell_exec,passthru,proc_open,system
Proceed? [y/N]: y
✓ Applied L001 ✓ Applied L010 ✓ Applied L011 ✓ Applied L012
Backups are stored in .sigil/backups/ inside your project root and can be restored manually if needed.
Saves the current configuration state as a signed baseline for drift comparison. Run this after a deploy or after applying fixes to record a known-good state.
sigil snapshot [path]Snapshots are stored in .sigil/snapshots/ and are HMAC-signed to detect tampering. Each snapshot captures parsed values from .env, nginx.conf, docker-compose.yml, php.ini, and database config files.
# Snapshot the current directory
sigil snapshot
# Snapshot a specific project
sigil snapshot /var/www/myappCompares the current configuration state against the most recent stored snapshot. Use this after deploys, config changes, or when investigating unexpected changes.
sigil drift [path]# Check for drift in the current directory
sigil drift
# Check a specific project
sigil drift /var/www/myappDrift detection flags any configuration value that changed since the last sigil snapshot. Removed rules, new environment variables, changed php.ini directives, and modified nginx blocks all appear as drift events.
Lists all rules available for the detected (or specified) stack, with severity levels and short descriptions.
sigil rules [options]Options
| Option | Description |
|---|---|
--category=<name> |
Filter to a single rule category |
Available categories: laravel, nginx, docker, mysql, mariadb, postgresql
Examples
# List all rules for the auto-detected stack
sigil rules
# List only PostgreSQL rules
sigil rules --category=postgresql
# List only Nginx rules
sigil rules --category=nginxSigil uses three distinct remediation modes depending on the severity and type of finding.
Low-risk findings with safe, deterministic fixes are handled automatically. Sigil writes a backup first, then applies the change.
Auto-fixable rules:
| Rule | What gets fixed |
|---|---|
| L001 | Sets APP_DEBUG=false in .env |
| L006 | Corrects storage/ directory permissions |
| L010 | Sets expose_php=Off in php.ini |
| L011 | Sets display_errors=Off in php.ini |
| L012 | Sets disable_functions in php.ini |
For MEDIUM findings and structural changes to Nginx or Docker config. Sigil generates the diff file you own the apply step.
$ sigil scan --output=patch
Patches written to .sigil/patches/
→ nginx-security-headers.patch
→ nginx-block-env.patch
$ cat .sigil/patches/nginx-block-env.patch
--- /etc/nginx/sites-available/myapp
+++ /etc/nginx/sites-available/myapp.patched
@@ -14,6 +14,10 @@
root /var/www/myapp/public;
+ # SIGIL N010 — Block direct .env access
+ location ~ /\.env {
+ deny all; return 404;
+ }
$ patch /etc/nginx/sites-available/myapp < .sigil/patches/nginx-block-env.patch
$ nginx -t && nginx -s reloadSigil never auto-applies CRITICAL or HIGH findings under any circumstance. Instead it outputs precise, actionable instructions with file references and recommended commands:
D002 — MANUAL REMEDIATION
Docker socket mounted in php-fpm gives container full host root access.
This allows any web shell in your app to escape the container entirely.
Remove from docker-compose.yml:
volumes:
- /var/run/docker.sock:/var/run/docker.sock ← delete this line
After removing: docker compose down && docker compose up -d
| Severity | Meaning | Auto-Fix |
|---|---|---|
| CRITICAL | Active exploitation vector. Requires immediate action. Examples: APP_DEBUG=true, .env publicly accessible, Docker socket mounted. |
Never |
| HIGH | Significant exposure with likely exploitability. Examples: container running as root, root DB user, world-writable storage/. |
Never |
| MEDIUM | Exploitable under specific conditions. Examples: missing HSTS, weak SSL ciphers, no login rate limiting. | Manual (patch) |
| LOW | Best-practice deviation with low immediate risk. Examples: server_tokens On, expose_php On, no health check. |
Yes |
| INFO | Informational, not a vulnerability. Examples: PHP version detected, framework version logged. | Yes |
| Rule | Finding | Severity | Auto-Fix |
|---|---|---|---|
| L001 | APP_DEBUG=true in production .env |
CRITICAL | Yes |
| L002 | .env file readable via HTTP (cross-checked with nginx config) |
CRITICAL | Manual |
| L003 | APP_KEY not set or using a default value |
CRITICAL | No |
| L004 | Session driver set to file with world-readable storage |
HIGH | Manual |
| L005 | CSRF middleware removed from web middleware group | HIGH | No |
| L006 | storage/ directory world-writable (chmod 0777) |
HIGH | Yes |
| L007 | Rate limiting not applied to login/register routes | MEDIUM | Manual |
| L008 | Composer packages with known CVEs (via NVD API) | MEDIUM | No |
| L009 | PHP version below active security support window | MEDIUM | No |
| L010 | expose_php=On in php.ini — version leaked in HTTP headers |
LOW | Yes |
| L011 | display_errors=On in php.ini |
LOW | Yes |
| L012 | disable_functions not set — exec, shell_exec, passthru, proc_open exposed |
HIGH | Yes |
| Rule | Finding | Severity | Auto-Fix |
|---|---|---|---|
| N001 | server_tokens On — nginx version exposed in response headers |
LOW | Yes |
| N002 | Missing X-Frame-Options header (clickjacking vector) |
MEDIUM | Yes |
| N003 | Missing X-Content-Type-Options header |
LOW | Yes |
| N004 | Missing Strict-Transport-Security (HSTS) header |
MEDIUM | Yes |
| N005 | Missing Content-Security-Policy header |
MEDIUM | Manual |
| N006 | autoindex On — directory listing enabled |
HIGH | Yes |
| N007 | Weak SSL protocols enabled (TLSv1.0, TLSv1.1) | HIGH | Yes |
| N008 | Weak SSL cipher suites configured | HIGH | Yes |
| N009 | No rate limiting on authentication endpoints | MEDIUM | Manual |
| N010 | No deny rule for .env — file publicly accessible |
CRITICAL | Yes |
| N011 | client_max_body_size not configured (DoS vector) |
LOW | Yes |
| Rule | Finding | Severity | Auto-Fix |
|---|---|---|---|
| D001 | Container running as root (no USER directive in Dockerfile) |
HIGH | Manual |
| D002 | Docker socket (/var/run/docker.sock) mounted in container |
CRITICAL | Manual |
| D003 | Container using latest image tag — no pinned version |
MEDIUM | No |
| D004 | privileged: true set on container |
CRITICAL | Manual |
| D005 | No memory/CPU resource limits defined on any container | MEDIUM | Yes |
| D006 | Secrets passed as plain environment variables (not Docker secrets) | HIGH | Manual |
| D007 | Database port exposed to host on 0.0.0.0 (3306 or 5432) |
HIGH | Manual |
| D008 | No health check defined for web-facing container | LOW | Yes |
| D009 | No .dockerignore — build context copies entire project |
LOW | Yes |
Loaded automatically when DB_CONNECTION=mysql is detected in .env or the mysql image is found in docker-compose.yml.
| Rule | Finding | Severity | Auto-Fix |
|---|---|---|---|
| M001 | Root user used as application DB user (DB_USERNAME=root) |
CRITICAL | Manual |
| M002 | MySQL port 3306 exposed to host network | HIGH | Manual |
| M003 | No SSL/TLS configured for MySQL connections | MEDIUM | Manual |
| M004 | general_log=ON in production (performance + data exposure) |
LOW | Yes |
| M005 | MySQL version below active security support window | MEDIUM | No |
Loaded when DB_CONNECTION=mariadb is set, or when the mariadb image name is found in docker-compose.yml. Docker image detection takes priority over .env when both are readable.
Note on
unix_socketauth: MariaDB enablesunix_socketauthentication for root by default. Sigil treats this as a PASS. The MB001 root-user rule does not fire for a correctly configured MariaDBunix_socketsetup unlike the equivalent MySQL rule, which would be a false positive here.
If your project has DB_CONNECTION=mysql but is actually running MariaDB, use the --compat=mariadb flag to force the correct rule pack:
sigil scan --compat=mariadb| Rule | Finding | Severity | Auto-Fix |
|---|---|---|---|
| MB001 | Root user used as application DB user | CRITICAL | Manual |
| MB002 | MariaDB port 3306 exposed to host network | HIGH | Manual |
| MB003 | No SSL/TLS for MariaDB connections | MEDIUM | Manual |
| MB004 | MariaDB version below active security support window | MEDIUM | No |
| MB005 | STRICT_TRANS_TABLES not in sql_mode — silent data truncation risk |
MEDIUM | Manual |
Loaded when DB_CONNECTION=pgsql is set, or when the postgres image is found in docker-compose.yml.
Note on
pg_hba.conf: This file is often only readable from inside the database container. If Sigil cannot access it, PG003 and PG007 will be skipped and flagged as INFO with a note to run the scan from inside the container.
| Rule | Finding | Severity | Auto-Fix |
|---|---|---|---|
| PG001 | Superuser used as application DB user (DB_USERNAME=postgres) |
CRITICAL | Manual |
| PG002 | PostgreSQL port 5432 exposed to host network on 0.0.0.0 |
HIGH | Manual |
| PG003 | pg_hba.conf contains trust authentication method |
CRITICAL | Manual |
| PG004 | ssl=off in postgresql.conf — connections unencrypted |
MEDIUM | Manual |
| PG005 | log_connections / log_disconnections disabled — no audit trail |
LOW | Yes |
| PG006 | PostgreSQL version below active security support window | MEDIUM | No |
| PG007 | pg_hba.conf allows all hosts (0.0.0.0/0) without restriction |
HIGH | Manual |
sigil/
├── bin/sigil # Executable entry point
├── src/
│ ├── Application.php # Bootstraps console, registers commands
│ ├── Commands/ # scan, enforce, snapshot, drift, rules
│ ├── Parsers/ # .env, nginx.conf, docker-compose.yml, php.ini, my.cnf, pg_hba.conf
│ ├── Rules/
│ │ ├── RuleInterface.php # evaluate(), getSeverity(), getRemediation(), canAutoFix()
│ │ ├── Laravel/ # L001–L012
│ │ ├── Nginx/ # N001–N011
│ │ ├── Docker/ # D001–D009
│ │ ├── MySQL/ # M001–M005
│ │ ├── MariaDB/ # MB001–MB005
│ │ └── PostgreSQL/ # PG001–PG007
│ ├── Fixers/ # Write layer — separate from Rules
│ ├── Engine/
│ │ ├── ScanContext.php # Data container passed to every rule
│ │ ├── StackDetector.php # Auto-detects framework, web server, DB engine
│ │ ├── RuleEngine.php # Loads rule packs, collects findings
│ │ ├── NvdClient.php # NIST NVD API v2 with 24hr local cache
│ │ └── SnapshotManager.php # HMAC-signed snapshots for drift detection
│ └── Reporters/ # cli, json, patch output renderers
├── stubs/ # Nginx and php.ini patch templates
└── tests/ # Unit + integration tests with fixtures
The architecture follows a strict three-layer separation:
- Rules — read-only. They interrogate
ScanContextand return findings. They never touch the filesystem. - Fixers — the only layer that writes files. Always backs up before modifying.
- Reporters — render
FindingCollectionto the chosen output format.
Contributions are welcome. The most useful contributions are new rules, parser improvements, and bug reports with reproducible fixture cases.
git clone https://github.com/jcadima/sigil
cd sigil
composer install
./vendor/bin/phpunitEvery rule implements RuleInterface. The interface has five methods:
interface RuleInterface
{
public function evaluate(ScanContext $context): FindingCollection;
public function getSeverity(): Severity;
public function getCategory(): string;
public function getRemediation(): Remediation;
public function canAutoFix(): bool;
public function applyFix(ScanContext $context): FixResult;
}The evaluate() method receives the fully parsed ScanContext and returns an empty FindingCollection on pass, or one with Finding objects on failure. Rules never read files directly they always work from the pre-parsed context.
The applyFix() method is only called by EnforceCommand and only if canAutoFix() returns true. Only LOW and INFO severity rules may return true from canAutoFix().
After writing a rule, register it in RuleEngine.php and add unit tests under tests/Unit/Rules/<Category>/.
Integration tests use fixture directories under tests/Fixtures/. Each fixture set is a minimal project structure (.env, nginx.conf, docker-compose.yml, php.ini) representing a specific scenario. If your rule requires a new test scenario, add a fixture directory rather than mocking file reads.
| Category | Prefix | Example class name |
|---|---|---|
| Laravel | L |
AppDebugEnabledRule |
| Nginx | N |
EnvNotBlockedRule |
| Docker | D |
DockerSocketMountedRule |
| MySQL | M |
RootAsAppUserRule |
| MariaDB | MB |
SqlModeStrictRule |
| PostgreSQL | PG |
PgHbaTrustAuthRule |
- Fork the repository and create a branch from
main - Add or modify code with corresponding tests
- Run the full test suite:
./vendor/bin/phpunit - Open a pull request with a clear description of what the rule detects and why it matters
For bug reports, please include the output of sigil scan --output=json (with sensitive values redacted) and the Sigil version (sigil --version).
MIT see LICENSE.
