laramint/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.

Maintainers

Package info

github.com/laramint/php-security-scanner

pkg:composer/laramint/php-security-scanner

Statistics

Installs: 28

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-05-15 00:59 UTC

This package is auto-updated.

Last update: 2026-05-15 01:06:17 UTC


README

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