Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions src/Console/ProductionChecks.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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)) {
Expand Down
29 changes: 29 additions & 0 deletions tests/Integration/ProductionDoctorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ public function testShortApplicationSecretFailsProductionDoctorWithoutDisclosure

/** @return array{int|null, array<string, string>, 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, string>, 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();
Expand All @@ -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();
Expand Down
Loading