Saltar al contenido
← Volver a proyectos

PHP Security Scanner

Framework-agnostic static security scanner for PHP. Detects SQLi, XSS, command injection, path traversal, insecure deserialization, weak crypto, hardcoded secrets, and more.

Laravel Brain

A framework-agnostic, static security scanner for PHP.

PHP License Sponsor Buy Me a Coffee

#PHP Security Scanner

A framework-agnostic, static security scanner for PHP. Detects the OWASP-ish core of vulnerabilities — SQL injection, XSS, command injection, path traversal, insecure deserialization, weak crypto, hardcoded secrets, weak randomness, eval, disabled TLS verification, XXE, open redirect, insecure cookies — using AST analysis with kind-aware taint tracking. Also audits composer.lock against the Packagist Security Advisories database so known-CVE dependencies fail the build.

Ships as a CLI binary, a library you can embed, a PHAR you can download, and a GitHub Action that uploads SARIF to Code Scanning.

#Install

#As a dev dependency

composer require --dev laramint/php-security-scanner
./vendor/bin/php-security-scanner scan src/

#As a PHAR (no Composer conflicts)

curl -fsSL https://github.com/laramint/php-security-scanner/releases/latest/download/php-security-scanner.phar \
    -o /usr/local/bin/php-security-scanner
chmod +x /usr/local/bin/php-security-scanner
php-security-scanner scan src/

#Usage

php-security-scanner scan [paths...] [options]

  --format=pretty|json|sarif|junit          (default: pretty)
  --severity-threshold=low|medium|high|critical  (suppress below)
  --fail-on=critical|high|medium|low|none   (CI gate; default: high)
  --config=PATH                             (.yaml, .yml, or .php; auto-discovers
                                             php-security-scanner.yaml/.yml/.php)
  --exclude=GLOB                            (repeatable)
  --rule=ID                                 (run only these; repeatable)
  --skip-rule=ID                            (skip these; repeatable)
  --baseline=baseline.json
  --update-baseline
  --output=PATH
  --extension=Fqcn\\To\\Extension           (repeatable)
  --no-progress
  --no-audit                                (skip composer.lock CVE audit)
  --audit-offline                           (use cached advisories only)
  --explain                                 (emit INFO notes for sinks the
                                             scanner considered but kept silent,
                                             naming the sanitizer/cast/method
                                             that cleared the taint)

php-security-scanner list-rules

Exit codes: 0 clean • 1 finding ≥ --fail-on2 config error.

The pretty progress output is PHPUnit-style — one character per file written to stderr so piping JSON/SARIF/JUnit to stdout stays clean:

....⨯.....⨯..............................................   60 / 134 (44%)
.....................................⨯......                134 / 134 (100%)

  FAIL  134 file(s) scanned in 1.42s, 6 finding(s).

. (gray) = clean file, (red bold) = file with at least one finding. Trailing label shows progress (done / total (%)) and the final summary shows the elapsed scan time.

By default, the file walker silently skips vendor/, node_modules/, bower_components/, .git/, storage/, bootstrap/cache/, build/, dist/, out/, coverage/, IDE state (.idea/, .vscode/, .fleet/, .zed/), and tool caches (.phpunit.cache/, .phpstan.cache/, .pint.cache/, .psalm.cache/, .rector.cache/, etc.) — see FileFinder::DEFAULT_SKIP_DIRS. Only add project-specific extras to exclude.

#Rules

ID Default severity Detects
eval high eval() use, especially with tainted input
sql.injection critical Tainted input concatenated into raw SQL
cmd.injection critical Tainted input passed to exec/shell_exec/system/...
path.traversal high Tainted input used as file path or include/require target
xss.echo high Tainted input echoed without htmlspecialchars
deserialize critical unserialize() on tainted input
crypto.weak-hash medium md5/sha1 on security-sensitive data
crypto.deprecated high mcrypt_* functions
random.insecure medium rand/mt_rand/uniqid used for tokens/secrets/CSRF
secret.hardcoded high High-entropy literals in $api_key, SECRET, etc.
ssl.disabled critical CURLOPT_SSL_VERIFYPEER/VERIFYHOST set to false/0
xxe high XML parsed without LIBXML_NONET
redirect.open medium header("Location: …") from tainted input
cookie.insecure medium setcookie() without secure/httponly/samesite
assert.use high assert() with a string/tainted argument (eval-like)
cmd.backticks high Backtick shell-exec operator, especially with tainted input
eval.mb-ereg high mb_ereg_replace() with the e (eval) modifier
file.inclusion critical LFI/RFI: tainted input in include/require
callable.tainted critical Tainted value used as a callable in call_user_func and friends
object.tainted-new high Tainted value used as class name in new $cls()
ssrf high Tainted URL passed to curl_init, curl_setopt(CURLOPT_URL), Guzzle, etc.
cors.permissive high Access-Control-Allow-Origin: * or reflected origin
net.ftp-cleartext medium ftp_connect() — credentials/data in clear (use ftp_ssl_connect)
ldap.anonymous-bind high ldap_bind() with empty/null/missing password
crypto.mcrypt-deprecated high Use of any mcrypt_* function
crypto.md5-loose-eq medium Loose ==/!= comparison on md5/sha1/hash output
crypto.md5-password high $password = md5(...) / sha1(...) — use password_hash()
crypto.cbc-static-iv high openssl_encrypt in CBC mode with a constant/empty IV
crypto.openssl-decrypt-novalidate medium openssl_decrypt with unauthenticated mode (CBC/ECB)
crypto.sha224 low hash('sha224', …) — truncated SHA-256, prefer sha256/sha512
info.phpinfo high phpinfo() exposes runtime config and environment
audit.composer.vulnerable-package from advisory (default high) Composer dependency with a known CVE published on Packagist
audit.composer.network-error info Could not contact the Packagist advisories endpoint while auditing composer.lock

Run php-security-scanner list-rules to see the live list.

#Dependency CVE audit

Whenever the scanner finds a composer.lock at the root of any scanned path, it queries the Packagist Security Advisories API for the exact installed versions and emits one finding per (package, advisory) pair — same Finding shape as code rules, so baselines, severity overrides, --fail-on, and every reporter format work uniformly. CVE id, advisory link, affected version range, and report date are stored under each finding's metadata.

  • Auto-discovered — no flag required.
  • Results are cached at sys_get_temp_dir()/php-security-scanner-advisories.json for 6 hours.
  • --no-audit disables the audit entirely. --audit-offline reuses the warm cache and never contacts Packagist (useful in CI without egress).
  • A transient network failure produces a single informational audit.composer.network-error finding (severity info) so it doesn't trip --fail-on high.
  • Constraint matching uses composer/semver when present on the autoloader; otherwise a built-in matcher covers the operators Packagist actually emits (>=, <=, >, <, =, !=, AND with ,, OR with |/||, and x.y.* wildcards).

#Inline suppression

For code that is intentionally unsafe or already reviewed, add a suppression comment directly in the source file — no external baseline file needed.

Comment Effect
// @php-security-ignore Suppress all rules on the next line
// @php-security-ignore rule.id Suppress a specific rule on the next line
echo $x; // @php-security-ignore End-of-line form — suppresses this line
// @php-security-ignore-start Suppress all rules until …-end
// @php-security-ignore-start rule.id Suppress a specific rule until …-end
// @php-security-ignore-end Close the most-recently opened block
// @php-security-ignore-file Suppress all findings in the entire file

Examples:

// @php-security-ignore
echo $userHtml;                         // this line is suppressed

echo $safe; // @php-security-ignore     // end-of-line form, same effect

// @php-security-ignore-start
echo $a;                                // suppressed
echo $b;                                // suppressed
// @php-security-ignore-end
echo $c;                                // NOT suppressed

// @php-security-ignore-start xss.echo
echo $a;                                // XSS suppressed; SQL rules still active
// @php-security-ignore-end

// @php-security-ignore xss.echo       // rule-scoped single-line
echo $d;

Blocks can be nested; each @php-security-ignore-end closes the innermost open block. Rule-scoped and all-rules blocks may be mixed.

#Configuration

Drop a php-security-scanner.yaml (or .yml) in your project root — auto-discovered, no flag needed. See php-security-scanner.dist.yaml:

paths:
  - src
  - app

# vendor/, node_modules/, .git/, build/, *.cache/ etc. are skipped by default —
# only add project-specific extras here.
exclude:
  - tests/fixtures/**
  - '*.blade.php'

rules:
  secret.hardcoded:
    severity: critical
  xss.echo:
    enabled: false
  audit.composer.vulnerable-package:
    severity: critical

taint:
  extra_sources:
    - { type: method, class: 'App\Http\MyRequest', method: raw }
  extra_sanitizers:
    - { type: function, name: my_clean_html, clears: [html] }
    # Method whose return value is trusted regardless of receiver:
    - { type: method, method: sanitized, clears: [html, sql, shell, path] }

extensions:
  # - 'LaraMint\PhpSecurityScannerLaravel\LaravelExtension'

baseline: .security-baseline.json
fail_on: high

Discovery order: --config <path> (any of .yaml/.yml/.php) > php-security-scanner.yaml > php-security-scanner.yml > php-security-scanner.php in each scan-root, then in the current working directory. The legacy PHP form (which still works — see php-security-scanner.dist.php) just needs to return the same shape.

#GitHub Action

- uses: laramint/php-security-scanner@v1
  id: scan
  with:
    path: src
    fail-on: high
- uses: github/codeql-action/upload-sarif@v3
  if: always()
  with:
    sarif_file: ${{ steps.scan.outputs.sarif-path }}

Findings appear in the repo's Security → Code scanning tab and inline on PR diffs.

#Framework extensions

Framework-specific rules live in companion packages. The core stays framework-agnostic; install whichever ones apply.

Status

Framework Package Status
Laravel laramint/php-security-scanner-laravel ✅ Available
Yii laramint/php-security-scanner-yii 🚧 Planned (not yet released)
Symfony laramint/php-security-scanner-symfony 🚧 Planned (not yet released)
WordPress laramint/php-security-scanner-wordpress 🚧 Planned (not yet released)

Only the Laravel extension is shipped today; Yii, Symfony, and WordPress are on the roadmap. Contributions welcome — the API is small (see Writing your own extension below).

composer require --dev laramint/php-security-scanner-laravel

Installed extensions are auto-loaded via Composer (the package declares its FQCN in extra.php-security-scanner.extension). You can also list them explicitly in your config under extensions, or pass --extension=Fqcn\\To\\Extension for ad-hoc runs.

#Writing your own extension

use LaraMint\PhpSecurityScanner\Extension\Extension;
use LaraMint\PhpSecurityScanner\Rules\RuleRegistry;
use LaraMint\PhpSecurityScanner\Taint\{SourceRegistry, SinkRegistry, SanitizerRegistry};

final class MyExtension implements Extension
{
    public function name(): string { return 'my-framework'; }

    public function register(
        RuleRegistry $rules,
        SourceRegistry $sources,
        SinkRegistry $sinks,
        SanitizerRegistry $sanitizers,
    ): void {
        $sources->addMethod('App\\Http\\MyRequest', ['raw', 'all']);
        $sanitizers->addFunction('my_escape', ['html']);
        $rules->register(new MyCustomRule());
    }
}

To distribute it as a Composer package, add this to your composer.json:

"extra": {
    "php-security-scanner": {
        "extension": "MyVendor\\MyPackage\\MyExtension"
    }
}

It will be auto-discovered the moment a consumer installs your package.

#How it works

ScanCommand → Analyzer → for each PHP file:
   PhpFileParser (LRU-cached AST + use map)
   ↓
   TaintEngine pre-pass (variable origin map, sanitizer-aware propagation)
   ↓
   FileScanner (one NodeTraverser, rules dispatched by node type)
   ↓
   Reporter (pretty / JSON / SARIF / JUnit)

Taint kinds are HTML / SQL / Shell / Pathhtmlspecialchars clears HTML XSS but not SQL injection; escapeshellarg clears shell but not XSS. This avoids the typical static-analyzer false-negative where a single "tainted/clean" flag is fooled by the wrong sanitizer.

#Zero-false-positive policy

Rules emit a finding only when a tainted flow from a known source to a known sink can be proven with no sanitizer, cast, or cleansing accessor on the path. When the scanner cannot prove the danger, it stays silent — by design. The trade-off is that some real vulnerabilities behind cross-function flows or unknown sanitizers will be missed; the gain is that what is reported is actionable.

Recognized cleansers (extend via config or extensions):

  • Function sanitizershtmlspecialchars, htmlentities, strip_tags, e (Laravel helper), escapeshellarg, escapeshellcmd, basename, realpath, pathinfo, intval, floatval, doubleval, abs, intdiv, ctype_digit, filter_var, filter_input, urlencode, rawurlencode.
  • Type casts(int), (float), (bool) clear injection-sensitive taint. (string) and (array) preserve it.
  • Cleansing methods (kind-agnostic)->validate(...), ->validated(), ->safe(), ->integer(...), ->boolean(...), ->float(...). Useful for request()->validate([...]), FormRequest accessors, and similar framework patterns.

#--explain mode

To see why the scanner stayed silent on a given sink, run with --explain. INFO-severity findings name the cleanser that neutralized the taint:

$ php-security-scanner scan src/ --explain

[INFO] Considered XSS sink echo: silent — taint cleared by htmlspecialchars() sanitizer.
  src/Http/Controllers/UserController.php:42  (xss.echo, confidence: high, CWE-79)

[INFO] Considered SQL sink mysqli_query(): silent — taint cleared by (int) cast.
  src/Legacy/Reports.php:114  (sql.injection, confidence: high, CWE-89)

The flag auto-lowers --severity-threshold to info so the notes surface. Currently emitted by the sql.injection and xss.echo rules. Explain notes never replace real findings — HIGH/CRITICAL output is unchanged.

Caveat: the explanation walks the sink argument's AST directly. If the taint was cleared at an earlier assignment (e.g. $id = (int)$_GET['id']; ... $db->query("... " . $id);), the note isn't emitted — at the sink the engine only sees a clean $id variable.

#Limitations

Intra-procedural only — no cross-file or cross-function data flow, no class-property taint, no array-key precision. Combined with the zero-FP policy above, this means the scanner will miss any vulnerability whose dangerous flow crosses function/file boundaries or passes through a sanitizer the engine doesn't recognize. The scanner is fast and predictable; for deeper analysis pair it with Psalm or PHPStan with taint plugins.

#Development

git clone https://github.com/laramint/php-security-scanner.git
cd php-security-scanner
composer install
composer test          # PHPUnit
composer test:types    # PHPStan

Smoke-scan the bundled fixtures:

./bin/php-security-scanner scan tests/fixtures/vulnerable --fail-on=none
./bin/php-security-scanner scan tests/fixtures/safe         # must be clean

#License

MIT — see LICENSE.

#php-security-scanner

Nueva versión disponible.