From 435f73cb003cc93b5a103c9785be6f1e31e2066e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Sat, 3 Oct 2026 16:00:45 +0200 Subject: [PATCH] fix(env): refresh compiled templates without persisted secret verifiers --- .github/workflows/dependency-canary.yml | 2 +- .github/workflows/qa.yml | 4 +- CHANGELOG.md | 7 + composer.json | 4 +- docs/adr/0023-env-production.md | 27 ++-- docs/api.md | 8 ++ docs/deployment.md | 41 ++++++ src/Console/Doctor.php | 7 +- src/Console/DumpEnvironment.php | 4 + src/Console/ProductionChecks.php | 2 + src/Env/EnvCacheExpressions.php | 17 +++ src/Env/EnvCacheFormat.php | 14 +- src/Env/EnvCacheInterpolation.php | 22 ++- src/Env/EnvCacheLiteral.php | 93 +++++++++++++ src/Env/EnvCacheTemplates.php | 126 ++++++++++++++++++ src/Env/EnvReader.php | 38 +++++- src/Env/TrustedProxy.php | 14 ++ src/EnvironmentInspection.php | 118 ++++++++++++++++ src/Generation/RuntimeBundleBuilder.php | 2 +- tests/Contract/ArchitectureTest.php | 2 +- tests/Contract/TrustedProxyTest.php | 5 + .../Integration/EnvironmentInspectionTest.php | 99 ++++++++++++++ tests/Integration/OperationsCommandTest.php | 11 ++ tests/Integration/ProductionDoctorTest.php | 14 +- .../Integration/ProductionEnvironmentTest.php | 29 +++- 25 files changed, 664 insertions(+), 46 deletions(-) create mode 100644 src/Env/EnvCacheLiteral.php create mode 100644 src/Env/EnvCacheTemplates.php create mode 100644 src/EnvironmentInspection.php create mode 100644 tests/Integration/EnvironmentInspectionTest.php diff --git a/.github/workflows/dependency-canary.yml b/.github/workflows/dependency-canary.yml index 51eba2b..324ea7e 100644 --- a/.github/workflows/dependency-canary.yml +++ b/.github/workflows/dependency-canary.yml @@ -13,6 +13,6 @@ jobs: permissions: contents: read issues: write - uses: sympress/workflows/.github/workflows/dependency-canary.yml@177fa0d727b278d2103052ec77c102b4a4c492a0 + uses: sympress/workflows/.github/workflows/dependency-canary.yml@78d083a9a0176cf0252318b0c69fc64938a727d6 with: php_version: '8.5' diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index d4b1457..4773b60 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -20,8 +20,8 @@ jobs: - name: Check documentation and public references run: python3 tools/check-docs.py workflows: - uses: sympress/workflows/.github/workflows/lint-workflows.yml@177fa0d727b278d2103052ec77c102b4a4c492a0 + uses: sympress/workflows/.github/workflows/lint-workflows.yml@78d083a9a0176cf0252318b0c69fc64938a727d6 qa: - uses: sympress/workflows/.github/workflows/sympress-qa.yml@177fa0d727b278d2103052ec77c102b4a4c492a0 + uses: sympress/workflows/.github/workflows/sympress-qa.yml@78d083a9a0176cf0252318b0c69fc64938a727d6 with: php_version: '8.5' diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cf9bae..d55ab0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +- Remove persisted process-value fingerprints; evaluate compiled interpolation templates on every warm request without reparsing Dotenv, including rotated, absent and newly supplied values. +- Add side-effect-free `EnvironmentInspection` for requested raw values, sharing Doctor's configured cache/dump/file precedence and rejecting executable PHP or shell substitutions. +- Require a production `APP_SECRET` of at least 32 bytes in Doctor, refresh diagnostic interpolation and report dynamic dump selectors directly. +- Normalize IPv4-mapped IPv6 proxy peers and networks, refresh mutable OPcache environment data and constrain the tested Composer dependency to the 2.10 minor. + ## 1.2.0 — 2026-10-03 - Enable WordPress hardening, disallow unfiltered HTML/uploads and block outbound WordPress HTTP by default in Composer-managed production. Only `api.wordpress.org,downloads.wordpress.org` are admitted until an operator supplies a complete project-specific allowlist. diff --git a/composer.json b/composer.json index fb41b62..96394bb 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,7 @@ "require": { "php": "^8.5", "composer-plugin-api": "^2.6", - "composer/composer": "^2.10.3", + "composer/composer": "~2.10.3", "composer/semver": "^3.4", "opis/json-schema": "^2.6", "symfony/console": "^8.1", @@ -77,7 +77,7 @@ "extra": { "class": "SymPress\\Runtime\\Composer\\Plugin", "branch-alias": { - "dev-main": "1.1.x-dev" + "dev-main": "1.2.x-dev" }, "kernel": { "bundle": "SymPress\\Runtime\\Bridge\\Kernel\\RuntimeBundle", diff --git a/docs/adr/0023-env-production.md b/docs/adr/0023-env-production.md index a15708e..2bcaa50 100644 --- a/docs/adr/0023-env-production.md +++ b/docs/adr/0023-env-production.md @@ -7,17 +7,21 @@ Status: implemented, 2026-09-30. Native runtime caching defaults to auto, checks parent writability before shutdown registration, and invalidates on size/mtime changes to all attempted chain paths. Missing paths are recorded so newly deployed overrides invalidate existing caches. -Interpolated process values use parser-produced substitution templates and salted -dependency fingerprints. Runtime cache hits validate those dependencies and -expand templates without Dotenv parsing or persisted process secret bytes. -Missing and empty dependencies remain distinct; changes refresh the original -affected expressions and their file-owned dependencies. Unrelated values stay cached. -Dotenv retains control of quoting, escaping, defaults and file-chain resolution. +Interpolated values use templates compiled through Dotenv when the artifact is +built. Persisted artifacts contain variable names, literal defaults and random +placeholder markers, never fingerprints or other verifiers derived from process +values. Every request evaluates the templates against its current environment, +without reparsing Dotenv. Missing and empty values, defaults, assignment side +effects, quoting and source-chain precedence remain covered by differential tests. +Unrelated values stay cached. Older artifacts refresh through the original parser; +rebuild caches and dumps to use the compiled warm path and remove old fingerprints. Deployment dumps preserve safe values and non-secret expressions too. Command results and transitive results remain outside the value map and run live on hits. Dynamic environment selectors decline persistence because their source-chain -selection cannot be updated through a partial cache. Doctor never executes -persisted dynamic commands. This supersedes the previous whole-dump refusal for +selection cannot be updated through a partial cache; dump creation reports this +directly. Doctor and the public read-only environment inspection share the same +safe reader, refresh compiled interpolation and reject executable artifact data or +command substitutions. They never execute wp-config.php. This supersedes the previous whole-dump refusal for external interpolation and whole-cache refusal for command substitution. Deployment dumps remain explicit immutable artifacts. Legacy runtime cache semantics remain unchanged. @@ -64,3 +68,10 @@ must flush caches in that case. Group ownership is a deployment responsibility. `ProductionEnvironmentTest` covers an actual unprivileged read-only directory, permissions, new/changed/deleted overrides, immutable dumps, live file secrets, required value validation and lazy parsing through the bundled bootstrap. + +A PHP 8.5.9 container measurement with 30 variable templates and 1,000 requests +per case recorded median reader costs of 0.45 ms from source and 0.19 ms from a +warm cache, including alternating process-secret rotation. Warm p95 was 0.23 ms; +the rotated case was 0.21 ms. Both warm cases verified every result and left +Dotenv unloaded. These synthetic reader timings exclude WordPress boot and +describe the tested container, not a production latency guarantee. diff --git a/docs/api.md b/docs/api.md index 8d84013..87f9a8d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -53,6 +53,12 @@ Constructing a runner or invoking `run` is internal. `installed` and `reason` are public, with nullable booleans preserving unknown state. Obtain it through `dbChecker()->status()`. +`EnvironmentInspection::inspect()` reads only requested raw variable names through +Runtime's configured dump/cache/file precedence without setup or WordPress boot. +It preserves the caller's environment and rejects executable PHP/command substitutions. +See [read-only inspection](deployment.md#read-only-environment-inspection) for the +result structure and explicit file/Composer-manifest overrides. + ## Services and narrow handles | `Services` accessor | Handle and supported operations | @@ -196,6 +202,8 @@ SymPress\Runtime\Env\EnvReader::rawValue(string $name): ?string SymPress\Runtime\Env\EnvReader::read(string $name): string|int|float|bool|null SymPress\Runtime\Env\EnvReader::readMany(string ...$names): array SymPress\Runtime\Env\EnvReader::write(string $name, string $value): void +SymPress\Runtime\EnvironmentInspection [public type] +SymPress\Runtime\EnvironmentInspection::inspect(string $projectRoot, array $names, ?string $manifest = NULL, ?string $environmentFile = NULL): array [static] SymPress\Runtime\Event\LifecycleEvent [public type] SymPress\Runtime\Event\LifecycleEvent::$result: int [readonly] SymPress\Runtime\Event\LifecycleEvent::$services: SymPress\Runtime\Services [readonly] diff --git a/docs/deployment.md b/docs/deployment.md index a4d4765..1a9ad7d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -23,6 +23,47 @@ PHP identity before switching traffic. A successful package QA suite does not replace those site checks. Pin a reviewed tagged Runtime release and keep its exact resolved source reference in the lockfile. +Provide a randomly generated `APP_SECRET` of at least 32 bytes before production +validation. Doctor reports a missing or short value without printing it. Older +projects should provision a new strong secret once through their private environment +or secret store, then rebuild the dump and kernel cache. Do not regenerate an existing +good secret on each deployment: rotation deliberately invalidates signed cache data +and can affect Symfony features using the application secret. + +Set `SYMPRESS_PROJECT_DIR` to a persistent project identity, such as the deployment +base directory, before the kernel and object-cache drop-in boot. Its literal value +seeds WordPress cache namespaces/signing and Symfony cache pool prefixes; keep it +identical across release directories and different between projects. Actual package, +configuration and autoloader discovery continues from the active release. Changing +the identity or secret causes a normal cache refill. Provision a durable private +`APP_CACHE_DIR` accessible to the same CLI and PHP-FPM identity and warm it before +traffic; explicit unsafe cache permissions fail with an actionable diagnostic. + +After updating interpolation persistence, regenerate ordinary environment caches +and deployment dumps to remove fingerprints stored by older Runtime versions. +New artifacts contain no verifier derived from a process secret. Compiled variable +templates resolve against current process values on every request without loading +Dotenv; command substitutions still require the live parser during application boot. + +## Read-only environment inspection + +Applications and security checks can request selected raw values without running +setup, loading WordPress or changing the caller's process/ENV/SERVER state: + +```php +$snapshot = \SymPress\Runtime\EnvironmentInspection::inspect( + $activeReleaseDirectory, + ['APP_SECRET', 'DB_HOST'], +); +``` + +The result contains `environment`, the requested `values` (null when absent), and +`source` (`dump`, `cache` or `files`). It honors Runtime configuration and live +process precedence. Generated artifacts are decoded as literal data rather than +executed. Command substitutions are rejected. The optional `manifest` argument +means a Composer configuration manifest; `environmentFile` explicitly selects a +file chain and bypasses cached/dumped artifacts. Treat returned values as secrets. + ## Required artifacts `var/runtime//` is required application code. Generated `wp-config.php` references its `bootstrap.php` on every request. **Do not exclude all of `var/` or clear `var/runtime` as a cache cleanup.** Each immutable payload includes its manifest, scoped environment classes, Symfony Dotenv/Process source and license notices. diff --git a/src/Console/Doctor.php b/src/Console/Doctor.php index 603454e..a7e379e 100644 --- a/src/Console/Doctor.php +++ b/src/Console/Doctor.php @@ -9,9 +9,8 @@ use SymPress\Runtime\Config\Config; use SymPress\Runtime\Database\DbChecker; use SymPress\Runtime\Database\MysqliProbe; -use SymPress\Runtime\Env\EnvFactory; -use SymPress\Runtime\Env\EnvReader; use SymPress\Runtime\Env\EnvRequirements; +use SymPress\Runtime\EnvironmentInspection; use SymPress\Runtime\Filesystem\Paths; use SymPress\Runtime\Filesystem\ProjectBoundary; use SymPress\Runtime\Kernel\BootOwnership; @@ -64,9 +63,7 @@ public function inspect(bool $production = false, bool $databaseHealth = false, if (!is_string($profile)) { throw new RuntimeException('Invalid compatibility profile.'); } - $env = $dump - ? EnvReader::buildFromCacheDump($environmentDirectory . '/.env.dump.php', $profile, compatibility: $this->config['compatibility']->is(true), refreshInterpolation: false) - : (new EnvFactory($this->config, $this->paths))->create(); + [$env] = EnvironmentInspection::loadReader($this->config, $this->paths); $kernel = new KernelPaths($env, $this->paths); $canonical = $kernel->environment(); if ($production || in_array($canonical, ['staging', 'production'], true)) { diff --git a/src/Console/DumpEnvironment.php b/src/Console/DumpEnvironment.php index 49ef7c0..d70e2fc 100644 --- a/src/Console/DumpEnvironment.php +++ b/src/Console/DumpEnvironment.php @@ -31,6 +31,10 @@ public function run(string $environment): int return 1; } + if (!$reader->canPersistEnvironment()) { + $this->io->error('Cannot publish an environment dump with a dynamic environment selector; use a literal selector and rebuild.'); + return 1; + } $reader->setupConstants(); $directory = $this->config['env-dir']->unwrapOrFallback($this->paths->root()); if (!is_string($directory)) { diff --git a/src/Console/ProductionChecks.php b/src/Console/ProductionChecks.php index 1fef14a..c3e4e6c 100644 --- a/src/Console/ProductionChecks.php +++ b/src/Console/ProductionChecks.php @@ -41,6 +41,8 @@ public function inspect(EnvReader $env, bool $dump, ?string $phpUser = null, ?st $record('production.debug-display', $this->effectiveBoolean($env, 'WP_DEBUG_DISPLAY', false, $defaults && in_array($canonical, ['staging', 'production'], true), $predefined, $opaque), 'Effective WP_DEBUG_DISPLAY must be false.'); $record('production.debug', $this->effectiveBoolean($env, 'WP_DEBUG', false, $defaults && $canonical === 'production', $predefined, $opaque), 'Effective WP_DEBUG must be false for production.'); $record('production.dump', $dump, 'A readable deployment environment dump is required.'); + $secret = $env->rawValue('APP_SECRET'); + $record('production.app-secret', is_string($secret) && strlen($secret) >= 32, 'APP_SECRET requires at least 32 bytes generated with a cryptographically secure random source.'); $record('production.env-cache', !$this->config['cache-env']->is(false), 'Production must not explicitly disable environment caching.'); $record('production.proxy-trust', $this->hasProxyTrust($env), 'A forwarded scheme header or enabled forwarded SSL requires explicit trusted proxy addresses or CIDRs.'); $record('production.file-mods', $this->effectiveBoolean($env, 'DISALLOW_FILE_MODS', true, $managedDefaults, $predefined, $opaque), 'Effective DISALLOW_FILE_MODS must be true, explicitly or through the generated Composer-managed defaults.'); diff --git a/src/Env/EnvCacheExpressions.php b/src/Env/EnvCacheExpressions.php index 20db377..46069ba 100644 --- a/src/Env/EnvCacheExpressions.php +++ b/src/Env/EnvCacheExpressions.php @@ -4,6 +4,8 @@ namespace SymPress\Runtime\Env; +use Symfony\Component\Dotenv\Dotenv; + /** Keeps original expressions, never their process-derived results. @internal */ final class EnvCacheExpressions { @@ -52,6 +54,21 @@ public function referencesFor(string $path): array return array_values(array_unique(array_merge(...array_column($this->sources[$path] ?? [], 'references')))); } + /** @return array|null */ + public function templates(Dotenv $dotenv): ?array + { + $expressions = $this->payload()['expressions']; + $plans = []; + foreach ($expressions as $path => $content) { + $compiled = EnvCacheTemplates::compile($dotenv, $this->statements($content)); + if ($compiled === null) { + return null; + } + $plans[$path] = $compiled; + } + return $plans; + } + public function hasDynamicEnvironmentSelection(): bool { foreach ($this->sources as $definitions) { diff --git a/src/Env/EnvCacheFormat.php b/src/Env/EnvCacheFormat.php index db3ba0c..1306757 100644 --- a/src/Env/EnvCacheFormat.php +++ b/src/Env/EnvCacheFormat.php @@ -13,14 +13,24 @@ final class EnvCacheFormat private const string HEADER = " */ - public static function read(string $file): array + public static function read(string $file, bool $dataOnly = false): array { $header = file_get_contents($file, false, null, 0, strlen(self::HEADER)); if ($header !== self::HEADER) { throw new RuntimeException('Environment cache format is not supported.'); } try { - $data = (static fn (string $path): mixed => require $path)($file); + if ($dataOnly) { + $source = file_get_contents($file); + if (!is_string($source)) { + throw new RuntimeException('Cannot read environment cache.'); + } + $data = (new EnvCacheLiteral())->decode($source); + } + if (!$dataOnly && function_exists('opcache_invalidate')) { + opcache_invalidate($file, true); + } + $data = $dataOnly ? $data : (static fn (string $path): mixed => require $path)($file); } catch (Throwable) { throw new RuntimeException('Cannot read environment cache.'); } diff --git a/src/Env/EnvCacheInterpolation.php b/src/Env/EnvCacheInterpolation.php index 14b51a3..a96d835 100644 --- a/src/Env/EnvCacheInterpolation.php +++ b/src/Env/EnvCacheInterpolation.php @@ -11,7 +11,7 @@ /** @internal */ final class EnvCacheInterpolation { - /** @var array */ + /** @var array */ private array $dependencies = []; /** @var array */ private array $templates = []; @@ -19,6 +19,7 @@ final class EnvCacheInterpolation private array $resolved = []; private bool $uncacheable = false; private ?EnvCacheExpressions $expressions = null; + private ?Dotenv $dotenv = null; /** * @param callable(string): ?string $external @@ -26,6 +27,7 @@ final class EnvCacheInterpolation */ public function parse(Dotenv $dotenv, string $content, string $path, callable $external): array { + $this->dotenv = $dotenv; ($this->expressions ??= new EnvCacheExpressions())->record($content, $path); // The selected source chain cannot lose its history through later overrides. $this->uncacheable = $this->uncacheable || $this->expressions->hasDynamicEnvironmentSelection(); @@ -36,7 +38,7 @@ public function parse(Dotenv $dotenv, string $content, string $path, callable $e foreach ($this->expressions->referencesFor($path) as $name) { $value = $external($name); $marker = $this->dependencies[$name]['marker'] ?? 'SYMPRESS_INTERPOLATION_' . bin2hex(random_bytes(16)); - $this->dependencies[$name] = ['marker' => $marker, 'fingerprint' => self::fingerprint($marker, $value)]; + $this->dependencies[$name] = ['marker' => $marker]; if ($value !== null && $value !== '') { $externalReferences[$name] = true; } @@ -111,7 +113,7 @@ public function canPersist(bool $immutable): bool return !$this->uncacheable && array_intersect_key($dynamic, array_flip(['WP_ENVIRONMENT_TYPE', 'WP_ENV', 'WORDPRESS_ENV'])) === []; } - /** @return array{dependencies: array, templates: array, expressions: array, dynamic: array, transient: array, process: array, replay: array, refresh: bool} */ + /** @return array{dependencies: array, templates: array, expressions: array, dynamic: array, transient: array, process: array, replay: array, refresh: bool, plans: array|null} */ public function payload(): array { $templates = []; @@ -129,7 +131,8 @@ public function payload(): array $dependencies = array_intersect_key($this->dependencies, $needed + $expressions['needed']); unset($expressions['needed']); - return ['dependencies' => $dependencies, 'templates' => $templates, 'refresh' => array_diff_key($expressions['process'], $templates) !== []] + $expressions; + $plans = $this->dotenv === null ? [] : $this->expressions?->templates($this->dotenv); + return ['dependencies' => $dependencies, 'templates' => $templates, 'refresh' => $expressions['dynamic'] !== [], 'plans' => $plans] + $expressions; } /** @@ -145,13 +148,11 @@ public static function resolve(mixed $payload, callable $external): ?array throw new RuntimeException('Environment cache interpolation data is invalid.'); } $replacements = []; - $changed = false; foreach ($payload['dependencies'] as $name => $dependency) { - if (!is_string($name) || !preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/D', $name) || !is_array($dependency) || !is_string($dependency['marker'] ?? null) || !preg_match('/^SYMPRESS_INTERPOLATION_[a-f0-9]{32}$/D', $dependency['marker']) || !array_key_exists('fingerprint', $dependency) || ($dependency['fingerprint'] !== null && (!is_string($dependency['fingerprint']) || !preg_match('/^[a-f0-9]{64}$/D', $dependency['fingerprint'])))) { + if (!is_string($name) || !preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/D', $name) || !is_array($dependency) || !is_string($dependency['marker'] ?? null) || !preg_match('/^SYMPRESS_INTERPOLATION_[a-f0-9]{32}$/D', $dependency['marker'])) { throw new RuntimeException('Environment cache interpolation dependency is invalid.'); } $value = $external($name); - $changed = $changed || self::fingerprint($dependency['marker'], $value) !== $dependency['fingerprint']; $replacements[$dependency['marker']] = $value ?? ''; } $values = []; @@ -161,12 +162,7 @@ public static function resolve(mixed $payload, callable $external): ?array } $values[$name] = strtr($template, $replacements); } - return $changed || ($payload['transient'] ?? []) !== [] || ($payload['refresh'] ?? false) ? null : $values; - } - - private static function fingerprint(string $marker, ?string $value): ?string - { - return $value === null ? null : hash('sha256', $marker . "\0" . $value); + return ($payload['dynamic'] ?? []) !== [] || ($payload['transient'] ?? []) !== [] || ($payload['refresh'] ?? false) ? null : $values; } /** @param array $replacements */ diff --git a/src/Env/EnvCacheLiteral.php b/src/Env/EnvCacheLiteral.php new file mode 100644 index 0000000..bc8da4f --- /dev/null +++ b/src/Env/EnvCacheLiteral.php @@ -0,0 +1,93 @@ + */ + private array $tokens; + private int $cursor = 0; + + /** @return array */ + public function decode(string $source): array + { + $this->tokens = array_values(array_filter(PhpToken::tokenize($source), static fn (PhpToken $token): bool => !$token->is([T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT]))); + $this->cursor = 0; + $this->expect('return'); + $value = $this->value(); + $this->expect(';'); + if ($this->cursor !== count($this->tokens) || !is_array($value)) { + throw new RuntimeException('Environment cache must contain only generated literal data.'); + } + return $value; + } + + private function value(int $depth = 0): mixed + { + if ($depth > 64 || !isset($this->tokens[$this->cursor])) { + throw new RuntimeException('Environment cache literal data is invalid.'); + } + $token = $this->tokens[$this->cursor]; + ++$this->cursor; + if ($token->id === T_ARRAY) { + $this->expect('('); + $array = []; + while (($this->tokens[$this->cursor]->text ?? null) !== ')') { + $key = $this->value($depth + 1); + $this->expect('=>'); + if (!is_int($key) && !is_string($key)) { + throw new RuntimeException('Environment cache literal key is invalid.'); + } + $array[$key] = $this->value($depth + 1); + $this->expect(','); + } + $this->expect(')'); + return $array; + } + if ($token->id === T_CONSTANT_ENCAPSED_STRING) { + $value = $token->text[0] === "'" ? str_replace(["\\'", '\\\\'], ["'", '\\'], substr($token->text, 1, -1)) : stripcslashes(substr($token->text, 1, -1)); + if (($this->tokens[$this->cursor]->text ?? null) === '.') { + ++$this->cursor; + $next = $this->value($depth + 1); + if (!is_string($next)) { + throw new RuntimeException('Environment cache string literal is invalid.'); + } + $value .= $next; + } + return $value; + } + if ($token->id === T_LNUMBER) { + return (int) $token->text; + } + if ($token->id === T_DNUMBER) { + return (float) $token->text; + } + if ($token->text === '-') { + $number = $this->value($depth + 1); + if (is_int($number) || is_float($number)) { + return -$number; + } + } + return match (strtolower($token->text)) { + 'true' => true, + 'false' => false, + 'null' => null, + default => throw new RuntimeException('Environment cache must contain only generated literal data.'), + }; + } + + private function expect(string $text): void + { + $token = $this->tokens[$this->cursor] ?? null; + ++$this->cursor; + if ($token?->text !== $text) { + throw new RuntimeException('Environment cache literal syntax is invalid.'); + } + } +} diff --git a/src/Env/EnvCacheTemplates.php b/src/Env/EnvCacheTemplates.php new file mode 100644 index 0000000..2c6f136 --- /dev/null +++ b/src/Env/EnvCacheTemplates.php @@ -0,0 +1,126 @@ + $statements + * @return list}>|null + */ + // phpcs:ignore SymPress.Complexity.NestingLevel.High -- Single-pass lexical quote and escape handling avoids reparsing on warm requests. + public static function compile(Dotenv $dotenv, array $statements): ?array + { + $plans = []; + foreach ($statements as $statement) { + if (str_contains($statement, '$(')) { + return null; + } + $references = []; + $transformed = ''; + $quote = null; + for ($index = 0, $length = strlen($statement); $index < $length; ++$index) { + $char = $statement[$index]; + if ($char === '\\' && $quote !== "'") { + $transformed .= $char; + ++$index; + if ($index < $length) { + $transformed .= $statement[$index]; + } + continue; + } + if ($char === $quote) { + $quote = null; + } elseif ($quote === null && ($char === "'" || $char === '"')) { + $quote = $char; + } + if ($quote === null && $char === '#' && ($index === 0 || ctype_space($statement[$index - 1]))) { + $transformed .= substr($statement, $index); + break; + } + if ($char !== '$' || $quote === "'") { + $transformed .= $char; + continue; + } + $tail = substr($statement, $index); + if (str_starts_with($tail, '$(')) { + return null; + } + if (!preg_match('/^\$(\{)?([A-Za-z_][A-Za-z0-9_]*)(:[-=][^}]*+)?(\})?/', $tail, $match)) { + $transformed .= $char; + continue; + } + $marker = 'SYMPRESS_TEMPLATE_' . bin2hex(random_bytes(16)); + $default = $match[3] ?? ''; + $references[$marker] = ['name' => $match[2], 'default' => $default === '' ? null : self::lexDefault(substr($default, 2), $quote), 'assign' => str_starts_with($default, ':=')]; + $transformed .= $marker . ($match[1] === '' && ($match[4] ?? '') === '}' ? '}' : ''); + $index += strlen($match[0]) - 1; + } + foreach ($dotenv->parse($transformed) as $name => $template) { + if (!is_string($name) || !is_string($template)) { + throw new RuntimeException('Environment parser returned invalid template data.'); + } + $plans[] = ['name' => $name, 'template' => $template, 'references' => $references]; + } + } + return $plans; + } + + /** @return array */ + // phpcs:ignore SymPress.Complexity.NestingLevel.High -- Ordered assignments and default side effects share one parser-compatible evaluation boundary. + public static function resolve(mixed $plans): array + { + if (!is_array($plans)) { + throw new RuntimeException('Environment cache templates are invalid.'); + } + $loaded = EnvReader::loadedVars(); + $values = []; + foreach ($plans as $plan) { + if (!is_array($plan) || !is_string($plan['name'] ?? null) || !is_string($plan['template'] ?? null) || !is_array($plan['references'] ?? null)) { + throw new RuntimeException('Environment cache template is invalid.'); + } + $replacements = []; + foreach ($plan['references'] as $marker => $reference) { + if (!is_string($marker) || !is_array($reference) || !is_string($reference['name'] ?? null)) { + throw new RuntimeException('Environment cache template reference is invalid.'); + } + $name = $reference['name']; + $value = isset($loaded[$name], $values[$name]) ? $values[$name] + : ($_ENV[$name] ?? (!str_starts_with($name, 'HTTP_') ? ($_SERVER[$name] ?? null) : null) ?? $values[$name] ?? getenv($name)); + $value = is_scalar($value) ? (string) $value : ''; + if ($value === '' && is_string($reference['default'] ?? null)) { + $value = $reference['default']; + if (strpbrk($value, '\'"{$') !== false) { + throw new RuntimeException('Environment cache interpolation default is invalid.'); + } + if (($reference['assign'] ?? false) === true) { + $values[$name] = $value; + } + $value = str_replace(['\\\\', "\0"], ['\\', '$'], $value); + } + $replacements[$marker] = $value; + } + $values[$plan['name']] = strtr($plan['template'], $replacements); + } + return $values; + } + + private static function lexDefault(string $value, ?string $quote): string + { + if ($quote === '"') { + $value = str_replace(['\\"', '\\r', '\\n'], ['"', "\r", "\n"], $value); + } + return preg_replace_callback('/\\\\+\$/', static function (array $match): string { + $slashes = substr($match[0], 0, -1); + return strlen($slashes) % 2 === 1 ? substr($slashes, 0, -1) . "\0" : $match[0]; + }, $value) ?? $value; + } +} diff --git a/src/Env/EnvReader.php b/src/Env/EnvReader.php index f1b3ffe..0da1502 100644 --- a/src/Env/EnvReader.php +++ b/src/Env/EnvReader.php @@ -320,13 +320,20 @@ public function hasCachedValues(): bool } /** @internal */ - public static function buildFromCacheDump(string $file, string $profile = 'native', ?string $environment = null, bool $compatibility = true, bool $validateSources = false, int $fileMode = 0600, ?string $producer = null, bool $refreshInterpolation = true): self + public function canPersistEnvironment(): bool + { + return $this->interpolation === null || $this->interpolation->canPersist(true); + } + + /** @internal */ + // phpcs:ignore SymPress.Complexity.NestingLevel.High -- Cache restoration distinguishes compiled templates, command replay and the retained format-1 compatibility path. + public static function buildFromCacheDump(string $file, string $profile = 'native', ?string $environment = null, bool $compatibility = true, bool $validateSources = false, int $fileMode = 0600, ?string $producer = null, bool $refreshInterpolation = true, bool $defineConstants = true, bool $dataOnly = false): self { $reader = new self(profile: $profile, compatibility: $compatibility, fileMode: $fileMode); if (!is_file($file) || !is_readable($file)) { return $reader; } - $data = EnvCacheFormat::read($file); + $data = EnvCacheFormat::read($file, $dataOnly); if ($producer !== null && ($data['producer'] ?? null) !== $producer) { return $reader; } @@ -342,8 +349,27 @@ public static function buildFromCacheDump(string $file, string $profile = 'nativ } $inputEnvironment = $_ENV; $inputServer = $_SERVER; - $reader->restoreCache($data, $environment, []); + $reader->restoreCache($data, $environment, [], $defineConstants); + $plans = $payload['plans'] ?? null; + if (is_array($plans)) { + $reader->prepareInterpolationReplay($payload, $inputEnvironment, $inputServer); + foreach ($plans as $sourcePlans) { + foreach (EnvCacheTemplates::resolve($sourcePlans) as $name => $value) { + if ($reader->externalValue($name) !== null) { + $reader->raw[$name] = $value; + continue; + } + $reader->write($name, $value); + } + } + $reader->fromCache = $reader->cache !== []; + $reader->loaded = true; + return $reader; + } if (!$refreshInterpolation) { + if ($dataOnly) { + throw new RuntimeException('Environment inspection cannot safely refresh command-based or older interpolation data; rebuild the dump.'); + } return $reader; } $reader->prepareInterpolationReplay($payload, $inputEnvironment, $inputServer); @@ -358,7 +384,7 @@ public static function buildFromCacheDump(string $file, string $profile = 'nativ return $reader; } - $reader->restoreCache($data, $environment, $interpolated); + $reader->restoreCache($data, $environment, $interpolated, $defineConstants); return $reader; } @@ -409,7 +435,7 @@ public function canWriteCache(string $file): bool } /** @param array $interpolated */ - private function restoreCache(mixed $data, ?string $environment, array $interpolated): void + private function restoreCache(mixed $data, ?string $environment, array $interpolated, bool $defineConstants = true): void { if (!is_array($data) || ($data['format'] ?? null) !== 1 || ($data['profile'] ?? null) !== $this->profile || !is_string($data['environment'] ?? null)) { throw new RuntimeException('Environment cache format or profile is invalid.'); @@ -480,7 +506,7 @@ private function restoreCache(mixed $data, ?string $environment, array $interpol } $this->write($name, $value); } - foreach ($data['constants'] as $name) { + foreach ($defineConstants ? $data['constants'] : [] as $name) { if (in_array($name, ['WP_ENV', 'WP_ENVIRONMENT_TYPE'], true)) { $this->setupEnvConstants(); continue; diff --git a/src/Env/TrustedProxy.php b/src/Env/TrustedProxy.php index ec4bb2e..6ddd056 100644 --- a/src/Env/TrustedProxy.php +++ b/src/Env/TrustedProxy.php @@ -7,6 +7,7 @@ /** @internal */ final class TrustedProxy { + // phpcs:ignore SymPress.Complexity.NestingLevel.High -- IP-family normalization and bounded CIDR validation share a fail-closed comparison boundary. public static function forwardsHttps(mixed $peer, mixed $scheme, mixed $trusted): bool { if (!is_string($peer) || !is_string($scheme) || strtolower($scheme) !== 'https' || !is_string($trusted)) { @@ -16,9 +17,22 @@ public static function forwardsHttps(mixed $peer, mixed $scheme, mixed $trusted) if ($address === false) { return false; } + $mappedPrefix = str_repeat("\0", 10) . "\xff\xff"; + if (strlen($address) === 16 && str_starts_with($address, $mappedPrefix)) { + $address = substr($address, 12); + } foreach (explode(',', $trusted) as $entry) { $parts = explode('/', trim($entry)); $network = @inet_pton($parts[0]); + if (is_string($network) && strlen($network) === 16 && str_starts_with($network, $mappedPrefix)) { + if (isset($parts[1])) { + if (!ctype_digit($parts[1]) || (int) $parts[1] < 96 || (int) $parts[1] > 128) { + continue; + } + $parts[1] = (string) ((int) $parts[1] - 96); + } + $network = substr($network, 12); + } if ($network === false || strlen($network) !== strlen($address) || count($parts) > 2) { continue; } diff --git a/src/EnvironmentInspection.php b/src/EnvironmentInspection.php new file mode 100644 index 0000000..a027c80 --- /dev/null +++ b/src/EnvironmentInspection.php @@ -0,0 +1,118 @@ + $names + * @return array{environment: string, values: array, source: string} + */ + public static function inspect(string $projectRoot, array $names, ?string $manifest = null, ?string $environmentFile = null): array + { + $loader = new ConfigLoader(); + $metadata = $loader->readObject($manifest ?? $projectRoot . '/composer.json'); + $extra = $metadata['extra'] ?? []; + if (!is_array($extra)) { + throw new InvalidArgumentException('Composer extra must be an object.'); + } + $namedExtra = []; + foreach ($extra as $key => $value) { + if (!is_string($key)) { + throw new InvalidArgumentException('Composer extra must have named keys.'); + } + $namedExtra[$key] = $value; + } + $loaded = $loader->load($projectRoot, $namedExtra); + $loadedValues = $environmentFile === null ? $loaded->values : array_replace($loaded->values, ['env-file' => $environmentFile]); + $paths = new Paths($projectRoot); + $config = new Config($loadedValues, new Validator($paths, $loaded->profile), $loaded->profile); + return self::withConfiguration($config, $paths, $names, $environmentFile === null); + } + + /** + * @param list $names + * @return array{environment: string, values: array, source: string} + * @internal + */ + public static function withConfiguration(Config $config, Paths $paths, array $names, bool $useArtifacts = true): array + { + foreach ($names as $name) { + if (!is_string($name) || !preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/D', $name)) { + throw new InvalidArgumentException('Environment inspection requires variable names.'); + } + } + $environment = $_ENV; + $server = $_SERVER; + $process = getenv(); + $process = is_array($process) ? $process : []; + try { + [$reader, $source] = self::loadReader($config, $paths, $useArtifacts); + $values = []; + foreach ($names as $name) { + $values[$name] = $reader->rawValue($name); + } + return ['environment' => $reader->determineEnvType(), 'values' => $values, 'source' => $source]; + } finally { + $_ENV = $environment; + $_SERVER = $server; + $current = getenv(); + foreach (is_array($current) ? array_diff_key($current, $process) : [] as $name => $value) { + putenv($name); + } + foreach ($process as $name => $value) { + putenv($name . '=' . $value); + } + } + } + + /** @return array{EnvReader, string} @internal */ + public static function loadReader(Config $config, Paths $paths, bool $useArtifacts = true): array + { + $directory = $config['env-dir']->unwrapOrFallback($paths->root()); + $profile = $config['compatibility-profile']->unwrap(); + if (!is_string($directory) || !is_string($profile)) { + throw new RuntimeException('Invalid environment inspection configuration.'); + } + $source = 'files'; + $reader = null; + foreach ($useArtifacts ? ['dump' => EnvReader::BUILD_DUMP_FILE, 'cache' => EnvReader::CACHE_DUMP_FILE] : [] as $kind => $suffix) { + if ($kind === 'cache' && $config['cache-env']->is(false)) { + continue; + } + if (!is_file($directory . $suffix)) { + continue; + } + $candidate = EnvReader::buildFromCacheDump($directory . $suffix, $profile, compatibility: $config['compatibility']->is(true), validateSources: $kind === 'cache', refreshInterpolation: false, defineConstants: false, dataOnly: true); + if (!$candidate->hasCachedValues()) { + continue; + } + $reader = $candidate; + $source = $kind; + break; + } + if ($reader === null) { + if ((new EnvironmentFiles($config, $paths))->containsCommands()) { + throw new RuntimeException('Environment inspection refuses shell command substitutions.'); + } + $reader = (new EnvFactory($config, $paths))->create(); + } + return [$reader, $source]; + } +} diff --git a/src/Generation/RuntimeBundleBuilder.php b/src/Generation/RuntimeBundleBuilder.php index 32ea393..97e4cd8 100644 --- a/src/Generation/RuntimeBundleBuilder.php +++ b/src/Generation/RuntimeBundleBuilder.php @@ -83,7 +83,7 @@ private function sources(): array $process = dirname((string) (new ReflectionClass(Process::class))->getFileName()); $environment = dirname((string) (new ReflectionClass(EnvReader::class))->getFileName()); $sources = []; - foreach (['EnvReader', 'Filters', 'EnvironmentName', 'ConstantCatalog', 'LegacyDeprecation', 'SecureFileWriter', 'EnvRequirements', 'EnvCacheSources', 'EnvCacheFormat', 'EnvCacheValues', 'EnvCacheInterpolation', 'EnvCacheExpressions', 'SecretFile', 'TrustedProxy'] as $class) { + foreach (['EnvReader', 'Filters', 'EnvironmentName', 'ConstantCatalog', 'LegacyDeprecation', 'SecureFileWriter', 'EnvRequirements', 'EnvCacheSources', 'EnvCacheFormat', 'EnvCacheLiteral', 'EnvCacheValues', 'EnvCacheInterpolation', 'EnvCacheExpressions', 'EnvCacheTemplates', 'SecretFile', 'TrustedProxy'] as $class) { $sources['Env/' . $class . '.php'] = $this->read($environment . '/' . $class . '.php'); } $sources['Dotenv/Dotenv.php'] = $this->read($dotenv . '/Dotenv.php'); diff --git a/tests/Contract/ArchitectureTest.php b/tests/Contract/ArchitectureTest.php index 268b70f..dd395de 100644 --- a/tests/Contract/ArchitectureTest.php +++ b/tests/Contract/ArchitectureTest.php @@ -29,7 +29,7 @@ public function testInstalledGraphExcludesWpStarterAndUsesTheRequiredStack(): vo self::assertSame('^8.1', $constraint); } self::assertArrayHasKey('sympress/qa', $manifest['require-dev']); - self::assertStringContainsString('sympress/workflows/.github/workflows/sympress-qa.yml@177fa0d727b278d2103052ec77c102b4a4c492a0', (string) file_get_contents($root . '/.github/workflows/qa.yml')); + self::assertStringContainsString('sympress/workflows/.github/workflows/sympress-qa.yml@78d083a9a0176cf0252318b0c69fc64938a727d6', (string) file_get_contents($root . '/.github/workflows/qa.yml')); $workflow = (string) file_get_contents($root . '/.github/workflows/differential.yml'); self::assertStringContainsString('--fail-on-skipped', $workflow); self::assertStringContainsString('tools/parity-evidence.py', $workflow); diff --git a/tests/Contract/TrustedProxyTest.php b/tests/Contract/TrustedProxyTest.php index b8fa4c1..2bdc22d 100644 --- a/tests/Contract/TrustedProxyTest.php +++ b/tests/Contract/TrustedProxyTest.php @@ -19,6 +19,11 @@ public static function cases(): iterable yield 'ipv6 cidr' => ['2001:db8::a', 'https', '2001:db8::/32', true]; yield 'ipv6 outside' => ['2001:db9::a', 'https', '2001:db8::/32', false]; yield 'ipv6 exact' => ['::1', 'https', '127.0.0.1, ::1', true]; + yield 'mapped ipv4 peer' => ['::ffff:192.0.2.129', 'https', '192.0.2.128/25', true]; + yield 'mapped ipv4 network' => ['192.0.2.129', 'https', '::ffff:192.0.2.128/121', true]; + yield 'mapped ipv4 exact' => ['::ffff:127.0.0.1', 'https', '127.0.0.1', true]; + yield 'mapped ipv4 outside' => ['::ffff:192.0.2.127', 'https', '192.0.2.128/25', false]; + yield 'mapped network ambiguous width' => ['192.0.2.129', 'https', '::ffff:192.0.2.128/95', false]; yield 'untrusted direct' => ['203.0.113.9', 'https', '127.0.0.1', false]; yield 'no configured trust' => ['127.0.0.1', 'https', null, false]; yield 'malformed mask' => ['127.0.0.1', 'https', '127.0.0.1/-1,127.0.0.1/33', false]; diff --git a/tests/Integration/EnvironmentInspectionTest.php b/tests/Integration/EnvironmentInspectionTest.php new file mode 100644 index 0000000..75c5761 --- /dev/null +++ b/tests/Integration/EnvironmentInspectionTest.php @@ -0,0 +1,99 @@ +write('composer.json', json_encode(['extra' => ['sympress-runtime' => ['env-file' => 'site.env']]], JSON_THROW_ON_ERROR)); + $this->write('site.env', "WP_ENVIRONMENT_TYPE=production\nRTV_INSPECTION=base\n"); + $this->write('site.env.local', "RTV_INSPECTION=local\n"); + $this->write('site.env.production', "RTV_INSPECTION=production\n"); + $this->write('site.env.production.local', "RTV_INSPECTION=last\n"); + $this->write('wp-config.php', 'root, ['RTV_INSPECTION', 'RTV_ABSENT']); + self::assertSame(['environment' => 'production', 'values' => ['RTV_INSPECTION' => 'last', 'RTV_ABSENT' => null], 'source' => 'files'], $result); + self::assertSame($environment, $_ENV); + self::assertSame($server, $_SERVER); + self::assertSame($process, getenv()); + self::assertFalse(defined('WP_ENVIRONMENT_TYPE')); + self::assertFileDoesNotExist($this->root . '/.env.cached.php'); + self::assertDirectoryDoesNotExist($this->root . '/var'); + putenv('RTV_INSPECTION=process'); + try { + self::assertSame('process', EnvironmentInspection::inspect($this->root, ['RTV_INSPECTION'])['values']['RTV_INSPECTION']); + self::assertSame('process', getenv('RTV_INSPECTION')); + } finally { + putenv('RTV_INSPECTION'); + } + } + + public function testDumpAndCacheRefreshInterpolationWithoutParserOrFingerprintOracle(): void + { + $this->write('composer.json', '{}'); + $this->write('.env', 'RTV_INSPECTION=${RTV_SECRET:-fallback}' . "\nRTV_STATIC=stable\n"); + $autoload = dirname(__DIR__, 2) . '/vendor/autoload.php'; + $build = new Process([PHP_BINARY, '-r', 'require ' . var_export($autoload, true) . '; $r = new SymPress\\Runtime\\Env\\EnvReader(); $r->loadChain(); $r->dumpCached(".env.cached.php"); $r->dumpCached(".env.dump.php", immutable:true);'], $this->root, ['RTV_SECRET' => 'dictionary-word']); + $build->mustRun(); + foreach (['.env.cached.php', '.env.dump.php'] as $artifact) { + $bytes = file_get_contents($this->root . '/' . $artifact); + self::assertIsString($bytes); + self::assertStringNotContainsString('dictionary-word', $bytes); + $payload = EnvCacheFormat::read($this->root . '/' . $artifact, dataOnly: true); + foreach ($payload['interpolation']['dependencies'] as $dependency) { + self::assertSame(['marker'], array_keys($dependency)); + foreach (['dictionary-word', 'password', 'secret'] as $guess) { + self::assertStringNotContainsString(hash('sha256', $dependency['marker'] . "\0" . $guess), $bytes); + } + } + } + $inspect = 'require ' . var_export($autoload, true) . '; $r = SymPress\\Runtime\\EnvironmentInspection::inspect(getcwd(), ["RTV_INSPECTION"]); echo json_encode([$r, class_exists(Symfony\\Component\\Dotenv\\Dotenv::class,false)]);'; + $run = new Process([PHP_BINARY, '-r', $inspect], $this->root, ['RTV_SECRET' => 'rotated-secret']); + $run->mustRun(); + self::assertSame([['environment' => 'production', 'values' => ['RTV_INSPECTION' => 'rotated-secret'], 'source' => 'dump'], false], json_decode($run->getOutput(), true, flags: JSON_THROW_ON_ERROR)); + unlink($this->root . '/.env.dump.php'); + $run->mustRun(); + self::assertSame('cache', json_decode($run->getOutput(), true, flags: JSON_THROW_ON_ERROR)[0]['source']); + } + + public function testInspectionRejectsExecutableArtifactAndShellSourceBeforeSideEffects(): void + { + $this->write('composer.json', '{}'); + $this->write('.env.dump.php', " 1,);\n"); + try { + EnvironmentInspection::inspect($this->root, ['DB_PASSWORD']); + self::fail('Executable artifact accepted.'); + } catch (\RuntimeException) { + self::assertFileDoesNotExist($this->root . '/executed'); + } + unlink($this->root . '/.env.dump.php'); + $this->write('.env', 'DB_PASSWORD=$(touch ' . $this->root . '/executed)' . "\n"); + try { + EnvironmentInspection::inspect($this->root, ['DB_PASSWORD']); + self::fail('Shell source accepted.'); + } catch (\RuntimeException $exception) { + self::assertStringContainsString('refuses shell', $exception->getMessage()); + self::assertFileDoesNotExist($this->root . '/executed'); + } + } + + public function testLiteralCacheInspectionPreservesEscapedAndBinaryValues(): void + { + $payload = ['format' => 1, 'values' => ['quoted' => "'\\\"\$literal", 'binary' => "\0\t\n\r", 'empty' => '', 'negative' => -1, 'null' => null, 'boolean' => true]]; + $this->write('.env.dump.php', EnvCacheFormat::encode($payload)); + self::assertSame($payload, EnvCacheFormat::read($this->root . '/.env.dump.php', dataOnly: true)); + } +} diff --git a/tests/Integration/OperationsCommandTest.php b/tests/Integration/OperationsCommandTest.php index f36e82c..b3d1836 100644 --- a/tests/Integration/OperationsCommandTest.php +++ b/tests/Integration/OperationsCommandTest.php @@ -97,4 +97,15 @@ public function testGeneratedModesAndRequirementTypesAreValidated(): void $this->fixture(['required-env' => ['SMTP_PORT' => 'object']]); self::assertNotSame(0, $this->command('validate')->getExitCode()); } + + public function testDynamicEnvironmentSelectorHasAnActionableDumpError(): void + { + $this->fixture(); + $this->write('.env', 'WP_ENVIRONMENT_TYPE=${RTV_SELECTOR:-production}' . "\nRTV_VALUE=safe\n"); + $run = $this->command('dump-env', 'production'); + self::assertSame(1, $run->getExitCode()); + self::assertStringContainsString('dynamic environment selector', $run->getOutput() . $run->getErrorOutput()); + self::assertStringNotContainsString('permissions', $run->getOutput() . $run->getErrorOutput()); + self::assertFileDoesNotExist($this->root . '/.env.dump.php'); + } } diff --git a/tests/Integration/ProductionDoctorTest.php b/tests/Integration/ProductionDoctorTest.php index 68a9b49..4fac9e9 100644 --- a/tests/Integration/ProductionDoctorTest.php +++ b/tests/Integration/ProductionDoctorTest.php @@ -19,7 +19,7 @@ private function fixture(): void $this->write('wordpress/wp-load.php', 'write('public/.keep', ''); $this->write('wp-content/.keep', ''); - $this->write('.env', "WP_ENVIRONMENT_TYPE=production\nWP_HOME=https://example.test\nDB_NAME=fixture\nDB_USER=fixture\nDB_HOST=localhost\nDB_PASSWORD=private-credential\nWPDB_ENV_VALID=true\nWPDB_EXISTS=true\nWP_INSTALLED=true\n"); + $this->write('.env', "WP_ENVIRONMENT_TYPE=production\nWP_HOME=https://example.test\nDB_NAME=fixture\nDB_USER=fixture\nDB_HOST=localhost\nDB_PASSWORD=private-credential\nAPP_SECRET=" . str_repeat('test-secret-', 4) . "\nWPDB_ENV_VALID=true\nWPDB_EXISTS=true\nWP_INSTALLED=true\n"); chmod($this->root . '/.env', 0600); $template = file_get_contents(dirname(__DIR__, 2) . '/templates/wp-config.php'); self::assertIsString($template); @@ -42,6 +42,18 @@ private function dump(): void (new Process([PHP_BINARY, '-r', 'require ' . var_export($autoload, true) . '; $r = new SymPress\\Runtime\\Env\\EnvReader(); $r->loadChain(); $r->setupConstants(); $r->dumpCached(".env.dump.php", immutable: true);'], $this->root))->mustRun(); } + public function testShortApplicationSecretFailsProductionDoctorWithoutDisclosure(): void + { + $this->fixture(); + $this->write('.env.production', "APP_SECRET=short-private-secret\n"); + chmod($this->root . '/.env.production', 0600); + $this->dump(); + [$exit, $checks, $output] = $this->diagnose(); + self::assertSame(1, $exit); + self::assertSame('fail', $checks['production.app-secret']); + self::assertStringNotContainsString('short-private-secret', $output); + } + /** @return array{int|null, array, string} */ private function diagnose(string ...$arguments): array { diff --git a/tests/Integration/ProductionEnvironmentTest.php b/tests/Integration/ProductionEnvironmentTest.php index aa91745..a0b1631 100644 --- a/tests/Integration/ProductionEnvironmentTest.php +++ b/tests/Integration/ProductionEnvironmentTest.php @@ -48,9 +48,9 @@ public function testInterpolationRefreshPreservesSafeCacheForChangedMissingOrRem echo json_encode([$cached, class_exists(Symfony\Component\Dotenv\Dotenv::class, false), $r->read('WP_SITEURL'), $r->read('RTV_APPENDED'), $r->read('RTV_DEFAULT')]); PHP; self::assertSame([true, false, 'https://process.example/wp', 'https://process.example/wp/admin/tail', 'fallback'], $this->runPhp("putenv('WP_HOME=https://process.example');" . $load)); - self::assertSame([true, true, 'https://changed.example/wp', 'https://changed.example/wp/admin/tail', 'fallback'], $this->runPhp("putenv('WP_HOME=https://changed.example');" . $load)); - self::assertSame([true, true, 'https://process.example/wp', 'https://process.example/wp/admin/tail', 'present'], $this->runPhp("putenv('WP_HOME=https://process.example'); putenv('RTV_OPTIONAL=present');" . $load)); - self::assertSame([true, true, 'https://file.example/wp', 'https://file.example/wp/admin/tail', 'fallback'], $this->runPhp("putenv('WP_HOME'); unset(\$_ENV['WP_HOME'], \$_SERVER['WP_HOME']);" . $load)); + self::assertSame([true, false, 'https://changed.example/wp', 'https://changed.example/wp/admin/tail', 'fallback'], $this->runPhp("putenv('WP_HOME=https://changed.example');" . $load)); + self::assertSame([true, false, 'https://process.example/wp', 'https://process.example/wp/admin/tail', 'present'], $this->runPhp("putenv('WP_HOME=https://process.example'); putenv('RTV_OPTIONAL=present');" . $load)); + self::assertSame([true, false, 'https://file.example/wp', 'https://file.example/wp/admin/tail', 'fallback'], $this->runPhp("putenv('WP_HOME'); unset(\$_ENV['WP_HOME'], \$_SERVER['WP_HOME']);" . $load)); } public function testRealEnvironmentSecretsAreExcludedAndFileDefaultsSurviveTheirRemoval(): void @@ -107,6 +107,27 @@ public function testExplicitWritesReplacePreviouslyInterpolatedCacheTemplates(): PHP)); } + public function testCompiledDefaultsKeepDotenvEscapeAndQuoteSemantics(): void + { + $this->write('.env', <<<'ENV' +RTV_DEFAULT="${RTV_MISSING:-line\nback\\slash\$literal}" +RTV_UNQUOTED=${RTV_MISSING:-back\\slash} +ENV); + $cold = $this->runPhp(<<<'PHP' +putenv('RTV_MISSING'); +$r = new SymPress\Runtime\Env\EnvReader(); $r->loadChain(); +$r->dumpCached('cache.php'); +echo json_encode([$r->rawValue('RTV_DEFAULT'), $r->rawValue('RTV_UNQUOTED')]); +PHP); + self::assertSame(["line\nback\\slash\$literal", 'back\\slash'], $cold); + $warm = $this->runPhp(<<<'PHP' +putenv('RTV_MISSING'); +$r = SymPress\Runtime\Env\EnvReader::buildFromCacheDump('cache.php', validateSources: true); +echo json_encode([$r->rawValue('RTV_DEFAULT'), $r->rawValue('RTV_UNQUOTED'), class_exists(Symfony\Component\Dotenv\Dotenv::class, false)]); +PHP); + self::assertSame([...$cold, false], $warm); + } + public function testExplicitWriteRetainsSafeCacheAndDumpAfterUnusedProcessDependencyRotates(): void { $this->write('.env', "RTV_SAFE=original\n" . 'RTV_DERIVED=$RTV_PROCESS_SECRET' . "\n"); @@ -407,7 +428,7 @@ public function testBundledInterpolationCacheUsesLiveProcessDependenciesWithoutL echo json_encode([$cached, $r->read('WP_SITEURL'), class_exists($parser, false)]); PHP; self::assertSame([true, 'https://process.example/wp', false], $this->runPhp("putenv('WP_HOME=https://process.example');" . $load)); - self::assertSame([true, 'https://changed.example/wp', true], $this->runPhp("putenv('WP_HOME=https://changed.example');" . $load)); + self::assertSame([true, 'https://changed.example/wp', false], $this->runPhp("putenv('WP_HOME=https://changed.example');" . $load)); } public function testUnprivilegedReadOnlyDirectoryNeverReceivesOrSpillsSecrets(): void