From 2ab56f285708962e7bdaa70838677e22f39cfc11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Sat, 24 Oct 2026 00:03:24 +0200 Subject: [PATCH 1/3] feat(doctor): require a read-only environment directory with a deployment dump The generated configuration executes .env.dump.php and .env.cached.php from the environment directory. With cache-env auto the web process writes the cache there itself, which needs write access on the bootstrap path. The strict production profile now reports production.env-dir-readonly once a dump exists; another identity remains unknown because mode bits cannot prove ACL behavior. --- docs/deployment.md | 2 ++ docs/operations.md | 5 ++++ src/Console/ProductionChecks.php | 19 +++++++++++++++ tests/Integration/ProductionDoctorTest.php | 27 ++++++++++++++++++++++ 4 files changed, 53 insertions(+) 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..4889e5f 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. Run doctor as the PHP-FPM identity for an authoritative check.']; + } if (is_string($configFile)) { $files[] = $configFile; $mode = @fileperms($configFile); @@ -315,6 +321,19 @@ private function literalDefinitions(string $content): array return $definitions; } + private function writableByRuntime(string $directory, ?string $phpUser): ?bool + { + if ($phpUser !== null) { + $identity = function_exists('posix_getpwnam') ? posix_getpwnam($phpUser) : false; + if ($identity === false || !function_exists('posix_geteuid') || $identity['uid'] !== posix_geteuid()) { + // Mode bits cannot prove another identity's write access under ACLs. + 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..1c016f5 100644 --- a/tests/Integration/ProductionDoctorTest.php +++ b/tests/Integration/ProductionDoctorTest.php @@ -62,6 +62,18 @@ 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. + chmod($this->root, 0555); + try { + return $this->diagnoseWritableEnvironmentDirectory(...$arguments); + } finally { + chmod($this->root, 0755); + } + } + + /** @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 +86,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(); From 1286ed0fc0e79c3a646345563617be4dd96da79a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Sat, 24 Oct 2026 00:12:21 +0200 Subject: [PATCH 2/3] fix(doctor): report env-dir-readonly as unknown when running as root Root can write every directory, so is_writable() says nothing about the PHP-FPM identity; mirror the readability checks' uid 0 handling. --- src/Console/ProductionChecks.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Console/ProductionChecks.php b/src/Console/ProductionChecks.php index 4889e5f..9c95d41 100644 --- a/src/Console/ProductionChecks.php +++ b/src/Console/ProductionChecks.php @@ -83,7 +83,7 @@ public function inspect(EnvReader $env, bool $dump, ?string $phpUser = null, ?st // 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. Run doctor as the PHP-FPM identity for an authoritative check.']; + $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; @@ -323,13 +323,18 @@ private function literalDefinitions(string $content): array 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 || !function_exists('posix_geteuid') || $identity['uid'] !== posix_geteuid()) { + 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); } From 8a5615c87e43a38832c91d3953a48cca6b05c8b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Sat, 24 Oct 2026 00:21:01 +0200 Subject: [PATCH 3/3] test: keep identity mode fixtures when making the project read-only --- tests/Integration/ProductionDoctorTest.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/Integration/ProductionDoctorTest.php b/tests/Integration/ProductionDoctorTest.php index 1c016f5..a90bd66 100644 --- a/tests/Integration/ProductionDoctorTest.php +++ b/tests/Integration/ProductionDoctorTest.php @@ -63,12 +63,14 @@ 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. - chmod($this->root, 0555); + // 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, 0755); + chmod($this->root, $mode); } }