sota-php
State-of-the-art PHP engineering (2026 baseline, PHP 8.3+ floor, 8.5 current) for both writing new PHP and auditing existing PHP code. Covers strict_types and modern idioms (enums, readonly, match, fibers, property hooks), OWASP-grade security (SQL injection, XSS, file uploads, L
Install
npx skills add https://github.com/martinholovsky/SOTA-skills/tree/main/skills/sota-php
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install martinholovsky-sota-skills@llmmart
git clone https://github.com/martinholovsky/SOTA-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole martinholovsky/sota-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
SOTA PHP (2026)
Purpose
This skill encodes the 2026 state of the art for PHP: a supported-version baseline
(PHP 8.3+ floor; 8.5 is the latest annual line — verify current, see rules/01), strict_types everywhere, typed
object-oriented design, security-by-default at every trust boundary, a locked and audited
Composer supply chain, and measured runtime performance. It serves two modes:
- BUILD — writing new code or modifying existing code to this standard.
- AUDIT — reviewing existing code against this standard and reporting findings.
The detailed rules live in rules/*.md. Read SKILL.md fully; load rules files on demand
per the index table below.
BUILD mode
When creating or modifying PHP code:
- Establish context first. Check
composer.json(require.php,config.platform),composer.lock, the framework in use, PHPStan/Psalm config, and CS ruleset. Match the project's PHP floor — no enums on a project that still supports 8.0. For a new project, scaffold perrules/05: PHP ≥ 8.3 floor, committed lockfile, PHPStan at max level (baseline only for legacy), PER-CS formatting, CI gates from day one. - Default style:
declare(strict_types=1)in every file, full parameter/return/ property types, constructor promotion,readonlywhere state shouldn't mutate, enums over class constants,matchoverswitch, exceptions over error codes, no@suppression. (rules/01) - Security posture is non-optional even when unrequested: PDO prepared statements,
context-correct output escaping, upload validation by content, no
unserialize()on external data,password_hash/sodium/random_bytesfor anything secret. (rules/02–rules/04) - Framework first. When a framework is present (e.g. Laravel, Symfony), use its
escaping, CSRF, auth, and validation mechanisms instead of hand-rolling — but verify
raw-escape hatches (
DB::raw,|raw,html()) aren't fed user input. - Tests accompany code (PHPUnit or Pest as the project dictates); static analysis
and CS must pass before code is presented. (
rules/05) - Performance: OPcache assumptions belong in deploy config, not code; anything
beyond correct-by-default (eager loading, streaming, generators) requires a profile
first. (
rules/06)
AUDIT mode
When reviewing existing PHP code:
- Sweep mechanically first. Run the "Audit checklist" blocks at the end of every
relevant rules file — ordered grep/composer/phpstan commands. Start with
composer audit --lockedand a grep sweep forunserialize(,eval(,shell_exec, string-interpolated SQL, andecho $_. - Then read for design: trust-boundary placement, escaping strategy (output-time or scattered?), session lifecycle, N+1 patterns, lockfile discipline.
- Verify every finding — open the file, trace the data flow. An
unserialize()of a value the same app signed with HMAC is not CRITICAL. Note mitigations already present. - Don't report style noise a fixer would auto-fix; mention once collectively.
Severity conventions
| Severity | Meaning | Examples |
|---|---|---|
| CRITICAL | Exploitable now, or data loss | SQL built by interpolation from request data, unserialize($_GET…), include of user path, eval on input, uploads executed as PHP |
| HIGH | Exploitable with preconditions, or prod-breaking | XSS via unescaped output, md5() passwords, missing use_strict_mode/fixation, SSRF fetch of user URL, CURLOPT_SSL_VERIFYPEER => false, world-readable secrets |
| MEDIUM | Correctness/maintenance risk | no lockfile committed, no composer audit in CI, loose == on security decisions, rand() for tokens in non-auth context, N+1 on hot path, no static analysis |
| LOW | Deviation from SOTA, friction | missing strict_types, untyped properties, switch where match fits, dev deps in prod image |
| INFO | Worth knowing | newer-PHP features available after floor bump, tooling consolidation |
Finding format
file:line | rule violated (rules/NN §S) | severity | effort | fix
Effort: trivial · small · medium · large. Group by severity, CRITICAL first. Borderline severities state the deciding assumption; unconfirmed findings are marked "needs verification", never asserted. End with counts per severity, the sweep commands run, and explicit "checked and clean" areas.
Rules index
| File | Read this when... |
|---|---|
rules/01-language-baseline.md |
choosing/verifying PHP version floor (support/EOL table); writing any PHP: strict_types, typed properties, enums, readonly, match, fibers, 8.4/8.5 features, comparison pitfalls incl. strpos returning false where 0 is a real match, error handling, deprecations |
rules/02-injection.md |
code touching SQL, shell, or HTML output: PDO prepared statements, command execution, XSS and context-aware escaping, template engines, eval-family bans |
rules/03-files-deserialization-ssrf.md |
file uploads, include/require paths, stream wrappers (LFI/RFI/phar://), unserialize and Phar object injection, XXE, server-side URL fetching (SSRF) |
rules/04-sessions-auth-web-hardening.md |
login/session/auth code: session cookie flags and fixation, password_hash/argon2id, sodium crypto, CSRF, security headers, production php.ini hardening |
rules/05-composer-tooling.md |
dependencies and CI: composer.lock discipline, composer audit, platform reqs, PHPStan/Psalm levels and baseline ratcheting, PER-CS, PHPUnit/Pest, CI gates |
rules/06-performance-runtime.md |
anything slow or deploy-shaped: OPcache and preloading, JIT reality check, PHP-FPM pool sizing, N+1/caching, autoloader optimization, profiling. Test strategy lives in sota-testing; DB depth in sota-databases. |
Top-10 non-negotiables
- Run a supported PHP (≥ 8.2 today, and 8.2 is security-only until 2026-12-31 —
plan the 8.3+ move now); new code targets 8.3+. (
rules/01) declare(strict_types=1)in every file; full types on every property, parameter, and return. Untyped is legacy, not a style choice. (rules/01)- SQL only via prepared statements with bound parameters (PDO/mysqli, emulation
off); identifiers via allowlist. String-built SQL is CRITICAL, no exceptions for
"internal" values. (
rules/02) - Escape at output, for the right context —
htmlspecialchars(…, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')or the template engine's auto-escaping; raw-output escape hatches never receive user input. (rules/02) - Never
unserialize(),eval(), orinclude/requiredata you don't fully control. External data is JSON. Filter user paths forphar://and friends. (rules/03) - Uploads: validate by content, rename randomly, store non-executable — never trust
client filename or MIME; never let the webserver execute uploads. (
rules/03) - Passwords via
password_hash()(bcrypt default, or argon2id) +password_verify; secrets viarandom_bytes/sodium; compare withhash_equals. Never md5/sha1/rand()/uniqid()for anything secret. (rules/04) - Sessions hardened:
use_strict_mode=1, cookiesSecure+HttpOnly+SameSite,session_regenerate_id(true)on privilege change. (rules/04) composer.lockcommitted; CI runscomposer install(neverupdate) andcomposer audit --locked; prod installs--no-dev. (rules/05)- PHPStan (or Psalm) gates CI at the highest level the project can hold; the
baseline only shrinks. OPcache on in prod; performance claims require a profile.
(
rules/05,rules/06)
Files (sota-skills)
-
rules
-
01-language-baseline.md 10.4 KB
# 01 — Language baseline & idioms Modern PHP is a strictly-typed language. Most legacy-PHP pain (type juggling, register-globals folklore, `@` suppression) is opt-out today — these rules make opting out the default. ## 1. Version baseline: know the support windows Verified against php.net/supported-versions.php and php.net/releases (2026-07): | Branch | Status | Active support until | Security fixes until | |---|---|---|---| | 8.2 | security-only | ended 2024-12-31 | **2026-12-31** | | 8.3 | security-only | ended 2025-12-31 | 2027-12-31 | | 8.4 | active | 2026-12-31 | 2028-12-31 | | 8.5 | active (current stable, released 2025-11-20) | 2027-12-31 | 2029-12-31 | - 8.1 and older are **EOL** (8.1's final release was 8.1.34, 2025-12; PHP 7 ended 2022-11). Running them is a HIGH finding on internet-facing systems. - Each branch gets 2 years active + 2 years security-only (php.net policy). - **BUILD:** target 8.3+ as the floor for new projects (8.2 exits security support 2026-12-31 — months away); use 8.4/8.5 features when the floor allows. - **AUDIT:** check `composer.json` `require.php` and `config.platform.php` against the table; flag EOL floors and floors about to lapse. Feature timeline for floor decisions: enums, `readonly` properties, fibers, first-class callable syntax (8.1); `readonly` classes, DNF types (8.2); typed class constants, `#[\Override]`, `json_validate()` (8.3); property hooks, asymmetric visibility, `new X()->method()` without parens, bcrypt default cost 10→12 (8.4); pipe operator `|>`, `clone with`, `#[\NoDiscard]`, `array_first`/`array_last`, closures in constant expressions, URI extension (8.5, per php.net/releases/8.5). ## 2. strict_types and real types, everywhere Without `declare(strict_types=1)`, scalar type declarations *coerce* ("42abc" may pass an `int` parameter with a notice, `"1"` passes `bool`). With it, mismatches throw `TypeError`. ```php <?php declare(strict_types=1); // first statement, every file — no exceptions ``` - Every property, parameter, and return gets a type. `mixed` is a documented last resort, not a default; `?Type` over implicit-nullable (implicit nullable parameters are deprecated since 8.4). - Use union/intersection/DNF types where they model reality (`(Countable&Traversable)|null`), not to paper over unclear design. - `array` hides shape: for structured data prefer a small typed class (or at least a PHPDoc `array{id: int, name: string}` shape that PHPStan/Psalm check). - Value objects: constructor promotion + `readonly`: ```php final class Money { public function __construct( public readonly int $amountMinor, public readonly Currency $currency, ) {} public function withAmount(int $amountMinor): self { return clone($this, ['amountMinor' => $amountMinor]); // 8.5 clone-with // pre-8.5: return new self($amountMinor, $this->currency); } } ``` - 8.4+ property hooks replace getter/setter boilerplate; asymmetric visibility (`public private(set)`) replaces "public getter, private setter" pairs. ## 3. Enums over constants; match over switch ```php enum OrderStatus: string // backed enum when it's persisted/serialized { case Pending = 'pending'; case Shipped = 'shipped'; case Cancelled = 'cancelled'; } $status = OrderStatus::tryFrom($raw) ?? throw new InvalidArgumentException( sprintf('unknown status "%s"', $raw), ); ``` - `::from()` throws `ValueError` on unknown input; `::tryFrom()` returns null — choose deliberately at trust boundaries. - Enums can carry methods and interfaces; use them instead of parallel `match`/lookup tables scattered around the codebase. `match` beats `switch`: strict (`===`) comparison, no fallthrough, it's an expression, and an unhandled value throws `\UnhandledMatchError` instead of silently doing nothing: ```php $label = match ($status) { OrderStatus::Pending => 'In progress', OrderStatus::Shipped => 'Done', OrderStatus::Cancelled => 'Cancelled', }; // adding a case to the enum makes this throw until handled — good ``` Audit `switch` on security-relevant values as MEDIUM (loose comparison + fallthrough hazards). ## 4. Comparison and juggling discipline - `==` compares after juggling; **always `===`/`!==`** unless a comment justifies otherwise. Classic traps: `0 == "a"` was true before 8.0 (string-to-number comparison changed in PHP 8.0 — saner, but `"1" == "01"` is still true), `null == false == 0 == ""` are all true. - `in_array($needle, $arr)` and `array_search` juggle by default — pass `strict: true`. `switch` juggles and cannot be fixed — prefer `match`. - `strcmp()`-style return values and `0` are falsy: `if (strpos($s, $p))` is a bug when the needle is at offset 0 — use `str_contains`/`str_starts_with` (8.0+) or `!== false`. - Never use `==` on anything security-relevant (tokens, hashes, MACs): juggling plus magic-hash pitfalls (`"0e123..." == "0e456..."`). Use `hash_equals()` (see `rules/04`). ## 4a. In-band sentinels — `strpos` is the textbook case PHP's search functions return **`false`** for not-found, and a legitimate match at the start returns **`0`** (verified, PHP 8.5.8): `strpos("abc","z")` is `false`, `strpos("abc","a")` is `int(0)`. With loose comparison `0 == false` is **true**, so `if (strpos($h,$n) == false)` reports "not found" on a match at offset 0 — the canonical instance of the class in `sota-architecture` rules/02 §8a, and the reason §4's identity rule exists. ```php if (strpos($h, $n) === false) { /* not found */ } // === is mandatory, not style ``` - Same shape: `array_search` (returns `false`; verified), `strrpos`, `stripos`. `str_contains`/`str_starts_with` (8.0+) return real `bool` — prefer them whenever you only need the yes/no, and the trap disappears. - Writing your own: return `null` (with a `?int` return type under `strict_types`) or throw. Returning `false` from an `int`-ish function reproduces the stdlib's worst API in code you control. - `intval("x")` / `(int)"x"` is `0`, indistinguishable from `(int)"0"` — use `filter_var($s, FILTER_VALIDATE_INT)`, which returns `false` for invalid, and test it with `===`. - Audit: `grep -rnE '(strpos|stripos|strrpos|array_search)\s*\(' --include='*.php' src/ | grep -v '==='` — every hit without `===` is a finding. ## 5. Errors and exceptions, not silence - Production ini: `display_errors=Off`, `log_errors=On`; development: `error_reporting(E_ALL)` and fail on warnings in tests. (OWASP PHP Configuration Cheat Sheet.) - **No `@` suppression** — it hides the error *and* costs a handler round-trip. The only near-acceptable uses are APIs with no error-free variant; wrap those once and document. - Throw exceptions; don't return `false|string` unions from new APIs. Define a small package-level exception hierarchy (`DomainException` subclasses), chain with `previous:`. - `json_decode(..., flags: JSON_THROW_ON_ERROR)` — silent `null` returns are a classic injection/logic hazard. 8.3+ `json_validate()` for validate-only. - Since 8.5, uncaught fatal errors include backtraces (php.net/releases/8.5) — make sure stack traces still never reach responses (`rules/04` §6). - `DateTimeImmutable` over `DateTime`; pass an explicit `DateTimeZone`; never parse dates with juggling (`strtotime` on user input needs validation). ## 6. Fibers and concurrency (8.1+) `Fiber` is a *low-level* cooperative-concurrency primitive: full-stack interruptible functions (`Fiber::suspend()`/`resume()`). It does **not** make code parallel and does not schedule anything by itself. - Application code should not hand-roll fiber schedulers. Use an event-loop runtime built on fibers (e.g. Revolt/AMPHP v3, ReactPHP) where async I/O concurrency is genuinely needed. - Classic FPM request/response code gains nothing from fibers — concurrency there is process-level (see `rules/06` FPM sizing). Long-running runtimes (CLI workers, e.g. FrankenPHP/Swoole-style servers as neutral examples) are where async PHP pays off; in those, blocking calls (`PDO`, `file_get_contents`) stall the whole loop — same discipline as any event loop. - AUDIT: raw `new Fiber(` in application (non-library) code is a MEDIUM design smell; blocking I/O inside an event-loop callback is HIGH in async runtimes. ## 7. Deprecations and legacy constructs to remove on sight - **Removed** (fail on modern PHP): `create_function` (8.0), string-argument `assert()` (8.0), `preg_replace` `/e` modifier (7.0), `mcrypt_*` (7.2), `each()` (8.0), curly-brace string offsets `$s{0}` (8.0). - **Deprecated** (fix now): backtick operator `` `cmd` `` and `__sleep`/ `__wakeup` (both deprecated in 8.5 — use `__serialize`/`__unserialize`), implicit nullable params (8.4), dynamic properties without `#[\AllowDynamicProperties]` (8.2). - **Legacy smells:** `extract()` on request data (variable injection), variable-variables `$$name` from input, `global` keyword in new code, `array_merge` in loops (quadratic — use spreads/`array_push`), `register_shutdown_function` as error handling. ## Audit checklist Run from repo root; verify each hit manually. ```bash # Missing strict_types — LOW per file, MEDIUM if project-wide grep -rL --include='*.php' 'declare(strict_types=1)' src/ | head -50 # EOL / lapsing PHP floor — check require.php against the table in §1 grep -n '"php"' composer.json php -v # Loose comparison on suspicious values — MEDIUM+, verify context grep -rnE '[^=!<>]==[^=]' --include='*.php' src/ | grep -iE 'token|password|hash|hmac|secret|sig' grep -rnE 'in_array\([^)]*\)' --include='*.php' src/ | grep -v 'true' # strpos truthiness bug grep -rnE 'if\s*\(\s*!?\s*strpos\(' --include='*.php' src/ # Error suppression and silent JSON grep -rn '@' --include='*.php' src/ | grep -E '@\s*[a-z_]+\(' | grep -v '//' grep -rn 'json_decode' --include='*.php' src/ | grep -v 'JSON_THROW_ON_ERROR' # Removed/deprecated constructs grep -rnE '(create_function|each\(|__sleep|__wakeup|\$\$[a-zA-Z]|extract\s*\(\s*\$_)' --include='*.php' src/ grep -rn '`' --include='*.php' src/ | grep -vE '(//|\*|#)' # backtick exec # Untyped properties (heuristic; rely on PHPStan level 6+ for the real sweep) grep -rnE '^\s*(public|protected|private)\s+\$' --include='*.php' src/ # switch on request-derived values — prefer match grep -rn 'switch\s*(' --include='*.php' src/ ``` Severity guide: EOL PHP in production HIGH; missing strict_types project-wide MEDIUM; loose `==` on security decisions HIGH; `@`-suppressed security function HIGH; style-level items LOW/INFO. -
02-injection.md 8.4 KB
# 02 — Injection: SQL, shell, and XSS Trust-boundary thinking: `$_GET`/`$_POST`/`$_COOKIE`/headers, uploaded files, DB content, queue payloads, and LLM output are attacker-controlled until proven otherwise. PHP's history is a catalog of interpolation bugs — the fix is always the same: **keep data out of the code/query/markup channel.** ## 1. SQL: prepared statements with bound parameters, nothing else String-interpolated SQL is CRITICAL regardless of the value's origin — "it comes from our own table" is how second-order injection happens. (OWASP SQL Injection Prevention Cheat Sheet: parameterized queries are defense #1.) ```php // BAD — CRITICAL, even with (worse: because of) manual quoting/escaping $rows = $pdo->query("SELECT * FROM users WHERE email = '" . $email . "'"); $rows = $pdo->query(sprintf("SELECT * FROM users WHERE id = %s", $id)); // GOOD — PDO named parameters $stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email'); $stmt->execute(['email' => $email]); $user = $stmt->fetch(); ``` PDO setup that makes the safe path the real path: ```php $pdo = new PDO($dsn, $user, $password, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // default since 8.0 PDO::ATTR_EMULATE_PREPARES => false, // real server-side prepares PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]); ``` - `ATTR_EMULATE_PREPARES => false` sends the statement and data separately (true separation, and typed results on mysqlnd). Emulated prepares are client-side string splicing — safe when used correctly, but one charset or driver quirk away from not being safe. - **Identifiers can't be bound.** Table/column names and `ORDER BY ... ASC|DESC` from user input go through a hardcoded allowlist, never quoting: ```php $col = ['name', 'created_at'][$idx] ?? 'created_at'; $dir = $desc ? 'DESC' : 'ASC'; $stmt = $pdo->prepare("SELECT * FROM users ORDER BY $col $dir LIMIT :n"); ``` - `LIKE`: bind the parameter *and* escape wildcards in it — `addcslashes($term, '%_\\')` — or user input `%` scans the table. - `IN (...)`: build exactly as many `?` placeholders as values; never implode values into the string. - ORMs/query builders (e.g. Doctrine, Eloquent) parameterize by default, but their raw escape hatches don't: audit `->raw(`, `DB::raw(`, `whereRaw(`, `createNativeQuery`, string-concatenated DQL. Same rule applies inside them. - `mysqli`: same discipline (`prepare`/`bind_param`). Any use of `mysqli_real_escape_string` as the *primary* defense is a finding (HIGH): it's charset-sensitive and doesn't help outside quoted string context. ## 2. Command execution: no shell between you and the binary Prefer no process at all (native functions, extensions). When you must run one: ```php // BAD — CRITICAL with any user-influenced part shell_exec("convert $input out.png"); system('ping -c1 ' . $host); $out = `ls $dir`; // backticks = shell_exec; deprecated in 8.5 // GOOD — argv array, no shell involved (proc_open array mode, 7.4+) $p = proc_open(['convert', $input, 'out.png'], $spec, $pipes); // Acceptable when a shell string is unavoidable: escape EVERY argument $cmd = 'ping -c1 ' . escapeshellarg($host); ``` - `proc_open` with an **array** command bypasses the shell entirely — the strongest option (php.net proc_open). Symfony Process (array syntax) is a neutral-example wrapper doing the same. - `escapeshellarg()` escapes one argument; `escapeshellcmd()` escapes a whole command *but leaves argument splitting possible* — it is not a substitute. - Watch argument injection even with perfect quoting: a value starting with `-` becomes an option. Prepend `--` where the tool supports it, or validate shape. - `mail()`: the 4th/5th parameters historically enabled header/argument injection — validate/drop user input there; prefer a mailer library (e.g. Symfony Mailer) as a neutral example. - Ban list for user-reachable paths: `eval()`, `assert()` with dynamic input, `preg_replace_callback` with attacker-chosen callables, `call_user_func`/ variable functions `$fn()` where `$fn` derives from input, `unserialize` (see `rules/03`). ## 3. XSS: escape at output, for the exact context Escaping at *input* time is the classic mistake — data gets double-escaped, mis-escaped for the context, or bypassed by a second write path. Store raw, escape at the sink. (OWASP Cross Site Scripting Prevention Cheat Sheet.) **HTML body and attribute context:** ```php // BAD — HIGH echo "<p>Hello {$_GET['name']}</p>"; // GOOD — the one true incantation; wrap it in a helper e() echo '<p>Hello ', htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), '</p>'; ``` - `ENT_QUOTES` covers single-quoted attributes; `ENT_SUBSTITUTE` prevents invalid-UTF-8 from truncating output; the explicit `'UTF-8'` pins the charset (default since 5.4, pin it anyway). Attribute values must also be *quoted* in the markup — escaping alone doesn't save `<img src=x onerror=...>` in an unquoted attribute. - `htmlentities` is not more secure, just noisier; `strip_tags` is not an XSS defense (attributes survive, and it destroys data). **JavaScript context:** never splice into script; pass via JSON with hex flags: ```php <script> const cfg = <?= json_encode($cfg, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_THROW_ON_ERROR) ?>; </script> ``` **URL context:** `rawurlencode()` for components; validate whole user-supplied URLs — scheme allowlist (`https?`) — before emitting in `href` (blocks `javascript:`). Redirect targets: allowlist or same-origin check (open redirect, and see SSRF in `rules/03`). **Templates:** use an auto-escaping engine — e.g. Twig (`autoescape` on by default) or Blade `{{ }}` — and treat the raw syntaxes (`|raw`, `{!! !!}`, `<?= $x ?>` in plain PHP templates) as audit targets: every one needs a proven non-user-controlled source. Plain-PHP templates escape via a project-wide `e()` helper; naked `<?=` of a variable is a finding until traced. **Rich text** (user HTML): sanitize with a real allowlist sanitizer — e.g. `Symfony\Component\HtmlSanitizer` or HTML Purifier as neutral examples — never regex/`strip_tags`. **Defense in depth:** a strict `Content-Security-Policy` (see `rules/04` §6) and `X-Content-Type-Options: nosniff` cap the blast radius; they don't replace escaping. ## 4. Header and log injection - `header()` rejects CR/LF since PHP 5.1.2 (response splitting), but building headers from input still enables open redirects (`Location: $_GET['next']`) and cache poisoning — validate/allowlist values. - Strip `\r`/`\n` from user data before writing to line-oriented logs, or log structured JSON; otherwise attackers forge log entries. ## Audit checklist Run from repo root; verify each hit manually (greps are recall-oriented). ```bash # SQL built from strings — CRITICAL if user data reaches it grep -rnE '(->query|->exec|_query)\s*\(\s*["'"'"'].*(\$|\bsprintf|\. )' --include='*.php' src/ grep -rnE '(SELECT|INSERT|UPDATE|DELETE)[^;]*(\{\$|"\s*\.\s*\$|\'\s*\.\s*\$)' --include='*.php' -i src/ grep -rnE '(whereRaw|selectRaw|orderByRaw|havingRaw|DB::raw|->raw\()' --include='*.php' src/ grep -rn 'EMULATE_PREPARES' --include='*.php' src/ # want: false grep -rn 'real_escape_string' --include='*.php' src/ # HIGH if primary defense # Shell — CRITICAL with tainted input grep -rnE '\b(exec|shell_exec|system|passthru|popen|pcntl_exec)\s*\(' --include='*.php' src/ grep -rn 'proc_open' --include='*.php' src/ # array command = good sign grep -rn '`' --include='*.php' src/ | grep -vE '(//|\*|#)' grep -rnE '\b(eval|assert)\s*\(\s*\$' --include='*.php' src/ # XSS — echo/print of request data, raw template sinks grep -rnE '(echo|print|<\?=)[^;]*\$_(GET|POST|REQUEST|COOKIE|SERVER)' --include='*.php' . grep -rnE '<\?=\s*\$(?!this)' --include='*.php' templates/ 2>/dev/null grep -rn '{!!' --include='*.blade.php' resources/ 2>/dev/null grep -rn '|raw' --include='*.twig' templates/ 2>/dev/null grep -rn 'strip_tags' --include='*.php' src/ # not an XSS defense # Header/redirect injection grep -rnE 'header\s*\(\s*["'"'"']Location:.*\$' --include='*.php' src/ # json_encode into <script> without hex flags grep -rn 'json_encode' --include='*.php' src/ | grep -v 'JSON_HEX' ``` Severity guide: interpolated SQL or shell with user input CRITICAL; unescaped output of request data HIGH; raw template sink with untraced source HIGH until proven benign; missing hex flags on script-embedded JSON MEDIUM; escaping at input time instead of output MEDIUM (design). -
03-files-deserialization-ssrf.md 9.5 KB
# 03 — Files, deserialization, and SSRF PHP's include system, stream wrappers, and native serialization form one connected attack surface: a "harmless" file path becomes code execution via `include`, `phar://`, or `unserialize`. Treat every user-influenced path, file, and URL as hostile. ## 1. File uploads: validate content, own the name, deny execution Never trust anything the client sent: `$_FILES[...]['name']` and `['type']` are attacker-chosen. (OWASP File Upload Cheat Sheet.) ```php $f = $_FILES['avatar'] ?? null; if ($f === null || $f['error'] !== UPLOAD_ERR_OK) { /* reject */ } if (!is_uploaded_file($f['tmp_name'])) { /* reject */ } if ($f['size'] > 2 * 1024 * 1024) { /* reject */ } // 1. Content-derived type, allowlist only $mime = new finfo(FILEINFO_MIME_TYPE)->file($f['tmp_name']); $ext = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'][$mime] ?? throw new RuntimeException('unsupported type'); // 2. Server-generated name — client filename is display metadata at most $name = bin2hex(random_bytes(16)) . '.' . $ext; // 3. Non-executable destination, outside the webroot move_uploaded_file($f['tmp_name'], '/srv/app/storage/uploads/' . $name); ``` - **Storage:** outside the document root, served via a controlled handler (with `Content-Type` you set, `Content-Disposition`, `X-Content-Type-Options: nosniff`) or from object storage/a separate cookieless domain. If files must live under the webroot, the directory gets *no PHP execution* (webserver config: no handler/`php_admin_flag engine off` equivalent) — double extensions (`x.php.jpg`), trailing-dot and case tricks defeat blocklists. - Extension **allowlist** derived from sniffed content, never the client name; reject polyglot-prone types you don't need (SVG = XSS vector unless sanitized). For images, re-encoding (GD/Imagick) strips embedded payloads — but keep Imagick patched (historic RCEs) and consider it a trade-off, not free. - Archives: extraction is a traversal vector ("zip slip") — validate each entry name against the target dir before writing; cap entry count/size ratios (zip bombs). - ini caps (`upload_max_filesize`, `post_max_size`, `max_file_uploads`) are the outer DoS guard (OWASP PHP Configuration Cheat Sheet), not validation. ## 2. Path traversal, LFI/RFI, and stream wrappers Any `include`/`require`/`fopen`/`file_get_contents`/`readfile` whose path is user-influenced is a code-execution candidate, not "just" file disclosure. ```php // BAD — CRITICAL: LFI, and with allow_url_include, RFI include $_GET['page'] . '.php'; readfile('/var/reports/' . $_GET['name']); // GOOD — closed set: map input to known files $page = ['home' => 'home.php', 'about' => 'about.php'][$_GET['page']] ?? 'home.php'; include __DIR__ . '/pages/' . $page; // GOOD — dynamic filenames: canonicalize, then prove containment $base = '/var/reports/'; $real = realpath($base . basename($_GET['name'])); if ($real === false || !str_starts_with($real, $base)) { throw new RuntimeException('invalid path'); } readfile($real); ``` - Prefer allowlist maps over sanitizing. When sanitizing: `basename()` to drop directories, `realpath()` to resolve `..` and symlinks, then a prefix check against the canonical base (with trailing separator). - **Stream wrappers escalate LFI:** `php://filter` (source disclosure via base64 chains), `phar://` (deserialization, §3), `data://`, `expect://`, `zip://`. Reject any user path containing `://`, or `parse_url` scheme-check it. Functions beyond include are affected — `file_exists('phar://…')` used to be enough pre-8.0 (§3). - ini: `allow_url_include=Off` (default off since 7.4 deprecation; removed as a real option risk — keep it off), and `allow_url_fopen=Off` unless remote fetching is genuinely needed (OWASP PHP Configuration Cheat Sheet); `open_basedir` as a coarse second fence. ## 3. Deserialization: unserialize() is code execution; JSON is data `unserialize()` on attacker data = **PHP object injection**: instantiated objects fire `__destruct`/`__wakeup`/`__toString`, and public gadget-chain catalogs (e.g. the phpggc project) cover major frameworks and libraries. Assume a chain exists for your dependency graph. ```php // BAD — CRITICAL on any external data (cookies, hidden fields, cache, queues) $prefs = unserialize($_COOKIE['prefs']); // GOOD — external data is JSON $prefs = json_decode($cookie, associative: true, flags: JSON_THROW_ON_ERROR); // If a legacy format forces unserialize: cap the damage AND authenticate first $data = unserialize($raw, ['allowed_classes' => false]); // scalars/arrays only $data = unserialize($raw, ['allowed_classes' => [Point::class]]); // tight allowlist ``` - `allowed_classes => false` (7.0+) blocks object instantiation but not all DoS shapes; it is the floor, not the fix. The fix is JSON (or a schema-ed format) plus validation. - Data that must round-trip internally (cache, queues) still transits attacker-reachable systems — sign it: `hash_hmac('sha256', $payload, $key)` verified with `hash_equals()` *before* deserializing. - **Phar:** a `.phar`'s metadata is a serialized blob. Since **PHP 8.0**, the `phar://` wrapper no longer auto-unserializes metadata on file operations — only `Phar->getMetadata()` does (PHP RFC: phar_stop_autoloading_metadata, accepted 25-0). Pre-8.0, `file_exists('phar://upload.jpg')` was an RCE primitive. Still: never call `getMetadata()` on untrusted archives (8.0+ accepts an `unserializeOptions` allowlist argument), and keep §2's wrapper filtering so uploads are never addressed as `phar://`. - Same family: `wddx` (removed 7.4), reading `serialize()`d session/cache blobs populated by less-trusted code. ## 4. XML: XXE and entity expansion - Since **PHP 8.0**, libxml external entity loading is disabled by default (requires libxml ≥ 2.9; `libxml_disable_entity_loader()` is deprecated because it became unnecessary — php.net migration80). Do not re-enable. - Never parse untrusted XML with `LIBXML_NOENT` (entity substitution) or `LIBXML_DTDLOAD`. Audit any occurrence as HIGH. - Billion-laughs/entity expansion: reject DTDs outright on untrusted input (`$dom->loadXML($xml, LIBXML_NONET)` and check `$dom->doctype === null`). ## 5. SSRF: server-side fetching of user-influenced URLs A URL fetched by the server reaches things the user can't: cloud metadata (`169.254.169.254`), localhost admin ports, internal services. (OWASP Server Side Request Forgery Prevention Cheat Sheet.) ```php function assertSafeUrl(string $url): void { $p = parse_url($url); if (!in_array($p['scheme'] ?? '', ['http', 'https'], true)) fail(); $ips = gethostbynamel($p['host'] ?? '') ?: []; foreach ($ips as $ip) { if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) fail(); } } ``` - Prefer a **positive allowlist** of hosts/URL prefixes; IP-range blocklists are the fallback and must run on the *resolved* address, cover IPv6 (`::1`, `fe80::`, mapped IPv4), and beware DNS rebinding (resolve once, pin via `CURLOPT_RESOLVE`). - cURL hardening: `CURLOPT_PROTOCOLS`/`CURLOPT_REDIR_PROTOCOLS` limited to HTTP(S) — redirects can bounce to `gopher://`/`file://`; cap `CURLOPT_MAXREDIRS` or re-validate each hop; set timeouts; **never** `CURLOPT_SSL_VERIFYPEER => false` (HIGH). - The strongest control is architectural: route egress through a proxy that enforces the allowlist (network-level, see sota-network-security), so a missed validation isn't fatal. - `file_get_contents($url)`/`fopen` honor redirects with no protocol pinning — use a real HTTP client for remote fetches. ## Audit checklist Run from repo root; verify each hit manually. ```bash # Uploads — client-trusted name/type, executable destinations grep -rnE "\\\$_FILES\[[^]]+\]\['(name|type)'\]" --include='*.php' src/ grep -rn 'move_uploaded_file' --include='*.php' src/ # trace dest: webroot? renamed? grep -rn 'is_uploaded_file' --include='*.php' src/ # absent near move_* = MEDIUM # LFI/RFI/traversal — user data reaching include/fs functions grep -rnE '(include|require)(_once)?\s*[( ][^;]*\$_(GET|POST|REQUEST|COOKIE)' --include='*.php' src/ grep -rnE '(file_get_contents|fopen|readfile|file_put_contents|copy|unlink)\s*\([^;]*\$_' --include='*.php' src/ grep -rnE '(phar|expect|data|zip)://' --include='*.php' src/ grep -rn 'php://filter' --include='*.php' src/ php -r 'echo ini_get("allow_url_include"), "|", ini_get("allow_url_fopen"), PHP_EOL;' # Deserialization — CRITICAL on external data grep -rn 'unserialize(' --include='*.php' src/ | grep -v 'allowed_classes' grep -rnE 'unserialize\s*\(\s*\$_(GET|POST|COOKIE|REQUEST)' --include='*.php' src/ grep -rn 'getMetadata' --include='*.php' src/ grep -rnE '__(destruct|wakeup|toString)' --include='*.php' src/ # gadget surface inventory # XML grep -rnE 'LIBXML_(NOENT|DTDLOAD)' --include='*.php' src/ grep -rn 'libxml_disable_entity_loader' --include='*.php' src/ # deprecated; check PHP<8 paths # SSRF — user URLs fetched server-side grep -rnE '(curl_init|file_get_contents|fopen|->request|->get)\s*\([^;]*\$' --include='*.php' src/ | grep -iE 'url|uri|host|endpoint|webhook' grep -rn 'CURLOPT_SSL_VERIFYPEER' --include='*.php' src/ # false = HIGH grep -rn 'CURLOPT_FOLLOWLOCATION' --include='*.php' src/ # check REDIR_PROTOCOLS nearby ``` Severity guide: `unserialize`/`include` of external data CRITICAL; uploads executable or client-named HIGH; user-URL fetch with no allowlist/IP validation HIGH (CRITICAL when cloud metadata is reachable); `LIBXML_NOENT` on untrusted XML HIGH; missing `is_uploaded_file` MEDIUM. -
04-sessions-auth-web-hardening.md 9 KB
# 04 — Sessions, auth, crypto, and web hardening Framework-neutral: whether sessions come from raw `session_start()` or a framework layer (e.g. Laravel, Symfony), the same properties must hold — verify them in *effective* config, not defaults you assume. ## 1. Session hardening Required ini/effective settings (OWASP Session Management + PHP Configuration Cheat Sheets; php.net session security manual): ```ini session.use_strict_mode = 1 ; reject attacker-supplied (uninitialized) IDs session.use_only_cookies = 1 ; never accept IDs from URLs session.cookie_secure = 1 ; HTTPS-only cookie session.cookie_httponly = 1 ; no JS access session.cookie_samesite = Lax ; Strict where UX allows session.sid_length = 48 ; entropy of the ID (pre-8.4 tunable) ``` - `use_strict_mode=1` is the **session fixation** kill switch — without it, PHP happily adopts any ID the attacker planted. Default is 0; always set it. - **Regenerate on privilege change:** `session_regenerate_id(true)` immediately after login, logout, and role elevation. The `true` deletes the old session file; without regeneration, a pre-login ID stays valid post-login (fixation). - Logout destroys server state: `$_SESSION = []; session_destroy();` plus expiring the cookie — not just a client-side redirect. - Implement **idle timeout and absolute lifetime** in app logic (timestamps in the session); `gc_maxlifetime` is garbage collection, not access control. - Bind sessions loosely to context (IP /24 or UA family) only if your users tolerate it; log mismatches either way. - Never put secrets, roles, or prices in cookies/hidden fields; the session ID is the only client-held session artifact. Custom session storage (e.g. Redis) keeps the same rules. ## 2. Passwords: password_hash, nothing homemade ```php $hash = password_hash($password, PASSWORD_DEFAULT); // bcrypt today // or, when compiled with Argon2 (or via libsodium): $hash = password_hash($password, PASSWORD_ARGON2ID); if (!password_verify($password, $hash)) fail(); if (password_needs_rehash($hash, PASSWORD_DEFAULT)) { // transparently upgrade cost/algorithm at successful login store(password_hash($password, PASSWORD_DEFAULT)); } ``` Verified against php.net password_hash (2026-07): - `PASSWORD_DEFAULT` = bcrypt; **default bcrypt cost rose 10 → 12 in PHP 8.4**. The constant is designed to change — store hashes in the self-describing `$2y$`/`$argon2id$` format (password_hash does) and rely on `password_needs_rehash` for migrations. - `PASSWORD_ARGON2ID` exists since 7.3 and requires Argon2 support compiled in (libargon2, or the sodium implementation since 7.4). OWASP Password Storage Cheat Sheet ranks argon2id first, bcrypt as the solid default; both are fine — `md5`/`sha1`/`sha256(+salt)`/`crypt()` for passwords are HIGH findings. - bcrypt truncates at 72 bytes; since 8.4 PHP rejects longer inputs with `ValueError` rather than silently truncating — cap length in validation. - Reset/verification tokens: `bin2hex(random_bytes(32))`, stored **hashed** (`hash('sha256', $token)`), single-use, short TTL. - Compare any secret (tokens, HMACs, API keys) with **`hash_equals($known, $user)`** — `==`/`===` are timing-unsafe and `==` also has magic-hash juggling traps (`rules/01` §4). - Rate-limit and lock out at the auth boundary; log failures. MFA/passkey and IdP architecture → sota-identity-access; app-level flows → sota-code-security. ## 3. General crypto: sodium first, random_bytes always - **CSPRNG:** `random_bytes()` / `random_int()` only. `rand`, `mt_rand`, `array_rand`, `str_shuffle`, `uniqid()` (timestamp-based, even with `more_entropy`) are predictable — HIGH wherever the value gates anything. 8.2+ `Random\Randomizer` with `Random\Engine\Secure` is fine (same source). - **Authenticated encryption:** libsodium is in core since PHP 7.2 — `sodium_crypto_secretbox` (symmetric), `sodium_crypto_aead_xchacha20poly1305_ietf_*`, `sodium_crypto_box`/`sign` (asymmetric). New nonce per message (`random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES)`), keys from a secret manager, `sodium_memzero()` after use. - If constrained to OpenSSL: AEAD only (`openssl_encrypt` with `aes-256-gcm`, checking/passing `$tag`); CBC without an HMAC is a padding-oracle finding. `mcrypt` was removed in 7.2 — its presence means abandoned code. - Key derivation from passwords: `sodium_crypto_pwhash` or argon2id — never a bare hash of the password as key material. - JWTs and OAuth flows: use a maintained library (e.g. lcobucci/jwt, web-token) — alg allowlist, `none` rejected; details in sota-code-security. ## 4. CSRF State-changing requests need a CSRF defense; `SameSite` cookies are strong but not sufficient alone (subdomain and same-site-scripting caveats — OWASP CSRF Prevention Cheat Sheet recommends token + SameSite). - Use the framework mechanism where present (e.g. Symfony form tokens, Laravel `@csrf`) — audit for routes excluded from verification. - Hand-rolled: synchronizer token — `bin2hex(random_bytes(32))` in the session, embedded per-form, compared with `hash_equals`, rotated on login. Verify on every non-GET route, centrally (middleware), not per-handler. - GET must never mutate state (also a CSRF hole via `<img src>`). ## 5. Production php.ini hardening Per the OWASP PHP Configuration Cheat Sheet: ```ini display_errors = Off ; stack traces/paths leak internals display_startup_errors = Off log_errors = On expose_php = Off ; drop X-Powered-By allow_url_include = Off allow_url_fopen = Off ; unless remote fetch is a real requirement open_basedir = /srv/app ; coarse containment fence disable_functions = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec ; tailor to what the app truly needs ``` - `disable_functions` is defense in depth against webshells/RCE pivots — build the list from what the app *doesn't* use, and keep CLI workers on a separate ini if they need more. - Run PHP-FPM as a dedicated non-root user; app files not writable by that user (a writable webroot turns any file-write bug into RCE); secrets in env/ secret manager, not in webroot files (`.env` must be denied by the webserver — better, outside the docroot entirely). - Uncaught exceptions must map to a generic 500 page; the chain goes to logs only (`rules/01` §5). ## 6. Security headers Set centrally (middleware or webserver), not per-page: - `Content-Security-Policy` — nonce/hash-based `script-src`, `object-src 'none'`, `frame-ancestors` (replaces `X-Frame-Options`); start `Content-Security-Policy-Report-Only`. - `Strict-Transport-Security: max-age=31536000; includeSubDomains` once HTTPS is universal; cookies also `__Host-` prefixed where possible. - `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, restrictive `Permissions-Policy`. - CORS: explicit origin allowlist; `Access-Control-Allow-Origin: *` with credentials is invalid anyway — reflecting arbitrary Origins with credentials is the real-world HIGH. ## Audit checklist Run from repo root; verify each hit manually. ```bash # Session config — effective values, not file greps alone php -r 'foreach (["use_strict_mode","use_only_cookies","cookie_secure","cookie_httponly","cookie_samesite"] as $k) echo "session.$k=", ini_get("session.$k"), PHP_EOL;' grep -rn 'session_regenerate_id' --include='*.php' src/ # absent near login = HIGH grep -rnE 'session_id\s*\(\s*\$' --include='*.php' src/ # attacker-settable ID # Password handling grep -rnE '\b(md5|sha1|crypt)\s*\(' --include='*.php' src/ | grep -iE 'pass|pwd' grep -rn 'password_hash' --include='*.php' src/ grep -rn 'password_needs_rehash' --include='*.php' src/ # absent = MEDIUM (stuck costs) # Weak randomness / timing-unsafe compares — HIGH where security-relevant grep -rnE '\b(rand|mt_rand|uniqid|str_shuffle|array_rand)\s*\(' --include='*.php' src/ grep -rnE '(===?)\s*\$.*(token|signature|hmac|hash)' -i --include='*.php' src/ grep -rn 'hash_equals' --include='*.php' src/ # Crypto grep -rn 'mcrypt' --include='*.php' src/ # removed 7.2 — abandoned code grep -rnE "openssl_encrypt\([^)]*(cbc|ecb)" -i --include='*.php' src/ grep -rn 'sodium_crypto' --include='*.php' src/ # CSRF — token verified centrally? exclusions? grep -rnE '(csrf|_token)' -il --include='*.php' src/ | head grep -rn 'VerifyCsrfToken' -r app/ 2>/dev/null # e.g. Laravel: check $except # ini hardening + headers php -r 'foreach (["display_errors","expose_php","allow_url_include","allow_url_fopen","open_basedir","disable_functions"] as $k) echo "$k=", ini_get($k), PHP_EOL;' curl -sI https://target/ | grep -iE 'content-security|strict-transport|x-content-type|x-powered-by' ``` Severity guide: fixation (no strict mode + no regeneration) HIGH; md5/sha1 passwords HIGH; predictable tokens HIGH; missing CSRF on state change HIGH; `display_errors=On` in prod MEDIUM (HIGH if traces confirmed reaching users); missing CSP/headers MEDIUM. -
05-composer-tooling.md 7 KB
# 05 — Composer, static analysis, and CI The PHP supply chain is Composer + Packagist; code quality is enforced by static analysis, not convention. A project without a committed lockfile, an audit gate, and PHPStan/Psalm in CI is unreviewed by 2026 standards. ## 1. Composer discipline - **Apps commit `composer.lock`.** CI and prod run `composer install` — which installs the exact locked versions — never `composer update`, which re-resolves and rewrites the lock (getcomposer.org CLI docs). Updates are deliberate PRs with a lock diff. - Libraries don't ship a lock to consumers but should still test against lowest and highest supported dependency sets (`composer update --prefer-lowest` in one CI job). - **Production install:** ```sh composer install --no-dev --prefer-dist --no-interaction --no-progress \ --optimize-autoloader composer check-platform-reqs # ext-* and PHP version actually present? ``` `--no-dev` keeps test/dev tooling out of the artifact (attack surface + size); `--optimize-autoloader` see `rules/06` §5. - **Platform requirements:** declare the PHP floor and every extension in `require` (`"php": "^8.3", "ext-pdo": "*", "ext-sodium": "*"`); set `config.platform.php` to the floor so resolution on a dev machine with a newer PHP can't pull packages the servers can't run (getcomposer.org docs). - **Plugin/script surface:** Composer plugins can run arbitrary code at install time; the `allow-plugins` config must be an explicit allowlist (Composer ≥2.2 prompts by default). Review `scripts` in composer.json diffs like code. - Version constraints: `^` ranges (semver), never `*` or `dev-master`; pin a commit hash when depending on a VCS fork. ## 2. composer audit and advisory gates `composer audit` checks installed (or `--locked`) packages against security advisories via the Packagist API, and also reports abandoned packages; exit code is non-zero when issues are found — CI-gateable (getcomposer.org CLI docs): ```sh composer audit --locked --abandoned=fail # in CI, on every PR + nightly ``` - `--format=json` for tooling; `--ignore-severity` exists but each ignore needs a tracked justification. - Abandoned packages are a real risk class (unpatched forever) — at minimum `--abandoned=report` and a migration ticket per hit. - Alternative/complementary: requiring `roave/security-advisories` (neutral example) makes *installing* a known-vulnerable version impossible at resolve time. - Renovate/Dependabot-style automation keeps the lock fresh; pair with the audit gate so urgency is advisory-driven, not calendar-driven. ## 3. Static analysis: PHPStan or Psalm, gating CI **PHPStan** has rule levels **0–10**; 10 (added in PHPStan 2.0) also flags *implicit* `mixed` — missing types — not just explicit ones (phpstan.org/user-guide/rule-levels). ```neon # phpstan.neon (or phpstan.dist.neon) parameters: level: 10 # new code: max; legacy: highest level that holds paths: [src, tests] ``` - **New projects start at the max level.** Legacy projects: pick the highest level, generate a baseline (`phpstan analyse --generate-baseline`), and enforce the **ratchet**: the baseline file only ever shrinks. A growing baseline means the gate is theater — MEDIUM finding. - Don't silence with `@phpstan-ignore` casually; each ignore carries a reason string. Prefer fixing types; use generics PHPDoc (`@template`, `array<int, Order>`) so collections stay typed. - **Psalm** is the equivalent alternative (note its levels run inverted: 1 = strictest, 8 = loosest). Run one of them, not both, at the strictest sustainable setting; `--taint-analysis` mode (Psalm) is a useful audit supplement for injection tracing. - The type checker runs on PRs against the same PHP version(s) as prod, with extensions available (or stubs configured). ## 4. Style and tests - **Formatting is automated, not reviewed:** PHP-CS-Fixer or PHP_CodeSniffer (neutral examples) pinned to the **PER-CS** ruleset (PHP-FIG's successor to PSR-12; PER-CS 2.x current — php-fig.org). `--dry-run --diff` in CI; local fix via pre-commit/composer script. - **Tests:** PHPUnit is the baseline; Pest (neutral example) layers a concise syntax on the same runner. Either way: data providers over copy-paste, one behavior per test, no order dependence, `assertSame` over `assertEquals` (strict comparison). Test *strategy* — suite shape, doubles, flake policy — lives in `sota-testing`. - Coverage needs a driver: pcov (fast, coverage-only) or Xdebug in coverage mode — CI-only; never on prod (`rules/06` §6). - Mutation testing (e.g. Infection) as a periodic quality probe on core domains, not a per-PR gate. ## 5. CI pipeline: the minimum gate set Every PR, in rough dependency order: ```sh composer validate --strict # composer.json sanity + lock in sync composer install --no-interaction # from lock, cached composer audit --locked # advisories + abandoned vendor/bin/php-cs-fixer check --diff vendor/bin/phpstan analyse --no-progress vendor/bin/phpunit # or: vendor/bin/pest ``` - **Matrix:** run tests on every PHP minor the code claims to support (`require.php`), floor *and* current — a `^8.2` constraint tested only on 8.5 is untested advertising. - Nightly job re-runs `composer audit` (advisories land independent of commits). - Pipeline/runner/secrets hardening (pinned actions, OIDC, SLSA) → sota-devsecops; this file owns only the PHP-specific gates. ## Audit checklist Run from repo root; verify each hit manually. ```bash # Lockfile discipline git ls-files composer.lock | grep -q . || echo "NO LOCKFILE COMMITTED (app = MEDIUM/HIGH)" composer validate --strict # flags json/lock drift grep -nE '"(php|ext-)' composer.json # platform reqs declared? grep -n '"platform"' composer.json # config.platform.php pinned? # Advisory + abandonment status right now composer audit --locked --abandoned=report # Risky constraints and install-time code grep -nE '"[^"]+"\s*:\s*"(\*|dev-)' composer.json grep -n '"scripts"' composer.json # review script contents grep -n 'allow-plugins' composer.json # explicit allowlist? # Static analysis presence + ratchet health ls phpstan*.neon* psalm*.xml* 2>/dev/null | grep -q . || echo "NO STATIC ANALYSIS CONFIG (MEDIUM)" grep -n 'level' phpstan*.neon* 2>/dev/null wc -l phpstan-baseline.neon 2>/dev/null # compare against last audit: shrinking? git log --oneline -5 -- phpstan-baseline.neon 2>/dev/null # CI gates actually wired (adjust path to CI system) grep -rnE '(composer audit|phpstan|psalm|php-cs-fixer|phpcs|phpunit|pest)' .github/workflows/ .gitlab-ci.yml 2>/dev/null # Dev deps leaking into prod artifacts grep -rn 'composer install' Dockerfile* .github/workflows/ 2>/dev/null | grep -v -- --no-dev ``` Severity guide: app with no committed lock or CI running `composer update` MEDIUM (HIGH once envs drift); no advisory gate MEDIUM; known-vulnerable dep currently installed HIGH/CRITICAL per advisory; no static analysis MEDIUM; growing baseline MEDIUM; dev deps in prod image LOW/MEDIUM. -
06-performance-runtime.md 8.5 KB
# 06 — Performance & runtime: OPcache, FPM, JIT, profiling PHP performance is mostly *runtime configuration and I/O shape*, not micro-optimization: OPcache on, FPM sized to memory, queries not multiplied by loops. Measure before optimizing; a profile is the entry ticket for any optimization PR. ## 1. OPcache: non-negotiable in production Without OPcache every request re-lexes and re-compiles every file. Verified against php.net OPcache configuration docs (2026-07): ```ini opcache.enable = 1 opcache.memory_consumption = 256 ; default 128 MB — size to the codebase, ; interned strings come out of this total opcache.max_accelerated_files = 20000 ; > count of .php files (vendor included) opcache.validate_timestamps = 0 ; immutable deploys: never stat files opcache.interned_strings_buffer = 16 ``` - `validate_timestamps=0` requires an OPcache reset on deploy — restart FPM or atomic-symlink switch with new realpaths. If deploys overwrite files in place, keep timestamps on (`revalidate_freq` low) or serve stale code. - Monitor `opcache_get_status()`: cache full, `oom_restarts`, low hit rate are silent performance cliffs; alert on them. - CLI workers: `opcache.enable_cli=1` only pays off for long-running processes. ## 2. Preloading (7.4+) `opcache.preload=/srv/app/preload.php` compiles chosen files once at FPM startup, linked into every worker — removing per-request autoload/compile for the hot core (php.net opcache.preload). - Preload the stable hot path (framework kernel, core domain classes), not all of `vendor/` — preloaded code is held **until server restart**; changing it requires an FPM restart, and it's shared across all pools of the process (php.net warns against preloading in shared/multi-tenant setups). - Not supported on Windows (php.net). - Measure: preloading typically buys a few percent on framework-heavy stacks — worth it at scale, not worth operational complexity for small apps. ## 3. JIT: reality check Verified against php.net OPcache configuration docs (2026-07): as of PHP 8.4 the defaults are `opcache.jit=disable` with `opcache.jit_buffer_size=64M` reserved — i.e. JIT ships **off**; enabling is a deliberate act (`opcache.jit=tracing`, the recommended mode). - **Typical web workloads are I/O-bound** (DB, cache, HTTP): JIT cannot optimize waiting, and published benchmarks of framework request paths show small single-digit gains at best. Don't expect throughput wins from flipping it on. - JIT shines on CPU-bound PHP: numeric loops, image/data processing, long-running CLI workers with typed hot functions. - If enabled: benchmark your actual workload before/after, watch memory (buffer is shared memory), and re-test after PHP upgrades — JIT bugs historically surface as heisencrashes. Debuggers/profilers may need JIT off. - AUDIT: `opcache.jit` enabled with no benchmark justification is INFO; claiming "JIT will fix it" for an I/O-bound app is a wrong-tool finding. ## 4. PHP-FPM sizing and hygiene FPM concurrency is the worker count — the classic failure is `pm.max_children` set by folklore, either OOM-killing the box or queueing requests while RAM sits idle. ```ini pm = static ; dedicated app servers; dynamic/ondemand for ; shared or spiky/low-traffic hosts pm.max_children = 40 ; = (RAM budget for PHP) / (avg worker RSS) pm.max_requests = 1000 ; recycle workers to cap leak accumulation pm.status_path = /fpm-status ; scrape it: active/idle, listen queue request_slowlog_timeout = 5s ; slowlog with stack traces of slow requests slowlog = /var/log/php-fpm/slow.log ``` - Measure average worker RSS under real traffic (`ps` on pool workers), leave headroom for OPcache shared memory + everything else on the host. - `listen.backlog` and the status page's `listen queue` reveal saturation before users do; alert on queue > 0 sustained. - One pool per app, distinct user per pool (also a security boundary, `rules/04` §5). - **Session locking:** file-backed sessions serialize concurrent requests per user; call `session_write_close()` as soon as writes are done, or use a store with proper locking semantics. - Long-running CLI/queue workers: recycle on a request/memory budget (`--max-jobs`-style flags or supervisor restarts), reconnect DB on failure, and remember: no OPcache revalidation surprise, but also no automatic code reload after deploys — restart workers on every deploy. ## 5. Application-level: N+1, caching, autoloading - **N+1 queries** dominate real PHP slowness. One query per loop iteration = finding. Fix with eager loading / joins (e.g. Eloquent `with()`, Doctrine `JOIN` fetch or `EXTRA_LAZY` deliberately). Detect: query logging in dev, a query-count assertion in tests for hot endpoints, APM span counts in prod. Index/schema depth → sota-databases. - **Cache layers, by scope:** OPcache (bytecode) → APCu (per-host, in-memory, no network — great for config/feature flags) → Redis/Memcached (shared, neutral examples). Every cache entry gets a TTL and a stampede story (lock-or-stale) on hot keys. - **Autoloader:** production runs `composer install --optimize-autoloader` (classmap generation); `--classmap-authoritative` when the deploy is truly immutable — skips filesystem checks for unknown classes (getcomposer.org autoloader optimization docs). - `realpath_cache_size` (default 4M since 7.x) matters for large vendor trees on stat-heavy configs; with `validate_timestamps=0` it's mostly moot. - Streams and generators (`yield`) for large datasets — `fetchAll()` on unbounded result sets and building giant arrays in memory are HIGH on memory-constrained workers; `->fetch()` loops / cursors / chunked processing instead. - `usleep`/HTTP calls inside request loops, un-batched external API calls: same N+1 pathology, worse latency multiplier. ## 6. Profiling: measure, don't guess - **Xdebug is a development tool**: step debugging and coverage. Its overhead (even in `develop`/profile modes) disqualifies it for production — never enabled there (also an information-exposure risk). - **Production:** sampling profilers/APM designed for prod — e.g. Excimer (low-overhead sampling), Blackfire, Tideways, or OpenTelemetry auto-instrumentation as neutral examples. Continuous sampling beats one-off local benchmarks because it sees real data shapes. - Workflow: profile → find the top exclusive-time frames → fix the biggest → re-profile. Optimizations without before/after numbers don't merge. - Micro-benchmarks: PHP's timers (`hrtime(true)`) with warmup and OPcache on, or phpbench (neutral example); beware measuring the JIT/opcache cold path. ## Audit checklist Run from repo root / against the runtime; verify each hit manually. ```bash # OPcache posture — the single highest-leverage check php -r 'var_export(function_exists("opcache_get_status") ? (opcache_get_status(false)["opcache_statistics"] ?? opcache_get_status(false)) : "OPCACHE MISSING");' php -r 'foreach (["enable","memory_consumption","max_accelerated_files","validate_timestamps","preload","jit","jit_buffer_size"] as $k) echo "opcache.$k=", ini_get("opcache.$k"), PHP_EOL;' # validate_timestamps=1 on immutable deploys = LOW perf debt; cache-full/oom_restarts>0 = MEDIUM # FPM sizing grep -rnE '^(pm|pm\.max_children|pm\.max_requests|request_slowlog_timeout)' /etc/php*/fpm/pool.d/ 2>/dev/null # max_children default-ish (5) on a production box, or no slowlog = MEDIUM # N+1 / query-in-loop heuristics — confirm by reading the loop grep -rnE '(foreach|while)[^{]*\{[^}]*->(query|prepare|find|get)\(' --include='*.php' src/ | head grep -rn 'fetchAll' --include='*.php' src/ # unbounded result sets? grep -rnE '(curl_exec|file_get_contents\s*\(\s*.http)' --include='*.php' src/ # HTTP in loops? # Autoloader optimization in the deploy path grep -rn 'optimize-autoloader\|classmap-authoritative\|-o ' Dockerfile* deploy* .github/workflows/ 2>/dev/null # Xdebug in production (HIGH if confirmed on prod hosts) php -m | grep -i xdebug # Session lock hygiene on slow endpoints grep -rn 'session_write_close' --include='*.php' src/ # Performance claims without measurements — check PR/commit rationale git log --oneline --grep='perf\|optimi' -10 ``` Severity guide: OPcache off in production HIGH (perf); Xdebug on production HIGH; FPM sized by default/folklore causing OOM or queueing MEDIUM–HIGH; confirmed N+1 on hot path MEDIUM; missing autoloader optimization LOW; unmeasured optimization PRs INFO (process).
-
-
SKILL.md 8.4 KB
--- name: sota-php description: >- State-of-the-art PHP engineering (2026 baseline, PHP 8.3+ floor) for both writing new PHP and auditing existing PHP code. Covers strict_types and modern idioms (enums, readonly, match, fibers, property hooks), OWASP-grade security (SQL injection, XSS, file uploads, LFI/RFI, unserialize/Phar object injection, sessions, password hashing, sodium, SSRF), framework-neutral web hardening, Composer supply chain and static analysis (PHPStan/Psalm levels, baselines), and runtime performance (OPcache, preloading, FPM tuning, JIT, N+1). Use whenever the task involves PHP source, composer.json, php.ini, FPM config, or a PHP framework — building features, scaffolding projects, reviewing PRs, or hunting bugs and vulnerabilities. Trigger keywords: PHP, composer, Laravel, Symfony, WordPress, PHPStan, Psalm, PHPUnit, Pest, PDO, php-fpm, OPcache, strict_types, phar, unserialize, htmlspecialchars. --- # SOTA PHP (2026) ## Purpose This skill encodes the 2026 state of the art for PHP: a supported-version baseline (PHP 8.3+ floor; 8.5 is the latest annual line — verify current, see `rules/01`), `strict_types` everywhere, typed object-oriented design, security-by-default at every trust boundary, a locked and audited Composer supply chain, and measured runtime performance. It serves two modes: - **BUILD** — writing new code or modifying existing code to this standard. - **AUDIT** — reviewing existing code against this standard and reporting findings. The detailed rules live in `rules/*.md`. Read SKILL.md fully; load rules files on demand per the index table below. ## BUILD mode When creating or modifying PHP code: 1. **Establish context first.** Check `composer.json` (`require.php`, `config.platform`), `composer.lock`, the framework in use, PHPStan/Psalm config, and CS ruleset. Match the project's PHP floor — no enums on a project that still supports 8.0. For a *new* project, scaffold per `rules/05`: PHP ≥ 8.3 floor, committed lockfile, PHPStan at max level (baseline only for legacy), PER-CS formatting, CI gates from day one. 2. **Default style:** `declare(strict_types=1)` in every file, full parameter/return/ property types, constructor promotion, `readonly` where state shouldn't mutate, enums over class constants, `match` over `switch`, exceptions over error codes, no `@` suppression. (`rules/01`) 3. **Security posture is non-optional** even when unrequested: PDO prepared statements, context-correct output escaping, upload validation by content, no `unserialize()` on external data, `password_hash`/`sodium`/`random_bytes` for anything secret. (`rules/02`–`rules/04`) 4. **Framework first.** When a framework is present (e.g. Laravel, Symfony), use its escaping, CSRF, auth, and validation mechanisms instead of hand-rolling — but verify raw-escape hatches (`DB::raw`, `|raw`, `html()`) aren't fed user input. 5. **Tests accompany code** (PHPUnit or Pest as the project dictates); static analysis and CS must pass before code is presented. (`rules/05`) 6. **Performance:** OPcache assumptions belong in deploy config, not code; anything beyond correct-by-default (eager loading, streaming, generators) requires a profile first. (`rules/06`) ## AUDIT mode When reviewing existing PHP code: 1. **Sweep mechanically first.** Run the "Audit checklist" blocks at the end of every relevant rules file — ordered grep/composer/phpstan commands. Start with `composer audit --locked` and a grep sweep for `unserialize(`, `eval(`, `shell_exec`, string-interpolated SQL, and `echo $_`. 2. **Then read for design:** trust-boundary placement, escaping strategy (output-time or scattered?), session lifecycle, N+1 patterns, lockfile discipline. 3. **Verify every finding** — open the file, trace the data flow. An `unserialize()` of a value the same app signed with HMAC is not CRITICAL. Note mitigations already present. 4. **Don't report style noise** a fixer would auto-fix; mention once collectively. ### Severity conventions | Severity | Meaning | Examples | |---|---|---| | CRITICAL | Exploitable now, or data loss | SQL built by interpolation from request data, `unserialize($_GET…)`, `include` of user path, `eval` on input, uploads executed as PHP | | HIGH | Exploitable with preconditions, or prod-breaking | XSS via unescaped output, `md5()` passwords, missing `use_strict_mode`/fixation, SSRF fetch of user URL, `CURLOPT_SSL_VERIFYPEER => false`, world-readable secrets | | MEDIUM | Correctness/maintenance risk | no lockfile committed, no `composer audit` in CI, loose `==` on security decisions, `rand()` for tokens in non-auth context, N+1 on hot path, no static analysis | | LOW | Deviation from SOTA, friction | missing `strict_types`, untyped properties, `switch` where `match` fits, dev deps in prod image | | INFO | Worth knowing | newer-PHP features available after floor bump, tooling consolidation | ### Finding format ``` file:line | rule violated (rules/NN §S) | severity | effort | fix ``` Effort: trivial · small · medium · large. Group by severity, CRITICAL first. Borderline severities state the deciding assumption; unconfirmed findings are marked "needs verification", never asserted. End with counts per severity, the sweep commands run, and explicit "checked and clean" areas. ## Rules index | File | Read this when... | |---|---| | `rules/01-language-baseline.md` | choosing/verifying PHP version floor (support/EOL table); writing any PHP: strict_types, typed properties, enums, readonly, match, fibers, 8.4/8.5 features, comparison pitfalls incl. **`strpos` returning `false` where `0` is a real match**, error handling, deprecations | | `rules/02-injection.md` | code touching SQL, shell, or HTML output: PDO prepared statements, command execution, XSS and context-aware escaping, template engines, eval-family bans | | `rules/03-files-deserialization-ssrf.md` | file uploads, include/require paths, stream wrappers (LFI/RFI/`phar://`), `unserialize` and Phar object injection, XXE, server-side URL fetching (SSRF) | | `rules/04-sessions-auth-web-hardening.md` | login/session/auth code: session cookie flags and fixation, password_hash/argon2id, sodium crypto, CSRF, security headers, production php.ini hardening | | `rules/05-composer-tooling.md` | dependencies and CI: composer.lock discipline, `composer audit`, platform reqs, PHPStan/Psalm levels and baseline ratcheting, PER-CS, PHPUnit/Pest, CI gates | | `rules/06-performance-runtime.md` | anything slow or deploy-shaped: OPcache and preloading, JIT reality check, PHP-FPM pool sizing, N+1/caching, autoloader optimization, profiling. **Test *strategy* lives in `sota-testing`; DB depth in `sota-databases`.** | ## Top-10 non-negotiables 1. **Run a supported PHP** (≥ 8.2 today, and 8.2 is security-only until 2026-12-31 — plan the 8.3+ move now); new code targets 8.3+. (`rules/01`) 2. **`declare(strict_types=1)` in every file; full types on every property, parameter, and return.** Untyped is legacy, not a style choice. (`rules/01`) 3. **SQL only via prepared statements with bound parameters** (PDO/mysqli, emulation off); identifiers via allowlist. String-built SQL is CRITICAL, no exceptions for "internal" values. (`rules/02`) 4. **Escape at output, for the right context** — `htmlspecialchars(…, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')` or the template engine's auto-escaping; raw-output escape hatches never receive user input. (`rules/02`) 5. **Never `unserialize()`, `eval()`, or `include`/`require` data you don't fully control.** External data is JSON. Filter user paths for `phar://` and friends. (`rules/03`) 6. **Uploads: validate by content, rename randomly, store non-executable** — never trust client filename or MIME; never let the webserver execute uploads. (`rules/03`) 7. **Passwords via `password_hash()` (bcrypt default, or argon2id) + `password_verify`; secrets via `random_bytes`/`sodium`; compare with `hash_equals`.** Never md5/sha1/ `rand()`/`uniqid()` for anything secret. (`rules/04`) 8. **Sessions hardened:** `use_strict_mode=1`, cookies `Secure` + `HttpOnly` + `SameSite`, `session_regenerate_id(true)` on privilege change. (`rules/04`) 9. **`composer.lock` committed; CI runs `composer install` (never `update`) and `composer audit --locked`; prod installs `--no-dev`.** (`rules/05`) 10. **PHPStan (or Psalm) gates CI at the highest level the project can hold; the baseline only shrinks. OPcache on in prod; performance claims require a profile.** (`rules/05`, `rules/06`)
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.