diff --git a/docs/deployment.md b/docs/deployment.md index 5e371ff..af5dfee 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -86,6 +86,8 @@ Build in the destination release layout where possible. Generated configuration Generate the environment dump and warm the application's production kernel cache before making the release read-only. Default private-file mode is 0600: provision the PHP runtime identity/ownership so it can read required configuration and secrets without making them public. Verify a real WordPress request and kernel boot under that identity with writes disabled. +Keep the environment directory itself unwritable for the PHP runtime identity once the dump exists. The generated configuration executes `.env.dump.php` and `.env.cached.php` from that directory, so write access there turns any web-process file write into executable bootstrap code; `doctor --production` reports this as `production.env-dir-readonly`. A dump also removes the per-request `stat()` of each environment source file that the mutable cache needs. + Publish complete releases atomically and retain the previous configuration, its referenced payloads and matching application packages for rollback. Payload cleanup is deliberately not automatic. Before manually removing an old fingerprint, inventory references from every active and retained release, account for workers/in-flight requests and rollback windows, then remove only explicitly unreferenced directories. Do not infer safety from file age or the newest fingerprint alone; never traverse symlinks during cleanup. Keeping an old payload is preferable to breaking an active or rollback release. For in-place deployments, publish the payload before changing the configuration reference and preserve the previous pair until rollback is no longer needed. Prefer release-directory switching when application/vendor changes must be coordinated. Package recovery persists a private write-ahead journal before mutations. The next setup invocation restores an interrupted transaction under the project locks before planning a fresh repair. diff --git a/docs/operations.md b/docs/operations.md index 369e872..dfc0a0f 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -41,6 +41,11 @@ vendor/bin/runtime doctor --production --webroot=public --json The production gate checks explicit HTTPS `WP_HOME`, debugging, the deployment dump, Composer file-update policy, private salts and readable private artifacts. +With a deployment dump it also requires an environment directory that the PHP +runtime identity cannot write: the generated configuration `require`s files from +that directory, so a web process with write access there could plant bootstrap +PHP. The automatic request-time cache (`cache-env: auto`) is a development +convenience; production deployments ship `.env.dump.php` and stay read-only. It never executes `wp-config.php` to inspect settings. Known literal definitions before the generated sections take precedence over environment values and defaults. Opaque PHP, definitions between sections, edited sections, early hooks diff --git a/src/Console/ProductionChecks.php b/src/Console/ProductionChecks.php index ba88569..9c95d41 100644 --- a/src/Console/ProductionChecks.php +++ b/src/Console/ProductionChecks.php @@ -79,6 +79,12 @@ public function inspect(EnvReader $env, bool $dump, ?string $phpUser = null, ?st $mode = @fileperms($file); $record('production.env-mode.' . $index, $mode !== false && in_array($mode & 0777, [0600, 0640], true), 'Environment files require private 0600 or group-readable 0640 permissions.'); } + if ($dump) { + // A deployment dump makes the environment directory a bootstrap input; + // a web process that can write there can place executable PHP there. + $writable = $this->writableByRuntime($directory, $phpUser); + $checks[] = ['id' => 'production.env-dir-readonly', 'status' => $writable === null ? 'unknown' : ($writable ? 'fail' : 'pass'), 'detail' => 'With a deployment dump the environment directory must not be writable by the PHP runtime identity; the request-time cache (cache-env auto) needs that access and is not a production mechanism. Run doctor as the PHP-FPM identity, not as root, for an authoritative check.']; + } if (is_string($configFile)) { $files[] = $configFile; $mode = @fileperms($configFile); @@ -315,6 +321,24 @@ private function literalDefinitions(string $content): array return $definitions; } + private function writableByRuntime(string $directory, ?string $phpUser): ?bool + { + $current = function_exists('posix_geteuid') ? posix_geteuid() : null; + if ($phpUser !== null) { + $identity = function_exists('posix_getpwnam') ? posix_getpwnam($phpUser) : false; + if ($identity === false || $current === null || $identity['uid'] !== $current) { + // Mode bits cannot prove another identity's write access under ACLs. + return null; + } + } + if ($current === 0) { + // Root can write everywhere; its answer says nothing about the PHP-FPM identity. + return null; + } + + return is_writable($directory); + } + private function currentIdentityReadable(string $file): bool { if (!is_file($file) || !is_readable($file)) { diff --git a/tests/Integration/ProductionDoctorTest.php b/tests/Integration/ProductionDoctorTest.php index 239c89f..a90bd66 100644 --- a/tests/Integration/ProductionDoctorTest.php +++ b/tests/Integration/ProductionDoctorTest.php @@ -62,6 +62,20 @@ public function testShortApplicationSecretFailsProductionDoctorWithoutDisclosure /** @return array{int|null, array, string} */ private function diagnose(string ...$arguments): array + { + // Production deployments keep the environment directory read-only for PHP: + // drop only the write bits so identity-specific mode fixtures stay intact. + $mode = fileperms($this->root) & 0777; + chmod($this->root, $mode & ~0222); + try { + return $this->diagnoseWritableEnvironmentDirectory(...$arguments); + } finally { + chmod($this->root, $mode); + } + } + + /** @return array{int|null, array, string} */ + private function diagnoseWritableEnvironmentDirectory(string ...$arguments): array { $process = new Process([PHP_BINARY, dirname(__DIR__, 2) . '/bin/runtime', '-n', 'doctor', '--production', '--json', '--webroot=public', ...$arguments], $this->root, ['COMPOSER' => false, 'COMPOSER_VENDOR_DIR' => false]); $process->run(); @@ -74,6 +88,21 @@ private function diagnose(string ...$arguments): array return [$process->getExitCode(), array_column($checks, 'status', 'id'), $process->getOutput() . $process->getErrorOutput()]; } + public function testWritableEnvironmentDirectoryWithDumpFailsProductionCheck(): void + { + $this->fixture(); + [$exit, $checks] = $this->diagnoseWritableEnvironmentDirectory(); + self::assertSame(1, $exit); + self::assertSame('fail', $checks['production.env-dir-readonly']); + + [$exit, $checks] = $this->diagnose(); + self::assertSame(0, $exit); + self::assertSame('pass', $checks['production.env-dir-readonly']); + + [, $checks] = $this->diagnose('--php-user=no-such-sympress-user'); + self::assertSame('unknown', $checks['production.env-dir-readonly']); + } + public function testGeneratedDefaultsPassWithoutExecutingConfigOrLeakingSecrets(): void { $this->fixture();