diff --git a/README.md b/README.md
index f4c911a..f9ea25b 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,8 @@
[](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 on configured root input classes.
## 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..af35e0a
--- /dev/null
+++ b/src/Handler/InputTaintHandler.php
@@ -0,0 +1,225 @@
+ */
+ 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;
+ }
+
+ $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
+ {
+ 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/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/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..1b51fa1 100644
--- a/tests/Fixture/psalm.xml
+++ b/tests/Fixture/psalm.xml
@@ -14,7 +14,15 @@
-
+
+
+
+
+
+
+
+
+
diff --git a/tests/PluginIntegrationTest.php b/tests/PluginIntegrationTest.php
index 4dc1ce5..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;
@@ -33,6 +35,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 +113,60 @@ 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('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
+ {
+ $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 +183,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 +220,30 @@ 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;
+ }
+
+ /**
+ * @param list $extraArgs
+ *
+ * @return list>
+ */
+ private static function runPsalm(array $extraArgs = []): array
+ {
$root = dirname(__DIR__);
$psalmBin = $root . '/vendor/bin/psalm';
$config = __DIR__ . '/Fixture/psalm.xml';
@@ -143,11 +256,14 @@ private static function issues(): array
self::markTestSkippedWithReason('fixture psalm.xml missing: ' . $config);
}
- $cmd = sprintf(
- '%s --config=%s --output-format=json --no-cache --no-progress 2>/dev/null',
+ $cmd = implode(' ', [
escapeshellarg($psalmBin),
- escapeshellarg($config),
- );
+ '--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;
@@ -158,8 +274,6 @@ private static function issues(): array
}
/** @var list> $decoded */
- self::$cachedIssues = $decoded;
-
return $decoded;
}