diff --git a/AGENTS.md b/AGENTS.md index 6eecaea..7a9c98d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,22 @@ ## Code and tooling -- All code must remain compatible with PHP 7.4, and PHPStan and PHP-CS-Fixer - must be run against PHP 7.4. +- All code must remain compatible with PHP 7.4. Run PHPStan and PHP-CS-Fixer + only under a PHP 7.4.x runtime; never run either tool under any other PHP + major.minor version. +- Use fully qualified `#[\SensitiveParameter]` on concrete executable + parameters when their established role normally carries a secret, + credential-bearing aggregate, or confidential Guzzle-owned container, and + the active frame can throw or invoke throwing code. +- Repeat the attribute on every qualifying owned caller, callee, concrete trait + method, and closure parameter. Do not add it to interfaces, abstract-only + declarations, pure/no-realistic-throw helpers, assignment-only sites, + arbitrary generic payloads, or completed non-recoverable derivatives. +- For PHP 7.4 compatibility, put `#[\SensitiveParameter]` on its own line and + the parameter on the following line, expand the complete parameter list, and + never add a comma after the final parameter. Native trace redaction starts on + PHP 8.2 and does not redact logs, messages, properties, wire traffic, captured + variables, return values, or the separate backtrace `$this`/`object`. - Always pass an explicit character list to `trim()`, `ltrim()`, and `rtrim()`; never rely on the default characters. - Handle `preg_*` engine failures: when the result is used as data, test for diff --git a/CHANGELOG.md b/CHANGELOG.md index a62a29d..398238a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ * Require service client response transformers to return `ResultInterface` values * Require command collections to be iterable and reject single commands in `executeAll()` and `executeAllAsync()` * Require custom middleware, transformers, and `executeAll()` callbacks to accept exact argument types instead of relying on scalar coercion +* Redact credential-bearing command and HTTP arguments from PHP 8.2+ backtraces * Preserve requests from PSR-18 request and network exceptions and from Guzzle transfer exceptions when wrapping command failures * Allow command exception constructors to accept any previous `Throwable` * Add generic PHPDoc return types to asynchronous service client APIs diff --git a/UPGRADING.md b/UPGRADING.md index 8ac9c33..3a6a86a 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -117,6 +117,18 @@ userland callbacks continue to work at runtime when PHP accepts them. `ServiceClient` no longer supports native PHP `serialize()` or `unserialize()`. Persist command names and parameter arrays instead of runtime client objects. +#### Sensitive Parameters and Backtraces + +Guzzle Command 2.0 marks credential-bearing command, request, response, and +exception parameters as sensitive. On PHP 8.2 and later, exception traces and +backtraces replace those argument values with `SensitiveParameterValue` +objects. PHP 7.4 through 8.1 retain the original trace arguments. + +This does not redact exception messages, application logs, HTTP traffic, +properties, captured variables, return values, or the separate `$this` object +in an explicit backtrace. Custom middleware and transformers must mark their +own sensitive parameters. + 1.0 from 0.8 ------------ diff --git a/composer.json b/composer.json index 612a83a..022d893 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,8 @@ "guzzlehttp/promises": "^3.0@dev", "guzzlehttp/psr7": "^3.0@dev", "psr/http-client": "^1.0", - "symfony/polyfill-php80": "^1.25" + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php82": "^1.27" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", diff --git a/src/Exception/CommandException.php b/src/Exception/CommandException.php index 4dfd390..25b81e2 100644 --- a/src/Exception/CommandException.php +++ b/src/Exception/CommandException.php @@ -25,8 +25,12 @@ class CommandException extends \RuntimeException implements GuzzleException private ?ResponseInterface $response; - public static function fromPrevious(CommandInterface $command, \Exception $prev): self - { + public static function fromPrevious( + #[\SensitiveParameter] + CommandInterface $command, + #[\SensitiveParameter] + \Exception $prev + ): self { // If the exception is already a command exception, return it. if ($prev instanceof self && $command === $prev->getCommand()) { return $prev; diff --git a/src/ServiceClient.php b/src/ServiceClient.php index 15bf97e..f7eec12 100644 --- a/src/ServiceClient.php +++ b/src/ServiceClient.php @@ -67,21 +67,28 @@ public function getHandlerStack(): HandlerStack return $this->handlerStack; } - public function getCommand(string $name, array $params = []): CommandInterface - { + public function getCommand( + string $name, + #[\SensitiveParameter] + array $params = [] + ): CommandInterface { return new Command($name, $params, clone $this->handlerStack); } - public function execute(CommandInterface $command): ResultInterface - { + public function execute( + #[\SensitiveParameter] + CommandInterface $command + ): ResultInterface { return $this->executeAsync($command)->wait(); } /** * @return PromiseInterface */ - public function executeAsync(CommandInterface $command): PromiseInterface - { + public function executeAsync( + #[\SensitiveParameter] + CommandInterface $command + ): PromiseInterface { $stack = $command->getHandlerStack() ?: $this->handlerStack; /** @var callable(CommandInterface): PromiseInterface $handler */ @@ -103,8 +110,11 @@ public function executeAsync(CommandInterface $command): PromiseInterface * * @return array */ - public function executeAll(iterable $commands, array $options = []): array - { + public function executeAll( + #[\SensitiveParameter] + iterable $commands, + array $options = [] + ): array { $fulfilled = $options['fulfilled'] ?? null; $rejected = $options['rejected'] ?? null; @@ -145,8 +155,11 @@ public function executeAll(iterable $commands, array $options = []): array * * @return PromiseInterface */ - public function executeAllAsync(iterable $commands, array $options = []): PromiseInterface - { + public function executeAllAsync( + #[\SensitiveParameter] + iterable $commands, + array $options = [] + ): PromiseInterface { // A single command satisfies the iterable type but would be iterated // as a collection of its own parameters. if ($commands instanceof CommandInterface) { @@ -183,8 +196,11 @@ public function executeAllAsync(iterable $commands, array $options = []): Promis * * @see ServiceClientInterface::getCommand */ - public function __call(string $name, array $args) - { + public function __call( + string $name, + #[\SensitiveParameter] + array $args + ) { $args = isset($args[0]) ? $args[0] : []; if (str_ends_with($name, 'Async')) { $command = $this->getCommand(substr($name, 0, -5), $args); @@ -202,7 +218,10 @@ public function __call(string $name, array $args) */ private function createCommandHandler(): callable { - return function (CommandInterface $command): PromiseInterface { + return function ( + #[\SensitiveParameter] + CommandInterface $command + ): PromiseInterface { return Promise\Coroutine::of(function () use ($command): \Generator { // Prepare the HTTP options. $opts = $command['@http'] ?: []; @@ -226,8 +245,10 @@ private function createCommandHandler(): callable /** * Transforms a Command object into a Request object. */ - private function transformCommandToRequest(CommandInterface $command): RequestInterface - { + private function transformCommandToRequest( + #[\SensitiveParameter] + CommandInterface $command + ): RequestInterface { $transform = $this->commandToRequestTransformer; return $transform($command); @@ -238,8 +259,11 @@ private function transformCommandToRequest(CommandInterface $command): RequestIn * into a Result object. */ private function transformResponseToResult( + #[\SensitiveParameter] ResponseInterface $response, + #[\SensitiveParameter] RequestInterface $request, + #[\SensitiveParameter] CommandInterface $command ): ResultInterface { $transform = $this->responseToResultTransformer;