From 2e0b7c376c3c8b65082e1e2303915d446c55a765 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Mon, 1 Jun 2026 03:09:50 +0900 Subject: [PATCH 1/2] Add configured input taint sources --- README.md | 48 ++++ demo/InputTaintDemo.php | 67 ++++++ demo/README.md | 19 ++ demo/psalm.xml | 30 +++ src/Handler/InputTaintHandler.php | 213 ++++++++++++++++++ src/Plugin.php | 67 +++++- .../Fixture/Invalid/TaintedAssignedInput.php | 33 +++ .../Fixture/Invalid/TaintedPromotedInput.php | 21 ++ .../Invalid/TaintedPromotedInputObject.php | 21 ++ tests/Fixture/Valid/InjectedNotTainted.php | 21 ++ tests/Fixture/Valid/SanitizedReinput.php | 42 ++++ tests/Fixture/psalm.xml | 9 +- tests/PluginIntegrationTest.php | 103 ++++++++- 13 files changed, 689 insertions(+), 5 deletions(-) create mode 100644 demo/InputTaintDemo.php create mode 100644 demo/README.md create mode 100644 demo/psalm.xml create mode 100644 src/Handler/InputTaintHandler.php create mode 100644 tests/Fixture/Invalid/TaintedAssignedInput.php create mode 100644 tests/Fixture/Invalid/TaintedPromotedInput.php create mode 100644 tests/Fixture/Invalid/TaintedPromotedInputObject.php create mode 100644 tests/Fixture/Valid/InjectedNotTainted.php create mode 100644 tests/Fixture/Valid/SanitizedReinput.php diff --git a/README.md b/README.md index f4c911a..a91ff8b 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ [![Test](https://github.com/be-framework/psalm-plugin/actions/workflows/test.yml/badge.svg)](https://github.com/be-framework/psalm-plugin/actions/workflows/test.yml) Psalm plugin that detects [Be Framework](https://github.com/be-framework/Be.Framework) runtime errors at static-analysis time. +It also teaches Psalm taint analysis that constructor parameters annotated with +`Ray\InputQuery\Attribute\Input` are user-controlled input. ## What it detects @@ -48,6 +50,52 @@ public function validate(string $value): void Variable throws and ternary/match unions are resolved via Psalm's `NodeTypeProvider`. When the type cannot be resolved, the plugin stays silent (false-positive avoidance). +### `#[Input]` taint sources + +When Psalm is run with `--taint-analysis`, only constructor parameters annotated with +`Ray\InputQuery\Attribute\Input` on configured root input classes are treated as +user-controlled input. `#[Inject]` parameters and unrelated variables are not +tainted. Downstream input classes are not re-tainted after sanitization unless +they are explicitly configured as sources. + +Configure the first input classes in `psalm.xml`: + +```xml + + + + + + + +``` + +```php +final readonly class ProfileInput +{ + public function __construct( + #[Input] public string $name, + ) {} + + public function render(): void + { + echo $this->name; // reported by Psalm as TaintedHtml + } +} +``` + +Taint analysis is enabled separately from normal Psalm analysis: + +```bash +vendor/bin/psalm --taint-analysis +``` + +There is a runnable demo in [`demo/`](demo/): + +```bash +vendor/bin/psalm --config=demo/psalm.xml --taint-analysis --no-cache --no-progress +``` + ## Installation ```bash diff --git a/demo/InputTaintDemo.php b/demo/InputTaintDemo.php new file mode 100644 index 0000000..292431c --- /dev/null +++ b/demo/InputTaintDemo.php @@ -0,0 +1,67 @@ +name; // TaintedHtml: #[Input] is user-controlled. + } + + public function sanitizedInput(): SanitizedGreetingInput + { + return new SanitizedGreetingInput( + htmlspecialchars($this->name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + ); + } + + public function injectedOutput(): void + { + echo $this->trustedTemplate; // No taint: #[Inject] is not a user input source. + } + + public function renderedInjectedOutput(): void + { + echo $this->renderer->renderTrusted($this->trustedTemplate); + } +} + +final readonly class SanitizedGreetingInput +{ + public function __construct( + #[Input] + public string $name, + ) { + } + + public function output(): void + { + echo $this->name; // No taint: this downstream Input class is not a source. + } +} diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..b6fe838 --- /dev/null +++ b/demo/README.md @@ -0,0 +1,19 @@ +# `#[Input]` Taint Demo + +This demo shows that the plugin treats only configured root input classes as +Psalm taint sources. Downstream input classes are not re-tainted after +sanitization. + +Run it from the repository root: + +```bash +vendor/bin/psalm --config=demo/psalm.xml --taint-analysis --no-cache --no-progress +``` + +Expected result: + +- Psalm exits non-zero because the unsafe output is intentional. +- `GreetingInput::unsafeOutput()` reports `TaintedHtml` and `TaintedTextWithQuotes` for `echo $this->name`. +- `GreetingInput::sanitizedInput()` returns a downstream input object with an escaped value. +- `SanitizedGreetingInput::output()` is accepted because the downstream input class is not configured as a source. +- `#[Inject]` values are not tainted. diff --git a/demo/psalm.xml b/demo/psalm.xml new file mode 100644 index 0000000..00a0bc4 --- /dev/null +++ b/demo/psalm.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Handler/InputTaintHandler.php b/src/Handler/InputTaintHandler.php new file mode 100644 index 0000000..ef9f322 --- /dev/null +++ b/src/Handler/InputTaintHandler.php @@ -0,0 +1,213 @@ + */ + private static array $sourceClasses = []; + + /** @param list $sourceClasses */ + public static function configure(array $sourceClasses): void + { + self::$sourceClasses = []; + + foreach ($sourceClasses as $sourceClass) { + $sourceClass = ltrim($sourceClass, '\\'); + self::$sourceClasses[strtolower($sourceClass)] = true; + } + } + + /** + * Called to see what taints should be added + * + * @return list + */ + #[Override] + public static function addTaints(AddRemoveTaintsEvent $event): array + { + $expr = $event->getExpr(); + + if ($expr instanceof Node\Expr\Variable && self::isInputConstructorVariable($expr, $event)) { + return TaintKindGroup::ALL_INPUT; + } + + if ($expr instanceof Node\Expr\PropertyFetch && self::isInputPromotedPropertyFetch($expr, $event)) { + return TaintKindGroup::ALL_INPUT; + } + + return []; + } + + private static function isInputConstructorVariable(Node\Expr\Variable $expr, AddRemoveTaintsEvent $event): bool + { + if (! is_string($expr->name)) { + return false; + } + + $className = $event->getStatementsSource()->getFQCLN(); + if ($className === null) { + return false; + } + + if ($event->getContext()->calling_method_id !== strtolower($className . '::__construct')) { + return false; + } + + if (! self::isSourceClass($className)) { + return false; + } + + return self::constructorParameterHasInput($event->getCodebase(), $className, $expr->name); + } + + private static function isInputPromotedPropertyFetch( + Node\Expr\PropertyFetch $expr, + AddRemoveTaintsEvent $event, + ): bool { + if (! $expr->name instanceof Node\Identifier) { + return false; + } + + if (! ($expr->var instanceof Node\Expr\Variable && $expr->var->name === 'this')) { + $exprType = $event->getStatementsSource()->getNodeTypeProvider()->getType($expr); + if ($exprType !== null && $exprType->parent_nodes !== []) { + return false; + } + } + + $propertyName = $expr->name->toString(); + foreach (self::propertyFetchClassNames($expr, $event) as $className) { + if (self::promotedPropertyHasInput($event->getCodebase(), $className, $propertyName)) { + return true; + } + } + + return false; + } + + /** @return list */ + private static function propertyFetchClassNames(Node\Expr\PropertyFetch $expr, AddRemoveTaintsEvent $event): array + { + $classNames = []; + + if ($expr->var instanceof Node\Expr\Variable && $expr->var->name === 'this') { + $className = $event->getStatementsSource()->getFQCLN(); + if ($className !== null) { + $classNames[] = $className; + } + } + + $type = $event->getStatementsSource()->getNodeTypeProvider()->getType($expr->var); + if ($type === null) { + return array_values(array_unique($classNames)); + } + + foreach ($type->getAtomicTypes() as $atomic) { + if ($atomic instanceof TNamedObject) { + $classNames[] = $atomic->value; + } + } + + return array_values(array_unique($classNames)); + } + + private static function promotedPropertyHasInput(Codebase $codebase, string $className, string $propertyName): bool + { + $classStorage = self::declaringPropertyClassStorage($codebase, $className, $propertyName); + if ($classStorage === null) { + return false; + } + + if (! self::isSourceClass($classStorage->name)) { + return false; + } + + return ($classStorage->properties[$propertyName] ?? null)?->is_promoted === true + && self::constructorParameterHasInput($codebase, $classStorage->name, $propertyName); + } + + private static function isSourceClass(string $className): bool + { + return isset(self::$sourceClasses[strtolower(ltrim($className, '\\'))]); + } + + /** @psalm-suppress InternalMethod */ + private static function constructorParameterHasInput(Codebase $codebase, string $className, string $paramName): bool + { + $classStorage = $codebase->classlikes->getStorageFor($className); + if ($classStorage === null) { + return false; + } + + $ctor = $classStorage->methods['__construct'] ?? null; + if ($ctor === null) { + return false; + } + + foreach ($ctor->params as $param) { + if ($param->name === $paramName && self::hasInputAttribute($param->attributes)) { + return true; + } + } + + return false; + } + + /** @psalm-suppress InternalMethod */ + private static function declaringPropertyClassStorage( + Codebase $codebase, + string $className, + string $propertyName, + ): ClassLikeStorage|null { + $classStorage = $codebase->classlikes->getStorageFor($className); + if ($classStorage === null) { + return null; + } + + $declaringClass = $classStorage->declaring_property_ids[$propertyName] ?? $className; + + return $codebase->classlikes->getStorageFor($declaringClass); + } + + /** @param list $attributes */ + private static function hasInputAttribute(array $attributes): bool + { + foreach ($attributes as $attribute) { + if ($attribute->fq_class_name === self::INPUT_FQCN) { + return true; + } + } + + return false; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 92aaa9a..5da3071 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -5,16 +5,22 @@ namespace Be\PsalmPlugin; use Be\PsalmPlugin\Handler\BeingParameterAttributeHandler; +use Be\PsalmPlugin\Handler\InputTaintHandler; use Be\PsalmPlugin\Handler\ValidateThrowHandler; +use DOMElement; use Override; use Psalm\Plugin\PluginEntryPointInterface; use Psalm\Plugin\RegistrationInterface; use SimpleXMLElement; +use function dom_import_simplexml; +use function ltrim; + /** * Psalm plugin entry point for Be Framework * - * Registers static-analysis counterparts for three runtime errors: + * Registers static-analysis counterparts for three runtime errors and one + * taint-analysis source: * * 1. {@see \Be\PsalmPlugin\Issue\MissingBeingParameterAttribute} * Detects Being constructor parameters missing both #[Input] and #[Inject]. @@ -24,6 +30,9 @@ * * 3. {@see \Be\PsalmPlugin\Issue\InvalidValidateException} * Detects #[Validate] methods that throw exceptions not extending DomainException. + * + * 4. {@see \Be\PsalmPlugin\Handler\InputTaintHandler} + * Treats configured root input classes as user-controlled input sources. */ final class Plugin implements PluginEntryPointInterface { @@ -38,9 +47,65 @@ public function __invoke(RegistrationInterface $registration, SimpleXMLElement|n require_once __DIR__ . '/Issue/ConflictingBeingParameterAttribute.php'; require_once __DIR__ . '/Issue/InvalidValidateException.php'; require_once __DIR__ . '/Handler/BeingParameterAttributeHandler.php'; + require_once __DIR__ . '/Handler/InputTaintHandler.php'; require_once __DIR__ . '/Handler/ValidateThrowHandler.php'; + InputTaintHandler::configure(self::inputTaintSourceClasses($config)); + $registration->registerHooksFromClass(BeingParameterAttributeHandler::class); + $registration->registerHooksFromClass(InputTaintHandler::class); $registration->registerHooksFromClass(ValidateThrowHandler::class); } + + /** @return list */ + private static function inputTaintSourceClasses(SimpleXMLElement|null $config): array + { + if ($config === null) { + return []; + } + + $node = dom_import_simplexml($config); + if (! $node instanceof DOMElement) { + return []; + } + + $sourceClasses = self::sourceClassesFromConfigNode($node); + $sourceNodes = $node->getElementsByTagName('inputTaintSources'); + for ($i = 0; $i < $sourceNodes->length; $i++) { + $sourceNode = $sourceNodes->item($i); + if (! $sourceNode instanceof DOMElement) { + continue; + } + + foreach (self::sourceClassesFromConfigNode($sourceNode) as $sourceClass) { + $sourceClasses[] = $sourceClass; + } + } + + return $sourceClasses; + } + + /** @return list */ + private static function sourceClassesFromConfigNode(DOMElement $node): array + { + if ($node->localName !== 'inputTaintSources') { + return []; + } + + $sourceClasses = []; + $classNodes = $node->getElementsByTagName('class'); + for ($i = 0; $i < $classNodes->length; $i++) { + $class = $classNodes->item($i); + if (! $class instanceof DOMElement) { + continue; + } + + $sourceClass = ltrim($class->getAttribute('name'), '\\'); + if ($sourceClass !== '') { + $sourceClasses[] = $sourceClass; + } + } + + return $sourceClasses; + } } diff --git a/tests/Fixture/Invalid/TaintedAssignedInput.php b/tests/Fixture/Invalid/TaintedAssignedInput.php new file mode 100644 index 0000000..4ea3799 --- /dev/null +++ b/tests/Fixture/Invalid/TaintedAssignedInput.php @@ -0,0 +1,33 @@ +name = $name; + } + + public function render(): void + { + echo $this->name; + } +} diff --git a/tests/Fixture/Invalid/TaintedPromotedInput.php b/tests/Fixture/Invalid/TaintedPromotedInput.php new file mode 100644 index 0000000..3678f89 --- /dev/null +++ b/tests/Fixture/Invalid/TaintedPromotedInput.php @@ -0,0 +1,21 @@ +name; + } +} diff --git a/tests/Fixture/Invalid/TaintedPromotedInputObject.php b/tests/Fixture/Invalid/TaintedPromotedInputObject.php new file mode 100644 index 0000000..97091e0 --- /dev/null +++ b/tests/Fixture/Invalid/TaintedPromotedInputObject.php @@ -0,0 +1,21 @@ +name; +} diff --git a/tests/Fixture/Valid/InjectedNotTainted.php b/tests/Fixture/Valid/InjectedNotTainted.php new file mode 100644 index 0000000..b6099c6 --- /dev/null +++ b/tests/Fixture/Valid/InjectedNotTainted.php @@ -0,0 +1,21 @@ +trustedHtml; + } +} diff --git a/tests/Fixture/Valid/SanitizedReinput.php b/tests/Fixture/Valid/SanitizedReinput.php new file mode 100644 index 0000000..e05a71d --- /dev/null +++ b/tests/Fixture/Valid/SanitizedReinput.php @@ -0,0 +1,42 @@ +name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + ); + } +} + +final readonly class SanitizedProfileInput +{ + public function __construct( + #[Input] + public string $name, + ) { + } + + public function render(): void + { + echo $this->name; + } +} diff --git a/tests/Fixture/psalm.xml b/tests/Fixture/psalm.xml index 792cbc3..40d1df7 100644 --- a/tests/Fixture/psalm.xml +++ b/tests/Fixture/psalm.xml @@ -14,7 +14,14 @@ - + + + + + + + + diff --git a/tests/PluginIntegrationTest.php b/tests/PluginIntegrationTest.php index 4dc1ce5..0fbc59a 100644 --- a/tests/PluginIntegrationTest.php +++ b/tests/PluginIntegrationTest.php @@ -33,6 +33,9 @@ final class PluginIntegrationTest extends TestCase /** @var list>|null */ private static array|null $cachedIssues = null; + /** @var list>|null */ + private static array|null $cachedTaintIssues = null; + #[TestDox('MissingBeingParameterAttribute is reported when no #[Input]/#[Inject] is present')] public function testMissingInputAndInjectIsReported(): void { @@ -108,6 +111,51 @@ public function testValidFixturesProduceNoIssues(): void $this->assertSame([], $invalid, 'Valid fixtures should not produce plugin issues'); } + #[TestDox('TaintedHtml is reported when a promoted #[Input] property is echoed')] + public function testPromotedInputPropertyIsTainted(): void + { + $this->assertTaintIssue( + 'TaintedHtml', + 'TaintedPromotedInput.php', + ); + } + + #[TestDox('TaintedHtml is reported when a promoted #[Input] property is echoed from an object')] + public function testPromotedInputObjectPropertyIsTainted(): void + { + $this->assertTaintIssue( + 'TaintedHtml', + 'TaintedPromotedInputObject.php', + ); + } + + #[TestDox('TaintedHtml is reported when an assigned #[Input] property is echoed')] + public function testAssignedInputPropertyIsTainted(): void + { + $this->assertTaintIssue( + 'TaintedHtml', + 'TaintedAssignedInput.php', + ); + } + + #[TestDox('#[Inject] properties are not treated as taint sources')] + public function testInjectedPropertyIsNotTainted(): void + { + $this->assertNoTaintIssue( + 'TaintedHtml', + 'InjectedNotTainted.php', + ); + } + + #[TestDox('Sanitized values are not re-tainted by downstream #[Input] constructors')] + public function testSanitizedReinputIsNotTaintedAgain(): void + { + $this->assertNoTaintIssue( + 'TaintedHtml', + 'SanitizedReinput.php', + ); + } + private function assertIssue(string $type, string $fileSuffix): void { foreach (self::issues() as $issue) { @@ -124,6 +172,36 @@ private function assertIssue(string $type, string $fileSuffix): void $this->fail(sprintf('Expected %s on %s but did not find it in psalm output', $type, $fileSuffix)); } + private function assertTaintIssue(string $type, string $fileSuffix): void + { + foreach (self::taintIssues() as $issue) { + if ( + ($issue['type'] ?? null) === $type + && str_ends_with((string) ($issue['file_name'] ?? ''), $fileSuffix) + ) { + $this->addToAssertionCount(1); + + return; + } + } + + $this->fail(sprintf('Expected %s on %s but did not find it in psalm taint output', $type, $fileSuffix)); + } + + private function assertNoTaintIssue(string $type, string $fileSuffix): void + { + foreach (self::taintIssues() as $issue) { + if ( + ($issue['type'] ?? null) === $type + && str_ends_with((string) ($issue['file_name'] ?? ''), $fileSuffix) + ) { + $this->fail(sprintf('Did not expect %s on %s in psalm taint output', $type, $fileSuffix)); + } + } + + $this->addToAssertionCount(1); + } + /** @return list> */ private static function issues(): array { @@ -131,6 +209,26 @@ private static function issues(): array return self::$cachedIssues; } + self::$cachedIssues = self::runPsalm(); + + return self::$cachedIssues; + } + + /** @return list> */ + private static function taintIssues(): array + { + if (self::$cachedTaintIssues !== null) { + return self::$cachedTaintIssues; + } + + self::$cachedTaintIssues = self::runPsalm('--taint-analysis'); + + return self::$cachedTaintIssues; + } + + /** @return list> */ + private static function runPsalm(string $extraArgs = ''): array + { $root = dirname(__DIR__); $psalmBin = $root . '/vendor/bin/psalm'; $config = __DIR__ . '/Fixture/psalm.xml'; @@ -144,9 +242,10 @@ private static function issues(): array } $cmd = sprintf( - '%s --config=%s --output-format=json --no-cache --no-progress 2>/dev/null', + '%s --config=%s %s --output-format=json --no-cache --no-progress 2>/dev/null', escapeshellarg($psalmBin), escapeshellarg($config), + $extraArgs, ); $stdout = shell_exec($cmd); @@ -158,8 +257,6 @@ private static function issues(): array } /** @var list> $decoded */ - self::$cachedIssues = $decoded; - return $decoded; } From 142e8facb4776dc69a20dd89e0c75c30ed32e8a1 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Mon, 1 Jun 2026 10:01:13 +0900 Subject: [PATCH 2/2] Address input taint review feedback --- README.md | 2 +- src/Handler/InputTaintHandler.php | 14 +++++++- .../Invalid/TaintedInheritedPromotedInput.php | 25 ++++++++++++++ tests/Fixture/psalm.xml | 1 + tests/PluginIntegrationTest.php | 33 ++++++++++++++----- 5 files changed, 65 insertions(+), 10 deletions(-) create mode 100644 tests/Fixture/Invalid/TaintedInheritedPromotedInput.php diff --git a/README.md b/README.md index a91ff8b..f9ea25b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Psalm plugin that detects [Be Framework](https://github.com/be-framework/Be.Framework) runtime errors at static-analysis time. It also teaches Psalm taint analysis that constructor parameters annotated with -`Ray\InputQuery\Attribute\Input` are user-controlled input. +`Ray\InputQuery\Attribute\Input` are user-controlled input on configured root input classes. ## What it detects diff --git a/src/Handler/InputTaintHandler.php b/src/Handler/InputTaintHandler.php index ef9f322..af35e0a 100644 --- a/src/Handler/InputTaintHandler.php +++ b/src/Handler/InputTaintHandler.php @@ -18,8 +18,10 @@ use function array_unique; use function array_values; +use function explode; use function is_string; use function ltrim; +use function str_contains; use function strtolower; /** @@ -194,11 +196,21 @@ private static function declaringPropertyClassStorage( return null; } - $declaringClass = $classStorage->declaring_property_ids[$propertyName] ?? $className; + $declaringPropertyId = $classStorage->declaring_property_ids[$propertyName] ?? $className; + $declaringClass = self::declaringClassName($declaringPropertyId); return $codebase->classlikes->getStorageFor($declaringClass); } + private static function declaringClassName(string $declaringPropertyId): string + { + if (! str_contains($declaringPropertyId, '::$')) { + return ltrim($declaringPropertyId, '\\'); + } + + return ltrim(explode('::$', $declaringPropertyId, 2)[0], '\\'); + } + /** @param list $attributes */ private static function hasInputAttribute(array $attributes): bool { diff --git a/tests/Fixture/Invalid/TaintedInheritedPromotedInput.php b/tests/Fixture/Invalid/TaintedInheritedPromotedInput.php new file mode 100644 index 0000000..77e18d0 --- /dev/null +++ b/tests/Fixture/Invalid/TaintedInheritedPromotedInput.php @@ -0,0 +1,25 @@ +name; +} diff --git a/tests/Fixture/psalm.xml b/tests/Fixture/psalm.xml index 40d1df7..1b51fa1 100644 --- a/tests/Fixture/psalm.xml +++ b/tests/Fixture/psalm.xml @@ -17,6 +17,7 @@ + diff --git a/tests/PluginIntegrationTest.php b/tests/PluginIntegrationTest.php index 0fbc59a..ecd59ba 100644 --- a/tests/PluginIntegrationTest.php +++ b/tests/PluginIntegrationTest.php @@ -8,10 +8,12 @@ use PHPUnit\Framework\TestCase; use function array_filter; +use function array_map; use function array_values; use function dirname; use function escapeshellarg; use function file_exists; +use function implode; use function in_array; use function is_array; use function json_decode; @@ -138,6 +140,15 @@ public function testAssignedInputPropertyIsTainted(): void ); } + #[TestDox('TaintedHtml is reported when an inherited promoted #[Input] property is echoed')] + public function testInheritedPromotedInputPropertyIsTainted(): void + { + $this->assertTaintIssue( + 'TaintedHtml', + 'TaintedInheritedPromotedInput.php', + ); + } + #[TestDox('#[Inject] properties are not treated as taint sources')] public function testInjectedPropertyIsNotTainted(): void { @@ -221,13 +232,17 @@ private static function taintIssues(): array return self::$cachedTaintIssues; } - self::$cachedTaintIssues = self::runPsalm('--taint-analysis'); + self::$cachedTaintIssues = self::runPsalm(['--taint-analysis']); return self::$cachedTaintIssues; } - /** @return list> */ - private static function runPsalm(string $extraArgs = ''): array + /** + * @param list $extraArgs + * + * @return list> + */ + private static function runPsalm(array $extraArgs = []): array { $root = dirname(__DIR__); $psalmBin = $root . '/vendor/bin/psalm'; @@ -241,12 +256,14 @@ private static function runPsalm(string $extraArgs = ''): array self::markTestSkippedWithReason('fixture psalm.xml missing: ' . $config); } - $cmd = sprintf( - '%s --config=%s %s --output-format=json --no-cache --no-progress 2>/dev/null', + $cmd = implode(' ', [ escapeshellarg($psalmBin), - escapeshellarg($config), - $extraArgs, - ); + '--config=' . escapeshellarg($config), + ...array_map(static fn (string $arg): string => escapeshellarg($arg), $extraArgs), + '--output-format=json', + '--no-cache', + '--no-progress', + ]) . ' 2>/dev/null'; $stdout = shell_exec($cmd); $stdout = $stdout === null || $stdout === false ? '[]' : $stdout;