mirror of
https://github.com/danog/psalm.git
synced 2024-11-26 12:24:49 +01:00
parent
ea1f9874fb
commit
b2c0993cdc
@ -309,6 +309,7 @@
|
||||
<xs:element name="RedundantConditionGivenDocblockType" type="IssueHandlerType" minOccurs="0" />
|
||||
<xs:element name="ReferenceConstraintViolation" type="IssueHandlerType" minOccurs="0" />
|
||||
<xs:element name="ReservedWord" type="IssueHandlerType" minOccurs="0" />
|
||||
<xs:element name="TaintedInput" type="IssueHandlerType" minOccurs="0" />
|
||||
<xs:element name="TraitMethodSignatureMismatch" type="IssueHandlerType" minOccurs="0" />
|
||||
<xs:element name="TooFewArguments" type="ArgumentIssueHandlerType" minOccurs="0" />
|
||||
<xs:element name="TooManyArguments" type="ArgumentIssueHandlerType" minOccurs="0" />
|
||||
|
@ -2044,6 +2044,10 @@ Emitted when using a reserved word as a class name
|
||||
function foo(resource $res) : void {}
|
||||
```
|
||||
|
||||
### TaintedInput
|
||||
|
||||
Emitted when tainted input detection is turned on
|
||||
|
||||
### TraitMethodSignatureMismatch
|
||||
|
||||
Emitted when a method's signature or return type differs from corresponding trait-defined method
|
||||
|
@ -404,4 +404,9 @@ class CodeLocation
|
||||
{
|
||||
return (string) $this->file_start;
|
||||
}
|
||||
|
||||
public function getShortSummary() : string
|
||||
{
|
||||
return $this->file_name . ':' . $this->getLineNumber() . ':' . $this->getColumn();
|
||||
}
|
||||
}
|
||||
|
@ -170,6 +170,11 @@ class Codebase
|
||||
*/
|
||||
public $populator;
|
||||
|
||||
/**
|
||||
* @var ?Internal\Codebase\Taint
|
||||
*/
|
||||
public $taint = null;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
@ -267,6 +272,8 @@ class Codebase
|
||||
*/
|
||||
public $php_minor_version = PHP_MINOR_VERSION;
|
||||
|
||||
|
||||
|
||||
public function __construct(
|
||||
Config $config,
|
||||
Providers $providers,
|
||||
|
@ -150,6 +150,7 @@ class DocComment
|
||||
'override-method-visibility', 'seal-properties', 'seal-methods',
|
||||
'generator-return', 'ignore-falsable-return', 'variadic', 'pure',
|
||||
'ignore-variable-method', 'ignore-variable-property', 'internal',
|
||||
'taint-sink', 'taint-source', 'assert-untainted',
|
||||
],
|
||||
true
|
||||
)) {
|
||||
@ -271,6 +272,7 @@ class DocComment
|
||||
'override-method-visibility', 'seal-properties', 'seal-methods',
|
||||
'generator-return', 'ignore-falsable-return', 'variadic', 'pure',
|
||||
'ignore-variable-method', 'ignore-variable-property', 'internal',
|
||||
'taint-sink', 'taint-source', 'assert-untainted',
|
||||
],
|
||||
true
|
||||
)) {
|
||||
|
@ -429,6 +429,24 @@ class CommentAnalyzer
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($parsed_docblock['specials']['psalm-taint-sink'])) {
|
||||
/** @var string $param */
|
||||
foreach ($parsed_docblock['specials']['psalm-taint-sink'] as $param) {
|
||||
$param = trim($param);
|
||||
|
||||
$info->taint_sink_params[] = ['name' => $param];
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($parsed_docblock['specials']['psalm-assert-untainted'])) {
|
||||
/** @var string $param */
|
||||
foreach ($parsed_docblock['specials']['psalm-assert-untainted'] as $param) {
|
||||
$param = trim($param);
|
||||
|
||||
$info->assert_untainted_params[] = ['name' => $param];
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($parsed_docblock['specials']['global'])) {
|
||||
foreach ($parsed_docblock['specials']['global'] as $offset => $global) {
|
||||
$line_parts = self::splitDocLine($global);
|
||||
|
@ -39,6 +39,7 @@ use function strtolower;
|
||||
use function substr;
|
||||
use function count;
|
||||
use function in_array;
|
||||
use Psalm\Internal\Taint\TypeSource;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
|
@ -42,6 +42,7 @@ use function array_search;
|
||||
use function array_keys;
|
||||
use function end;
|
||||
use function array_diff;
|
||||
use Psalm\Internal\Taint\TypeSource;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@ -267,7 +268,7 @@ abstract class FunctionLikeAnalyzer extends SourceAnalyzer implements Statements
|
||||
|
||||
$context->calling_method_id = strtolower($method_id);
|
||||
} elseif ($this->function instanceof Function_) {
|
||||
$cased_method_id = $this->function->name;
|
||||
$cased_method_id = $this->function->name->name;
|
||||
} else { // Closure
|
||||
if ($storage->return_type) {
|
||||
$closure_return_type = ExpressionAnalyzer::fleshOutType(
|
||||
@ -591,6 +592,15 @@ abstract class FunctionLikeAnalyzer extends SourceAnalyzer implements Statements
|
||||
]);
|
||||
}
|
||||
|
||||
if ($cased_method_id && $codebase->taint) {
|
||||
$type_source = TypeSource::getForMethodArgument($cased_method_id, $offset, $function_param->location);
|
||||
$var_type->sources = [$type_source];
|
||||
|
||||
if ($codebase->taint->hasExistingSource($type_source)) {
|
||||
$var_type->tainted = 1;
|
||||
}
|
||||
}
|
||||
|
||||
$context->vars_in_scope['$' . $function_param->name] = $var_type;
|
||||
$context->vars_possibly_in_scope['$' . $function_param->name] = true;
|
||||
|
||||
|
@ -82,6 +82,7 @@ use function file_get_contents;
|
||||
use function substr_count;
|
||||
use function array_map;
|
||||
use function end;
|
||||
use Psalm\Internal\Codebase\Taint;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@ -549,6 +550,14 @@ class ProjectAnalyzer
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function trackTaintedInputs()
|
||||
{
|
||||
$this->codebase->taint = new Taint();
|
||||
}
|
||||
|
||||
public function interpretRefactors() : void
|
||||
{
|
||||
if (!$this->codebase->alter_code) {
|
||||
|
@ -41,6 +41,7 @@ use function count;
|
||||
use function in_array;
|
||||
use function strtolower;
|
||||
use function explode;
|
||||
use Psalm\Internal\Taint\TypeSource;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@ -455,6 +456,45 @@ class PropertyAssignmentAnalyzer
|
||||
}
|
||||
}
|
||||
|
||||
if ($codebase->taint) {
|
||||
$method_source = new TypeSource(
|
||||
$property_id,
|
||||
new CodeLocation($statements_analyzer->getSource(), $stmt)
|
||||
);
|
||||
|
||||
if ($codebase->taint->hasPreviousSink($method_source)) {
|
||||
if ($assignment_value_type->sources) {
|
||||
$codebase->taint->addSinks(
|
||||
$statements_analyzer,
|
||||
$assignment_value_type->sources,
|
||||
new CodeLocation($statements_analyzer->getSource(), $stmt),
|
||||
$method_source
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($assignment_value_type->sources) {
|
||||
foreach ($assignment_value_type->sources as $type_source) {
|
||||
if ($codebase->taint->hasPreviousSource($type_source)
|
||||
|| $assignment_value_type->tainted
|
||||
) {
|
||||
$codebase->taint->addSources(
|
||||
$statements_analyzer,
|
||||
[$method_source],
|
||||
new CodeLocation($statements_analyzer->getSource(), $stmt),
|
||||
$type_source
|
||||
);
|
||||
}
|
||||
}
|
||||
} elseif ($assignment_value_type->tainted) {
|
||||
throw new \UnexpectedValueException(
|
||||
'sources should exist for tainted var in '
|
||||
. $statements_analyzer->getFileName() . ':'
|
||||
. $stmt->getLine()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$codebase->properties->propertyExists(
|
||||
$property_id,
|
||||
false,
|
||||
|
@ -329,6 +329,29 @@ class BinaryOpAnalyzer
|
||||
if (ExpressionAnalyzer::analyze($statements_analyzer, $stmt->right, $context) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($codebase->taint) {
|
||||
$sources = [];
|
||||
$either_tainted = 0;
|
||||
|
||||
if (isset($stmt->left->inferredType)) {
|
||||
$sources = $stmt->left->inferredType->sources ?: [];
|
||||
$either_tainted = $stmt->left->inferredType->tainted;
|
||||
}
|
||||
|
||||
if (isset($stmt->right->inferredType)) {
|
||||
$sources = array_merge($sources, $stmt->right->inferredType->sources ?: []);
|
||||
$either_tainted = $either_tainted | $stmt->right->inferredType->tainted;
|
||||
}
|
||||
|
||||
if ($sources) {
|
||||
$stmt->inferredType->sources = $sources;
|
||||
}
|
||||
|
||||
if ($either_tainted) {
|
||||
$stmt->inferredType->tainted = $either_tainted;
|
||||
}
|
||||
}
|
||||
} elseif ($stmt instanceof PhpParser\Node\Expr\BinaryOp\Coalesce) {
|
||||
$t_if_context = clone $context;
|
||||
|
||||
@ -578,6 +601,22 @@ class BinaryOpAnalyzer
|
||||
if ($result_type) {
|
||||
$stmt->inferredType = $result_type;
|
||||
}
|
||||
|
||||
if ($codebase->taint && $stmt->inferredType) {
|
||||
$sources = $stmt->left->inferredType->sources ?: [];
|
||||
$either_tainted = $stmt->left->inferredType->tainted;
|
||||
|
||||
$sources = array_merge($sources, $stmt->right->inferredType->sources ?: []);
|
||||
$either_tainted = $either_tainted | $stmt->right->inferredType->tainted;
|
||||
|
||||
if ($sources) {
|
||||
$stmt->inferredType->sources = $sources;
|
||||
}
|
||||
|
||||
if ($either_tainted) {
|
||||
$stmt->inferredType->tainted = $either_tainted;
|
||||
}
|
||||
}
|
||||
} elseif ($stmt instanceof PhpParser\Node\Expr\BinaryOp\BitwiseOr) {
|
||||
self::analyzeNonDivArithmeticOp(
|
||||
$statements_analyzer,
|
||||
|
@ -47,6 +47,7 @@ use function explode;
|
||||
use function array_search;
|
||||
use function array_keys;
|
||||
use function in_array;
|
||||
use Psalm\Internal\Taint\TypeSource;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@ -1143,6 +1144,10 @@ class MethodCallAnalyzer extends \Psalm\Internal\Analyzer\Statements\Expression\
|
||||
$class_storage->parent_class
|
||||
);
|
||||
|
||||
$return_type_candidate->sources = [
|
||||
new TypeSource(strtolower($method_id), new CodeLocation($source, $stmt->name))
|
||||
];
|
||||
|
||||
$return_type_location = $codebase->methods->getMethodReturnTypeLocation(
|
||||
$method_id,
|
||||
$secondary_return_type_location
|
||||
@ -1284,6 +1289,15 @@ class MethodCallAnalyzer extends \Psalm\Internal\Analyzer\Statements\Expression\
|
||||
);
|
||||
}
|
||||
|
||||
if ($codebase->taint && $method_id) {
|
||||
$method_source = new TypeSource(strtolower($method_id), new CodeLocation($source, $stmt->name));
|
||||
|
||||
if ($codebase->taint->hasPreviousSource($method_source)) {
|
||||
$return_type_candidate->tainted = 1;
|
||||
$return_type_candidate->sources = [$method_source];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$return_type) {
|
||||
$return_type = $return_type_candidate;
|
||||
} else {
|
||||
|
@ -31,6 +31,7 @@ use function strpos;
|
||||
use function is_string;
|
||||
use function strlen;
|
||||
use function substr;
|
||||
use Psalm\Internal\Taint\TypeSource;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@ -989,6 +990,15 @@ class StaticCallAnalyzer extends \Psalm\Internal\Analyzer\Statements\Expression\
|
||||
}
|
||||
|
||||
if ($return_type_candidate) {
|
||||
if ($codebase->taint && $method_id) {
|
||||
$method_source = new TypeSource(strtolower($method_id), new CodeLocation($source, $stmt->name));
|
||||
|
||||
if ($codebase->taint->hasPreviousSource($method_source)) {
|
||||
$return_type_candidate->tainted = 1;
|
||||
$return_type_candidate->sources = [$method_source];
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($stmt->inferredType)) {
|
||||
$stmt->inferredType = Type::combineUnionTypes($stmt->inferredType, $return_type_candidate);
|
||||
} else {
|
||||
|
@ -10,6 +10,7 @@ use Psalm\Internal\Analyzer\Statements\ExpressionAnalyzer;
|
||||
use Psalm\Internal\Analyzer\StatementsAnalyzer;
|
||||
use Psalm\Internal\Analyzer\TypeAnalyzer;
|
||||
use Psalm\Internal\Codebase\CallMap;
|
||||
use Psalm\Internal\Taint\TypeSource;
|
||||
use Psalm\Internal\Type\TypeCombination;
|
||||
use Psalm\CodeLocation;
|
||||
use Psalm\Context;
|
||||
@ -55,6 +56,7 @@ use function preg_replace;
|
||||
use function is_int;
|
||||
use function substr;
|
||||
use function array_merge;
|
||||
use Psalm\Issue\TaintedInput;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@ -1740,7 +1742,9 @@ class CallAnalyzer
|
||||
$context,
|
||||
$function_param->by_ref,
|
||||
$function_param->is_variadic,
|
||||
$arg->unpack
|
||||
$arg->unpack,
|
||||
$function_param->is_sink,
|
||||
$function_param->assert_untainted
|
||||
) === false) {
|
||||
return false;
|
||||
}
|
||||
@ -2231,15 +2235,6 @@ class CallAnalyzer
|
||||
}
|
||||
|
||||
/**
|
||||
* @param StatementsAnalyzer $statements_analyzer
|
||||
* @param Type\Union $input_type
|
||||
* @param Type\Union $param_type
|
||||
* @param string|null $cased_method_id
|
||||
* @param int $argument_offset
|
||||
* @param CodeLocation $code_location
|
||||
* @param bool $by_ref
|
||||
* @param bool $variadic
|
||||
*
|
||||
* @return null|false
|
||||
*/
|
||||
public static function checkFunctionArgumentType(
|
||||
@ -2247,14 +2242,16 @@ class CallAnalyzer
|
||||
Type\Union $input_type,
|
||||
Type\Union $param_type,
|
||||
?Type\Union $signature_param_type,
|
||||
$cased_method_id,
|
||||
?string $cased_method_id,
|
||||
int $argument_offset,
|
||||
CodeLocation $code_location,
|
||||
PhpParser\Node\Expr $input_expr,
|
||||
Context $context,
|
||||
bool $by_ref = false,
|
||||
bool $variadic = false,
|
||||
bool $unpack = false
|
||||
bool $unpack = false,
|
||||
bool $is_sink = false,
|
||||
bool $assert_untainted = false
|
||||
) {
|
||||
$codebase = $statements_analyzer->getCodebase();
|
||||
|
||||
@ -2379,6 +2376,98 @@ class CallAnalyzer
|
||||
$input_type = $union_comparison_results->replacement_union_type;
|
||||
}
|
||||
|
||||
if ($codebase->taint && $cased_method_id) {
|
||||
$method_source = TypeSource::getForMethodArgument($cased_method_id, $argument_offset, $code_location);
|
||||
|
||||
$has_previous_sink = $codebase->taint->hasPreviousSink($method_source);
|
||||
|
||||
if (($is_sink || $has_previous_sink)
|
||||
&& $input_type->sources
|
||||
) {
|
||||
$all_possible_sinks = [];
|
||||
|
||||
foreach ($input_type->sources as $source) {
|
||||
if ($codebase->taint->hasExistingSink($source)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$all_possible_sinks[] = $source;
|
||||
|
||||
if (strpos($source->id, '::') && strpos($source->id, '#')) {
|
||||
list($fq_classlike_name, $method_name) = explode('::', $source->id);
|
||||
|
||||
$method_name_parts = explode('#', $method_name);
|
||||
|
||||
$method_name = strtolower($method_name_parts[0]);
|
||||
|
||||
$class_storage = $codebase->classlike_storage_provider->get($fq_classlike_name);
|
||||
|
||||
foreach ($class_storage->dependent_classlikes as $dependent_classlike => $_) {
|
||||
$all_possible_sinks[] = TypeSource::getForMethodArgument(
|
||||
$dependent_classlike . '::' . $method_name,
|
||||
(int) $method_name_parts[1] - 1,
|
||||
$code_location
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($class_storage->overridden_method_ids[$method_name])) {
|
||||
foreach ($class_storage->overridden_method_ids[$method_name] as $parent_method_id) {
|
||||
$all_possible_sinks[] = TypeSource::getForMethodArgument(
|
||||
$parent_method_id,
|
||||
(int) $method_name_parts[1] - 1,
|
||||
$code_location
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$codebase->taint->addSinks(
|
||||
$statements_analyzer,
|
||||
$all_possible_sinks,
|
||||
$code_location,
|
||||
$method_source
|
||||
);
|
||||
}
|
||||
|
||||
if ($is_sink && $input_type->tainted) {
|
||||
if (IssueBuffer::accepts(
|
||||
new TaintedInput(
|
||||
'in path ' . $codebase->taint->getPredecessorPath($method_source)
|
||||
. ' out path ' . $codebase->taint->getSuccessorPath($method_source),
|
||||
$code_location
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
} elseif ($input_type->sources) {
|
||||
foreach ($input_type->sources as $type_source) {
|
||||
if ($codebase->taint->hasPreviousSource($type_source) || $input_type->tainted) {
|
||||
$codebase->taint->addSources(
|
||||
$statements_analyzer,
|
||||
[$method_source],
|
||||
$code_location,
|
||||
$type_source
|
||||
);
|
||||
}
|
||||
}
|
||||
} elseif ($input_type->tainted) {
|
||||
throw new \UnexpectedValueException(
|
||||
'sources should exist for tainted var in '
|
||||
. $statements_analyzer->getFileName() . ':'
|
||||
. $input_expr->getLine()
|
||||
);
|
||||
}
|
||||
|
||||
if ($assert_untainted) {
|
||||
$input_type = clone $input_type;
|
||||
$replace_input_type = true;
|
||||
$input_type->tainted = null;
|
||||
$input_type->sources = [];
|
||||
}
|
||||
}
|
||||
|
||||
if ($type_match_found
|
||||
&& $param_type->hasCallableType()
|
||||
) {
|
||||
@ -2760,19 +2849,27 @@ class CallAnalyzer
|
||||
);
|
||||
|
||||
if ($var_id) {
|
||||
$input_type = clone $input_type;
|
||||
$was_cloned = false;
|
||||
|
||||
if ($input_type->isNullable() && !$param_type->isNullable()) {
|
||||
$input_type = clone $input_type;
|
||||
$was_cloned = true;
|
||||
$input_type->removeType('null');
|
||||
}
|
||||
|
||||
if ($input_type->getId() === $param_type->getId()) {
|
||||
if (!$was_cloned) {
|
||||
$was_cloned = true;
|
||||
$input_type = clone $input_type;
|
||||
}
|
||||
|
||||
$input_type->from_docblock = false;
|
||||
|
||||
foreach ($input_type->getTypes() as $atomic_type) {
|
||||
$atomic_type->from_docblock = false;
|
||||
}
|
||||
} elseif ($input_type->hasMixed() && $signature_param_type) {
|
||||
$was_cloned = true;
|
||||
$input_type = clone $signature_param_type;
|
||||
|
||||
if ($input_type->isNullable()) {
|
||||
@ -2780,7 +2877,9 @@ class CallAnalyzer
|
||||
}
|
||||
}
|
||||
|
||||
$context->removeVarFromConflictingClauses($var_id, null, $statements_analyzer);
|
||||
if ($was_cloned) {
|
||||
$context->removeVarFromConflictingClauses($var_id, null, $statements_analyzer);
|
||||
}
|
||||
|
||||
if ($unpack) {
|
||||
$input_type = new Type\Union([
|
||||
|
@ -51,6 +51,7 @@ use function strtolower;
|
||||
use function in_array;
|
||||
use function is_int;
|
||||
use function preg_match;
|
||||
use Psalm\Internal\Taint\TypeSource;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@ -160,6 +161,13 @@ class ArrayFetchAnalyzer
|
||||
null
|
||||
);
|
||||
|
||||
if ($array_var_id === '$_GET' || $array_var_id === '$_POST') {
|
||||
$stmt->inferredType->tainted = Type\Union::TAINTED;
|
||||
$stmt->inferredType->sources = [
|
||||
new TypeSource('$_GET', new CodeLocation($statements_analyzer->getSource(), $stmt))
|
||||
];
|
||||
}
|
||||
|
||||
if ($context->inside_isset
|
||||
&& $stmt->dim
|
||||
&& isset($stmt->dim->inferredType)
|
||||
|
@ -39,6 +39,7 @@ use function array_reverse;
|
||||
use function array_keys;
|
||||
use function count;
|
||||
use function explode;
|
||||
use Psalm\Internal\Taint\TypeSource;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@ -173,6 +174,18 @@ class PropertyFetchAnalyzer
|
||||
|
||||
$property_id = $lhs_type_part->value . '::$' . $stmt->name->name;
|
||||
|
||||
if ($codebase->taint) {
|
||||
$method_source = new TypeSource(
|
||||
$property_id,
|
||||
new CodeLocation($statements_analyzer, $stmt->name)
|
||||
);
|
||||
|
||||
if ($codebase->taint->hasPreviousSource($method_source)) {
|
||||
$stmt->inferredType->tainted = 1;
|
||||
$stmt->inferredType->sources = [$method_source];
|
||||
}
|
||||
}
|
||||
|
||||
$codebase->properties->propertyExists(
|
||||
$property_id,
|
||||
false,
|
||||
@ -783,6 +796,15 @@ class PropertyFetchAnalyzer
|
||||
}
|
||||
}
|
||||
|
||||
if ($codebase->taint) {
|
||||
$method_source = new TypeSource($property_id, new CodeLocation($statements_analyzer, $stmt->name));
|
||||
|
||||
if ($codebase->taint->hasPreviousSource($method_source)) {
|
||||
$class_property_type->tainted = 1;
|
||||
$class_property_type->sources = [$method_source];
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($stmt->inferredType)) {
|
||||
$stmt->inferredType = Type::combineUnionTypes($class_property_type, $stmt->inferredType);
|
||||
} else {
|
||||
|
@ -523,6 +523,11 @@ class ExpressionAnalyzer
|
||||
}
|
||||
|
||||
$stmt->inferredType = Type::getString();
|
||||
|
||||
if (isset($stmt->expr->inferredType) && $stmt->expr->inferredType->tainted) {
|
||||
$stmt->inferredType->tainted = $stmt->expr->inferredType->tainted;
|
||||
$stmt->inferredType->sources = $stmt->expr->inferredType->sources;
|
||||
}
|
||||
} elseif ($stmt instanceof PhpParser\Node\Expr\Cast\Object_) {
|
||||
if (self::analyze($statements_analyzer, $stmt->expr, $context) === false) {
|
||||
return false;
|
||||
@ -1064,6 +1069,8 @@ class ExpressionAnalyzer
|
||||
$fleshed_out_type->by_ref = $return_type->by_ref;
|
||||
$fleshed_out_type->initialized = $return_type->initialized;
|
||||
$fleshed_out_type->had_template = $return_type->had_template;
|
||||
$fleshed_out_type->sources = $return_type->sources;
|
||||
$fleshed_out_type->tainted = $return_type->tainted;
|
||||
|
||||
return $fleshed_out_type;
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ use Psalm\Internal\Analyzer\TypeAnalyzer;
|
||||
use Psalm\CodeLocation;
|
||||
use Psalm\Context;
|
||||
use Psalm\Exception\DocblockParseException;
|
||||
use Psalm\Internal\Taint\TypeSource;
|
||||
use Psalm\Issue\FalsableReturnStatement;
|
||||
use Psalm\Issue\InvalidDocblock;
|
||||
use Psalm\Issue\InvalidReturnStatement;
|
||||
@ -20,10 +21,12 @@ use Psalm\Issue\MixedReturnStatement;
|
||||
use Psalm\Issue\MixedReturnTypeCoercion;
|
||||
use Psalm\Issue\NoValue;
|
||||
use Psalm\Issue\NullableReturnStatement;
|
||||
use Psalm\Issue\TaintedInput;
|
||||
use Psalm\IssueBuffer;
|
||||
use Psalm\Type;
|
||||
use function explode;
|
||||
use function strtolower;
|
||||
use UnexpectedValueException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@ -155,15 +158,52 @@ class ReturnAnalyzer
|
||||
$cased_method_id = $source->getCorrectlyCasedMethodId();
|
||||
|
||||
if ($stmt->expr) {
|
||||
if ($storage->return_type && !$storage->return_type->hasMixed()) {
|
||||
$inferred_type = ExpressionAnalyzer::fleshOutType(
|
||||
$codebase,
|
||||
$stmt->inferredType,
|
||||
$source->getFQCLN(),
|
||||
$source->getFQCLN(),
|
||||
$source->getParentFQCLN()
|
||||
$inferred_type = ExpressionAnalyzer::fleshOutType(
|
||||
$codebase,
|
||||
$stmt->inferredType,
|
||||
$source->getFQCLN(),
|
||||
$source->getFQCLN(),
|
||||
$source->getParentFQCLN()
|
||||
);
|
||||
|
||||
if ($codebase->taint) {
|
||||
$method_source = new TypeSource(
|
||||
strtolower($cased_method_id),
|
||||
new CodeLocation($source, $stmt->expr)
|
||||
);
|
||||
|
||||
if ($codebase->taint->hasPreviousSink($method_source)) {
|
||||
if ($inferred_type->sources) {
|
||||
$codebase->taint->addSinks(
|
||||
$statements_analyzer,
|
||||
$inferred_type->sources,
|
||||
new CodeLocation($source, $stmt->expr),
|
||||
$method_source
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($inferred_type->sources) {
|
||||
foreach ($inferred_type->sources as $type_source) {
|
||||
if ($codebase->taint->hasPreviousSource($type_source) || $inferred_type->tainted) {
|
||||
$codebase->taint->addSources(
|
||||
$statements_analyzer,
|
||||
[$method_source],
|
||||
new CodeLocation($source, $stmt->expr),
|
||||
$type_source
|
||||
);
|
||||
}
|
||||
}
|
||||
} elseif ($inferred_type->tainted) {
|
||||
throw new \UnexpectedValueException(
|
||||
'sources should exist for tainted var in '
|
||||
. $statements_analyzer->getFileName() . ':'
|
||||
. $stmt->getLine()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($storage->return_type && !$storage->return_type->hasMixed()) {
|
||||
$local_return_type = $source->getLocalReturnType($storage->return_type);
|
||||
|
||||
if ($storage instanceof \Psalm\Storage\MethodStorage) {
|
||||
|
@ -514,7 +514,8 @@ class StatementsAnalyzer extends SourceAnalyzer implements StatementsSource
|
||||
(int)$i,
|
||||
new CodeLocation($this->getSource(), $expr),
|
||||
$expr,
|
||||
$context
|
||||
$context,
|
||||
true
|
||||
) === false) {
|
||||
return false;
|
||||
}
|
||||
|
@ -68,7 +68,8 @@ use function usort;
|
||||
* class_locations: array<string, array<int, \Psalm\CodeLocation>>,
|
||||
* class_method_locations: array<string, array<int, \Psalm\CodeLocation>>,
|
||||
* class_property_locations: array<string, array<int, \Psalm\CodeLocation>>,
|
||||
* possible_method_param_types: array<string, array<int, \Psalm\Type\Union>>
|
||||
* possible_method_param_types: array<string, array<int, \Psalm\Type\Union>>,
|
||||
* taint_data: ?\Psalm\Internal\Codebase\Taint
|
||||
* }
|
||||
*/
|
||||
|
||||
@ -240,7 +241,6 @@ class Analyzer
|
||||
{
|
||||
$this->loadCachedResults($project_analyzer);
|
||||
|
||||
$filetype_analyzers = $this->config->getFiletypeAnalyzers();
|
||||
$codebase = $project_analyzer->getCodebase();
|
||||
|
||||
if ($alter_code) {
|
||||
@ -254,6 +254,60 @@ class Analyzer
|
||||
}
|
||||
);
|
||||
|
||||
$this->doAnalysis($project_analyzer, $pool_size);
|
||||
|
||||
if ($codebase->taint) {
|
||||
$i = 0;
|
||||
while ($codebase->taint->hasNewSinksAndSources() && ++$i <= 4) {
|
||||
$project_analyzer->progress->write("\n\n" . 'Found tainted inputs, reanalysing' . "\n\n");
|
||||
|
||||
$codebase->taint->clearNewSinksAndSources();
|
||||
|
||||
$this->doAnalysis($project_analyzer, $pool_size, true);
|
||||
}
|
||||
}
|
||||
|
||||
$this->progress->finish();
|
||||
|
||||
if ($codebase->find_unused_code
|
||||
&& ($project_analyzer->full_run || $codebase->find_unused_code === 'always')
|
||||
) {
|
||||
$project_analyzer->checkClassReferences();
|
||||
}
|
||||
|
||||
$scanned_files = $codebase->scanner->getScannedFiles();
|
||||
$codebase->file_reference_provider->setAnalyzedMethods($this->analyzed_methods);
|
||||
$codebase->file_reference_provider->setFileMaps($this->getFileMaps());
|
||||
$codebase->file_reference_provider->setTypeCoverage($this->mixed_counts);
|
||||
$codebase->file_reference_provider->updateReferenceCache($codebase, $scanned_files);
|
||||
|
||||
if ($codebase->diff_methods) {
|
||||
$codebase->statements_provider->resetDiffs();
|
||||
}
|
||||
|
||||
if ($alter_code) {
|
||||
$this->progress->startAlteringFiles();
|
||||
|
||||
$project_analyzer->prepareMigration();
|
||||
|
||||
$files_to_update = $this->files_to_update !== null ? $this->files_to_update : $this->files_to_analyze;
|
||||
|
||||
foreach ($files_to_update as $file_path) {
|
||||
$this->updateFile($file_path, $project_analyzer->dry_run);
|
||||
}
|
||||
|
||||
$project_analyzer->migrateCode();
|
||||
}
|
||||
}
|
||||
|
||||
private function doAnalysis(ProjectAnalyzer $project_analyzer, int $pool_size, bool $rerun = false) : void
|
||||
{
|
||||
$this->progress->start(count($this->files_to_analyze));
|
||||
|
||||
$codebase = $project_analyzer->getCodebase();
|
||||
|
||||
$filetype_analyzers = $this->config->getFiletypeAnalyzers();
|
||||
|
||||
$analysis_worker =
|
||||
/**
|
||||
* @param int $_
|
||||
@ -274,8 +328,6 @@ class Analyzer
|
||||
return $this->getFileIssues($file_path);
|
||||
};
|
||||
|
||||
$this->progress->start(count($this->files_to_analyze));
|
||||
|
||||
$task_done_closure =
|
||||
/**
|
||||
* @param array<IssueData> $issues
|
||||
@ -329,7 +381,7 @@ class Analyzer
|
||||
},
|
||||
$analysis_worker,
|
||||
/** @return WorkerData */
|
||||
function () {
|
||||
function () use ($rerun) {
|
||||
$project_analyzer = ProjectAnalyzer::getInstance();
|
||||
$codebase = $project_analyzer->getCodebase();
|
||||
$analyzer = $codebase->analyzer;
|
||||
@ -340,21 +392,22 @@ class Analyzer
|
||||
// @codingStandardsIgnoreStart
|
||||
return [
|
||||
'issues' => IssueBuffer::getIssuesData(),
|
||||
'file_references_to_classes' => $file_reference_provider->getAllFileReferencesToClasses(),
|
||||
'file_references_to_class_members' => $file_reference_provider->getAllFileReferencesToClassMembers(),
|
||||
'method_references_to_class_members' => $file_reference_provider->getAllMethodReferencesToClassMembers(),
|
||||
'file_references_to_missing_class_members' => $file_reference_provider->getAllFileReferencesToMissingClassMembers(),
|
||||
'method_references_to_missing_class_members' => $file_reference_provider->getAllMethodReferencesToMissingClassMembers(),
|
||||
'method_param_uses' => $file_reference_provider->getAllMethodParamUses(),
|
||||
'mixed_member_names' => $analyzer->getMixedMemberNames(),
|
||||
'file_manipulations' => FileManipulationBuffer::getAll(),
|
||||
'mixed_counts' => $analyzer->getMixedCounts(),
|
||||
'analyzed_methods' => $analyzer->getAnalyzedMethods(),
|
||||
'file_maps' => $analyzer->getFileMaps(),
|
||||
'class_locations' => $file_reference_provider->getAllClassLocations(),
|
||||
'class_method_locations' => $file_reference_provider->getAllClassMethodLocations(),
|
||||
'class_property_locations' => $file_reference_provider->getAllClassPropertyLocations(),
|
||||
'possible_method_param_types' => $analyzer->getPossibleMethodParamTypes(),
|
||||
'file_references_to_classes' => $rerun ? [] : $file_reference_provider->getAllFileReferencesToClasses(),
|
||||
'file_references_to_class_members' => $rerun ? [] : $file_reference_provider->getAllFileReferencesToClassMembers(),
|
||||
'method_references_to_class_members' => $rerun ? [] : $file_reference_provider->getAllMethodReferencesToClassMembers(),
|
||||
'file_references_to_missing_class_members' => $rerun ? [] : $file_reference_provider->getAllFileReferencesToMissingClassMembers(),
|
||||
'method_references_to_missing_class_members' => $rerun ? [] : $file_reference_provider->getAllMethodReferencesToMissingClassMembers(),
|
||||
'method_param_uses' => $rerun ? [] : $file_reference_provider->getAllMethodParamUses(),
|
||||
'mixed_member_names' => $rerun ? [] : $analyzer->getMixedMemberNames(),
|
||||
'file_manipulations' => $rerun ? [] : FileManipulationBuffer::getAll(),
|
||||
'mixed_counts' => $rerun ? [] : $analyzer->getMixedCounts(),
|
||||
'analyzed_methods' => $rerun ? [] : $analyzer->getAnalyzedMethods(),
|
||||
'file_maps' => $rerun ? [] : $analyzer->getFileMaps(),
|
||||
'class_locations' => $rerun ? [] : $file_reference_provider->getAllClassLocations(),
|
||||
'class_method_locations' => $rerun ? [] : $file_reference_provider->getAllClassMethodLocations(),
|
||||
'class_property_locations' => $rerun ? [] : $file_reference_provider->getAllClassPropertyLocations(),
|
||||
'possible_method_param_types' => $rerun ? [] : $analyzer->getPossibleMethodParamTypes(),
|
||||
'taint_data' => $codebase->taint,
|
||||
];
|
||||
// @codingStandardsIgnoreEnd
|
||||
},
|
||||
@ -374,6 +427,14 @@ class Analyzer
|
||||
foreach ($forked_pool_data as $pool_data) {
|
||||
IssueBuffer::addIssues($pool_data['issues']);
|
||||
|
||||
if ($codebase->taint && $pool_data['taint_data']) {
|
||||
$codebase->taint->addThreadData($pool_data['taint_data']);
|
||||
}
|
||||
|
||||
if ($rerun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($pool_data['issues'] as $issue_data) {
|
||||
$codebase->file_reference_provider->addIssue($issue_data['file_path'], $issue_data);
|
||||
}
|
||||
@ -470,40 +531,6 @@ class Analyzer
|
||||
$codebase->file_reference_provider->addIssue($issue_data['file_path'], $issue_data);
|
||||
}
|
||||
}
|
||||
|
||||
$this->progress->finish();
|
||||
|
||||
$codebase = $project_analyzer->getCodebase();
|
||||
|
||||
if ($codebase->find_unused_code
|
||||
&& ($project_analyzer->full_run || $codebase->find_unused_code === 'always')
|
||||
) {
|
||||
$project_analyzer->checkClassReferences();
|
||||
}
|
||||
|
||||
$scanned_files = $codebase->scanner->getScannedFiles();
|
||||
$codebase->file_reference_provider->setAnalyzedMethods($this->analyzed_methods);
|
||||
$codebase->file_reference_provider->setFileMaps($this->getFileMaps());
|
||||
$codebase->file_reference_provider->setTypeCoverage($this->mixed_counts);
|
||||
$codebase->file_reference_provider->updateReferenceCache($codebase, $scanned_files);
|
||||
|
||||
if ($codebase->diff_methods) {
|
||||
$codebase->statements_provider->resetDiffs();
|
||||
}
|
||||
|
||||
if ($alter_code) {
|
||||
$this->progress->startAlteringFiles();
|
||||
|
||||
$project_analyzer->prepareMigration();
|
||||
|
||||
$files_to_update = $this->files_to_update !== null ? $this->files_to_update : $this->files_to_analyze;
|
||||
|
||||
foreach ($files_to_update as $file_path) {
|
||||
$this->updateFile($file_path, $project_analyzer->dry_run);
|
||||
}
|
||||
|
||||
$project_analyzer->migrateCode();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -245,8 +245,9 @@ class Reflection
|
||||
$storage->is_static = $method->isStatic();
|
||||
$storage->abstract = $method->isAbstract();
|
||||
|
||||
$class_storage->declaring_method_ids[$method_name] =
|
||||
$declaring_class->name . '::' . strtolower((string)$method->getName());
|
||||
$declaring_method_id = $declaring_class->name . '::' . strtolower((string)$method->getName());
|
||||
|
||||
$class_storage->declaring_method_ids[$method_name] = $declaring_method_id;
|
||||
|
||||
$class_storage->inheritable_method_ids[$method_name] = $class_storage->declaring_method_ids[$method_name];
|
||||
$class_storage->appearing_method_ids[$method_name] = $class_storage->declaring_method_ids[$method_name];
|
||||
@ -261,10 +262,14 @@ class Reflection
|
||||
if ($callables && $callables[0]->params !== null && $callables[0]->return_type !== null) {
|
||||
$storage->params = [];
|
||||
|
||||
foreach ($callables[0]->params as $param) {
|
||||
foreach ($callables[0]->params as $i => $param) {
|
||||
if ($param->type) {
|
||||
$param->type->queueClassLikesForScanning($this->codebase);
|
||||
}
|
||||
|
||||
if ($declaring_method_id === 'PDO::exec' && $i === 0) {
|
||||
$param->is_sink = true;
|
||||
}
|
||||
}
|
||||
|
||||
$storage->params = $callables[0]->params;
|
||||
|
195
src/Psalm/Internal/Codebase/Taint.php
Normal file
195
src/Psalm/Internal/Codebase/Taint.php
Normal file
@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace Psalm\Internal\Codebase;
|
||||
|
||||
use Psalm\CodeLocation;
|
||||
use Psalm\Internal\Analyzer\StatementsAnalyzer;
|
||||
use Psalm\Internal\Taint\TypeSource;
|
||||
use Psalm\IssueBuffer;
|
||||
use Psalm\Issue\TaintedInput;
|
||||
use function array_merge;
|
||||
use function array_merge_recursive;
|
||||
use function strtolower;
|
||||
use UnexpectedValueException;
|
||||
|
||||
class Taint
|
||||
{
|
||||
/**
|
||||
* @var array<string, ?TypeSource>
|
||||
*/
|
||||
private $new_sinks = [];
|
||||
|
||||
/**
|
||||
* @var array<string, ?TypeSource>
|
||||
*/
|
||||
private $new_sources = [];
|
||||
|
||||
/**
|
||||
* @var array<string, ?TypeSource>
|
||||
*/
|
||||
private $previous_sinks = [];
|
||||
|
||||
/**
|
||||
* @var array<string, ?TypeSource>
|
||||
*/
|
||||
private $previous_sources = [];
|
||||
|
||||
/**
|
||||
* @var array<string, ?TypeSource>
|
||||
*/
|
||||
private $archived_sinks = [];
|
||||
|
||||
/**
|
||||
* @var array<string, ?TypeSource>
|
||||
*/
|
||||
private $archived_sources = [];
|
||||
|
||||
public function hasExistingSink(TypeSource $source) : ?TypeSource
|
||||
{
|
||||
return $this->archived_sinks[$source->id] ?? null;
|
||||
}
|
||||
|
||||
public function hasPreviousSink(TypeSource $source) : bool
|
||||
{
|
||||
return isset($this->previous_sinks[$source->id]);
|
||||
}
|
||||
|
||||
public function hasPreviousSource(TypeSource $source) : bool
|
||||
{
|
||||
return isset($this->previous_sources[$source->id]);
|
||||
}
|
||||
|
||||
public function hasExistingSource(TypeSource $source) : ?TypeSource
|
||||
{
|
||||
return $this->archived_sources[$source->id] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<TypeSource> $sources
|
||||
*/
|
||||
public function addSources(
|
||||
StatementsAnalyzer $statements_analyzer,
|
||||
array $sources,
|
||||
\Psalm\CodeLocation $code_location,
|
||||
?TypeSource $previous_source
|
||||
) : void {
|
||||
foreach ($sources as $source) {
|
||||
if ($this->hasExistingSource($source)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->hasExistingSink($source)) {
|
||||
if (IssueBuffer::accepts(
|
||||
new TaintedInput(
|
||||
($previous_source ? 'in path ' . $this->getPredecessorPath($previous_source) : '')
|
||||
. ' out path ' . $this->getSuccessorPath($source),
|
||||
$code_location
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
$this->new_sources[$source->id] = $previous_source;
|
||||
}
|
||||
}
|
||||
|
||||
public function getPredecessorPath(TypeSource $source) : string
|
||||
{
|
||||
$source_descriptor = $source->id
|
||||
. ($source->code_location ? ' (' . $source->code_location->getShortSummary() . ')' : '');
|
||||
|
||||
if ($previous_source = $this->new_sources[$source->id] ?? $this->archived_sources[$source->id] ?? null) {
|
||||
if ($previous_source === $source) {
|
||||
throw new \UnexpectedValueException('bad');
|
||||
}
|
||||
|
||||
return $this->getPredecessorPath($previous_source) . ' -> ' . $source_descriptor;
|
||||
}
|
||||
|
||||
return $source_descriptor;
|
||||
}
|
||||
|
||||
public function getSuccessorPath(TypeSource $source) : string
|
||||
{
|
||||
$source_descriptor = $source->id
|
||||
. ($source->code_location ? ' (' . $source->code_location->getShortSummary() . ')' : '');
|
||||
|
||||
if ($next_source = $this->new_sinks[$source->id] ?? $this->archived_sinks[$source->id] ?? null) {
|
||||
return $source_descriptor . ' -> ' . $this->getSuccessorPath($next_source);
|
||||
}
|
||||
|
||||
return $source_descriptor;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<TypeSource> $sources
|
||||
*/
|
||||
public function addSinks(
|
||||
StatementsAnalyzer $statements_analyzer,
|
||||
array $sources,
|
||||
\Psalm\CodeLocation $code_location,
|
||||
?TypeSource $previous_source
|
||||
) : void {
|
||||
foreach ($sources as $source) {
|
||||
if ($this->hasExistingSink($source)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->hasExistingSource($source)) {
|
||||
if (IssueBuffer::accepts(
|
||||
new TaintedInput(
|
||||
'in path ' . $this->getPredecessorPath($source)
|
||||
. ($previous_source ? ' out path ' . $this->getSuccessorPath($previous_source) : ''),
|
||||
$code_location
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
$this->new_sinks[$source->id] = $previous_source;
|
||||
}
|
||||
}
|
||||
|
||||
public function hasNewSinksAndSources() : bool
|
||||
{
|
||||
return $this->new_sinks && $this->new_sources;
|
||||
}
|
||||
|
||||
public function addThreadData(self $taint) : void
|
||||
{
|
||||
$this->new_sinks = array_merge(
|
||||
$this->new_sinks,
|
||||
$taint->new_sinks
|
||||
);
|
||||
|
||||
$this->new_sources = array_merge(
|
||||
$this->new_sources,
|
||||
$taint->new_sources
|
||||
);
|
||||
}
|
||||
|
||||
public function clearNewSinksAndSources() : void
|
||||
{
|
||||
$this->archived_sinks = array_merge(
|
||||
$this->archived_sinks,
|
||||
$this->new_sinks
|
||||
);
|
||||
|
||||
$this->previous_sinks = $this->new_sinks;
|
||||
|
||||
$this->new_sinks = [];
|
||||
|
||||
$this->archived_sources = array_merge(
|
||||
$this->archived_sources,
|
||||
$this->new_sources
|
||||
);
|
||||
|
||||
$this->previous_sources = $this->new_sources;
|
||||
|
||||
$this->new_sources = [];
|
||||
}
|
||||
}
|
@ -41,6 +41,16 @@ class FunctionDocblockComment
|
||||
*/
|
||||
public $params_out = [];
|
||||
|
||||
/**
|
||||
* @var array<int, array{name:string}>
|
||||
*/
|
||||
public $taint_sink_params = [];
|
||||
|
||||
/**
|
||||
* @var array<int, array{name:string}>
|
||||
*/
|
||||
public $assert_untainted_params = [];
|
||||
|
||||
/**
|
||||
* @var array<int, array{name:string, type:string, line_number: int}>
|
||||
*/
|
||||
|
33
src/Psalm/Internal/Taint/TypeSource.php
Normal file
33
src/Psalm/Internal/Taint/TypeSource.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Psalm\Internal\Taint;
|
||||
|
||||
use Psalm\CodeLocation;
|
||||
|
||||
class TypeSource
|
||||
{
|
||||
/** @var string */
|
||||
public $id;
|
||||
|
||||
/** @var ?CodeLocation */
|
||||
public $code_location;
|
||||
|
||||
public function __construct(string $id, ?CodeLocation $code_location)
|
||||
{
|
||||
$this->id = $id;
|
||||
$this->code_location = $code_location;
|
||||
}
|
||||
|
||||
public static function getForMethodArgument(
|
||||
string $method_id,
|
||||
int $argument_offset,
|
||||
?CodeLocation $code_location
|
||||
) : self {
|
||||
return new self(\strtolower($method_id . '#' . ($argument_offset + 1)), $code_location);
|
||||
}
|
||||
|
||||
public function __toString()
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
}
|
@ -2283,6 +2283,26 @@ class ReflectorVisitor extends PhpParser\NodeVisitorAbstract implements PhpParse
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($docblock_info->taint_sink_params as $taint_sink_param) {
|
||||
$param_name = substr($taint_sink_param['name'], 1);
|
||||
|
||||
foreach ($storage->params as $param_storage) {
|
||||
if ($param_storage->name === $param_name) {
|
||||
$param_storage->is_sink = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($docblock_info->assert_untainted_params as $untainted_assert_param) {
|
||||
$param_name = substr($untainted_assert_param['name'], 1);
|
||||
|
||||
foreach ($storage->params as $param_storage) {
|
||||
if ($param_storage->name === $param_name) {
|
||||
$param_storage->assert_untainted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($docblock_info->template_typeofs) {
|
||||
foreach ($docblock_info->template_typeofs as $template_typeof) {
|
||||
foreach ($storage->params as $param) {
|
||||
|
6
src/Psalm/Issue/TaintedInput.php
Normal file
6
src/Psalm/Issue/TaintedInput.php
Normal file
@ -0,0 +1,6 @@
|
||||
<?php
|
||||
namespace Psalm\Issue;
|
||||
|
||||
class TaintedInput extends CodeIssue
|
||||
{
|
||||
}
|
@ -46,7 +46,7 @@ abstract class Progress
|
||||
{
|
||||
}
|
||||
|
||||
protected function write(string $message): void
|
||||
public function write(string $message): void
|
||||
{
|
||||
fwrite(STDERR, $message);
|
||||
}
|
||||
|
@ -68,6 +68,16 @@ class FunctionLikeParameter
|
||||
*/
|
||||
public $is_variadic;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
public $is_sink = false;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
public $assert_untainted = false;
|
||||
|
||||
/**
|
||||
* @param string $name
|
||||
* @param bool $by_ref
|
||||
|
@ -1449,12 +1449,22 @@ abstract class Type
|
||||
if ($both_failed_reconciliation) {
|
||||
$combined_type->failed_reconciliation = true;
|
||||
}
|
||||
|
||||
if ($type_1->tainted || $type_2->tainted) {
|
||||
$combined_type->tainted = $type_1->tainted & $type_2->tainted;
|
||||
}
|
||||
}
|
||||
|
||||
if ($type_1->possibly_undefined || $type_2->possibly_undefined) {
|
||||
$combined_type->possibly_undefined = true;
|
||||
}
|
||||
|
||||
if ($type_1->sources || $type_2->sources) {
|
||||
$combined_type->sources = \array_unique(
|
||||
array_merge($type_1->sources ?: [], $type_2->sources ?: [])
|
||||
);
|
||||
}
|
||||
|
||||
return $combined_type;
|
||||
}
|
||||
|
||||
|
@ -31,6 +31,10 @@ use function substr;
|
||||
|
||||
class Union
|
||||
{
|
||||
const TAINTED = 1;
|
||||
const TAINTED_MYSQL_SAFE = 2;
|
||||
const TAINTED_HTML_SAFE = 4;
|
||||
|
||||
/**
|
||||
* @var array<string, Atomic>
|
||||
*/
|
||||
@ -141,6 +145,16 @@ class Union
|
||||
/** @var null|string */
|
||||
private $id;
|
||||
|
||||
/**
|
||||
* @var ?int
|
||||
*/
|
||||
public $tainted = null;
|
||||
|
||||
/**
|
||||
* @var ?array<\Psalm\Internal\Taint\TypeSource>
|
||||
*/
|
||||
public $sources;
|
||||
|
||||
/**
|
||||
* Constructs an Union instance
|
||||
*
|
||||
|
@ -62,6 +62,7 @@ $valid_long_options = [
|
||||
'shepherd::',
|
||||
'no-progress',
|
||||
'include-php-versions', // used for baseline
|
||||
'track-tainted-input'
|
||||
];
|
||||
|
||||
gc_collect_cycles();
|
||||
@ -127,7 +128,7 @@ array_map(
|
||||
if (!array_key_exists('use-ini-defaults', $options)) {
|
||||
ini_set('display_errors', '1');
|
||||
ini_set('display_startup_errors', '1');
|
||||
ini_set('memory_limit', (string) (4 * 1024 * 1024 * 1024));
|
||||
ini_set('memory_limit', (string) (8 * 1024 * 1024 * 1024));
|
||||
}
|
||||
|
||||
if (array_key_exists('help', $options)) {
|
||||
@ -508,6 +509,10 @@ if ($config->find_unused_variables) {
|
||||
$project_analyzer->getCodebase()->reportUnusedVariables();
|
||||
}
|
||||
|
||||
if (isset($options['track-tainted-input'])) {
|
||||
$project_analyzer->trackTaintedInputs();
|
||||
}
|
||||
|
||||
/** @var string $plugin_path */
|
||||
foreach ($plugins as $plugin_path) {
|
||||
$config->addPluginPath($plugin_path);
|
||||
|
@ -110,6 +110,7 @@ class DocumentationTest extends TestCase
|
||||
$code_blocks['UnrecognizedExpression'] = true;
|
||||
$code_blocks['UnrecognizedStatement'] = true;
|
||||
$code_blocks['PluginIssue'] = true;
|
||||
$code_blocks['TaintedInput'] = true;
|
||||
|
||||
// these are deprecated
|
||||
$code_blocks['TypeCoercion'] = true;
|
||||
|
@ -5,7 +5,7 @@ use Psalm\Progress\DefaultProgress;
|
||||
|
||||
class EchoProgress extends DefaultProgress
|
||||
{
|
||||
protected function write(string $message): void
|
||||
public function write(string $message): void
|
||||
{
|
||||
echo $message;
|
||||
}
|
||||
|
534
tests/TaintTest.php
Normal file
534
tests/TaintTest.php
Normal file
@ -0,0 +1,534 @@
|
||||
<?php
|
||||
namespace Psalm\Tests;
|
||||
|
||||
use Psalm\Config;
|
||||
use Psalm\Context;
|
||||
|
||||
class TaintTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testTaintedInputFromReturnType()
|
||||
{
|
||||
$this->expectException(\Psalm\Exception\CodeException::class);
|
||||
$this->expectExceptionMessage('TaintedInput');
|
||||
|
||||
$this->project_analyzer->trackTaintedInputs();
|
||||
|
||||
$this->addFile(
|
||||
'somefile.php',
|
||||
'<?php
|
||||
class A {
|
||||
public function getUserId() : string {
|
||||
return (string) $_GET["user_id"];
|
||||
}
|
||||
|
||||
public function getAppendedUserId() : string {
|
||||
return "aaaa" . $this->getUserId();
|
||||
}
|
||||
|
||||
public function deleteUser(PDO $pdo) : void {
|
||||
$userId = $this->getAppendedUserId();
|
||||
$pdo->exec("delete from users where user_id = " . $userId);
|
||||
}
|
||||
}'
|
||||
);
|
||||
|
||||
$this->analyzeFile('somefile.php', new Context());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testTaintedInputDirectly()
|
||||
{
|
||||
$this->expectException(\Psalm\Exception\CodeException::class);
|
||||
$this->expectExceptionMessage('TaintedInput');
|
||||
|
||||
$this->project_analyzer->trackTaintedInputs();
|
||||
|
||||
$this->addFile(
|
||||
'somefile.php',
|
||||
'<?php
|
||||
class A {
|
||||
public function deleteUser(PDO $pdo) : void {
|
||||
$userId = (string) $_GET["user_id"];
|
||||
$pdo->exec("delete from users where user_id = " . $userId);
|
||||
}
|
||||
}'
|
||||
);
|
||||
|
||||
$this->analyzeFile('somefile.php', new Context());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testTaintedInputDirectlySuppressed()
|
||||
{
|
||||
$this->project_analyzer->trackTaintedInputs();
|
||||
|
||||
$this->addFile(
|
||||
'somefile.php',
|
||||
'<?php
|
||||
class A {
|
||||
/** @psalm-suppress TaintedInput */
|
||||
public function deleteUser(PDO $pdo) : void {
|
||||
$userId = (string) $_GET["user_id"];
|
||||
$pdo->exec("delete from users where user_id = " . $userId);
|
||||
}
|
||||
}'
|
||||
);
|
||||
|
||||
$this->analyzeFile('somefile.php', new Context());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testTaintedInputDirectlySuppressedWithOtherUse()
|
||||
{
|
||||
$this->project_analyzer->trackTaintedInputs();
|
||||
|
||||
$this->addFile(
|
||||
'somefile.php',
|
||||
'<?php
|
||||
class A {
|
||||
/** @psalm-suppress TaintedInput */
|
||||
public function deleteUser(PDOWrapper $pdo) : void {
|
||||
$userId = (string) $_GET["user_id"];
|
||||
$pdo->exec("delete from users where user_id = " . $userId);
|
||||
}
|
||||
|
||||
public function deleteUserSafer(PDOWrapper $pdo) : void {
|
||||
$userId = $this->getSafeId();
|
||||
$pdo->exec("delete from users where user_id = " . $userId);
|
||||
}
|
||||
|
||||
public function getSafeId() : string {
|
||||
return "5";
|
||||
}
|
||||
}
|
||||
|
||||
class PDOWrapper {
|
||||
/**
|
||||
* @psalm-taint-sink $sql
|
||||
*/
|
||||
public function exec(string $sql) : void {}
|
||||
}'
|
||||
);
|
||||
|
||||
$this->analyzeFile('somefile.php', new Context());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testTaintedInputFromReturnTypeWithBranch()
|
||||
{
|
||||
$this->expectException(\Psalm\Exception\CodeException::class);
|
||||
$this->expectExceptionMessage('TaintedInput');
|
||||
|
||||
$this->project_analyzer->trackTaintedInputs();
|
||||
|
||||
$this->addFile(
|
||||
'somefile.php',
|
||||
'<?php
|
||||
class A {
|
||||
public function getUserId() : string {
|
||||
return (string) $_GET["user_id"];
|
||||
}
|
||||
|
||||
public function getAppendedUserId() : string {
|
||||
$userId = $this->getUserId();
|
||||
|
||||
if (rand(0, 1)) {
|
||||
$userId .= "aaa";
|
||||
} else {
|
||||
$userId .= "bb";
|
||||
}
|
||||
|
||||
return $userId;
|
||||
}
|
||||
|
||||
public function deleteUser(PDO $pdo) : void {
|
||||
$userId = $this->getAppendedUserId();
|
||||
$pdo->exec("delete from users where user_id = " . $userId);
|
||||
}
|
||||
}'
|
||||
);
|
||||
|
||||
$this->analyzeFile('somefile.php', new Context());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testSinkAnnotation()
|
||||
{
|
||||
$this->expectException(\Psalm\Exception\CodeException::class);
|
||||
$this->expectExceptionMessage('TaintedInput');
|
||||
|
||||
$this->project_analyzer->trackTaintedInputs();
|
||||
|
||||
$this->addFile(
|
||||
'somefile.php',
|
||||
'<?php
|
||||
class A {
|
||||
public function getUserId() : string {
|
||||
return (string) $_GET["user_id"];
|
||||
}
|
||||
|
||||
public function getAppendedUserId() : string {
|
||||
return "aaaa" . $this->getUserId();
|
||||
}
|
||||
|
||||
public function deleteUser(PDOWrapper $pdo) : void {
|
||||
$userId = $this->getAppendedUserId();
|
||||
$pdo->exec("delete from users where user_id = " . $userId);
|
||||
}
|
||||
}
|
||||
|
||||
class PDOWrapper {
|
||||
/**
|
||||
* @psalm-taint-sink $sql
|
||||
*/
|
||||
public function exec(string $sql) : void {}
|
||||
}'
|
||||
);
|
||||
|
||||
$this->analyzeFile('somefile.php', new Context());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testTaintedInputFromParam()
|
||||
{
|
||||
$this->expectException(\Psalm\Exception\CodeException::class);
|
||||
$this->expectExceptionMessage('TaintedInput - somefile.php:8:32 - in path $_GET (somefile.php:4:41) -> a::getuserid (somefile.php:8:48) out path a::getappendeduserid (somefile.php:8:32) -> a::deleteuser#2 (somefile.php:13:49) -> pdo::exec#1 (somefile.php:17:36)');
|
||||
|
||||
$this->project_analyzer->trackTaintedInputs();
|
||||
|
||||
$this->addFile(
|
||||
'somefile.php',
|
||||
'<?php
|
||||
class A {
|
||||
public function getUserId() : string {
|
||||
return (string) $_GET["user_id"];
|
||||
}
|
||||
|
||||
public function getAppendedUserId() : string {
|
||||
return "aaaa" . $this->getUserId();
|
||||
}
|
||||
|
||||
public function doDelete(PDO $pdo) : void {
|
||||
$userId = $this->getAppendedUserId();
|
||||
$this->deleteUser($pdo, $userId);
|
||||
}
|
||||
|
||||
public function deleteUser(PDO $pdo, string $userId) : void {
|
||||
$pdo->exec("delete from users where user_id = " . $userId);
|
||||
}
|
||||
}'
|
||||
);
|
||||
|
||||
$this->analyzeFile('somefile.php', new Context());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testTaintedInputToParam()
|
||||
{
|
||||
$this->expectException(\Psalm\Exception\CodeException::class);
|
||||
$this->expectExceptionMessage('TaintedInput');
|
||||
|
||||
$this->project_analyzer->trackTaintedInputs();
|
||||
|
||||
$this->addFile(
|
||||
'somefile.php',
|
||||
'<?php
|
||||
class A {
|
||||
public function getUserId(PDO $pdo) : void {
|
||||
$this->deleteUser(
|
||||
$pdo,
|
||||
$this->getAppendedUserId((string) $_GET["user_id"])
|
||||
);
|
||||
}
|
||||
|
||||
public function getAppendedUserId(string $user_id) : string {
|
||||
return "aaa" . $user_id;
|
||||
}
|
||||
|
||||
public function deleteUser(PDO $pdo, string $userId) : void {
|
||||
$pdo->exec("delete from users where user_id = " . $userId);
|
||||
}
|
||||
}'
|
||||
);
|
||||
|
||||
$this->analyzeFile('somefile.php', new Context());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testTaintedInputToParamAfterAssignment()
|
||||
{
|
||||
$this->expectException(\Psalm\Exception\CodeException::class);
|
||||
$this->expectExceptionMessage('TaintedInput');
|
||||
|
||||
$this->project_analyzer->trackTaintedInputs();
|
||||
|
||||
$this->addFile(
|
||||
'somefile.php',
|
||||
'<?php
|
||||
class A {
|
||||
public function getUserId(PDO $pdo) : void {
|
||||
$this->deleteUser(
|
||||
$pdo,
|
||||
$this->getAppendedUserId((string) $_GET["user_id"])
|
||||
);
|
||||
}
|
||||
|
||||
public function getAppendedUserId(string $user_id) : string {
|
||||
return "aaa" . $user_id;
|
||||
}
|
||||
|
||||
public function deleteUser(PDO $pdo, string $userId) : void {
|
||||
$userId2 = $userId;
|
||||
$pdo->exec("delete from users where user_id = " . $userId2);
|
||||
}
|
||||
}'
|
||||
);
|
||||
|
||||
$this->analyzeFile('somefile.php', new Context());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testTaintedInputToParamButSafe()
|
||||
{
|
||||
$this->project_analyzer->trackTaintedInputs();
|
||||
|
||||
$this->addFile(
|
||||
'somefile.php',
|
||||
'<?php
|
||||
class A {
|
||||
public function getUserId(PDO $pdo) : void {
|
||||
$this->deleteUser(
|
||||
$pdo,
|
||||
$this->getAppendedUserId((string) $_GET["user_id"])
|
||||
);
|
||||
}
|
||||
|
||||
public function getAppendedUserId(string $user_id) : string {
|
||||
return "aaa" . $user_id;
|
||||
}
|
||||
|
||||
public function deleteUser(PDO $pdo, string $userId) : void {
|
||||
$userId2 = strlen($userId);
|
||||
$pdo->exec("delete from users where user_id = " . $userId2);
|
||||
}
|
||||
}'
|
||||
);
|
||||
|
||||
$this->analyzeFile('somefile.php', new Context());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testTaintedInputToParamAlternatePath()
|
||||
{
|
||||
$this->expectException(\Psalm\Exception\CodeException::class);
|
||||
$this->expectExceptionMessage('TaintedInput - somefile.php:7:29 - in path $_GET (somefile.php:7:63) -> a::getappendeduserid#1 (somefile.php:11:62) -> a::getappendeduserid (somefile.php:7:36) out path a::deleteuser#3 (somefile.php:7:29) -> pdo::exec#1 (somefile.php:23:40)');
|
||||
|
||||
$this->project_analyzer->trackTaintedInputs();
|
||||
|
||||
$this->addFile(
|
||||
'somefile.php',
|
||||
'<?php
|
||||
class A {
|
||||
public function getUserId(PDO $pdo) : void {
|
||||
$this->deleteUser(
|
||||
$pdo,
|
||||
self::doFoo(),
|
||||
$this->getAppendedUserId((string) $_GET["user_id"])
|
||||
);
|
||||
}
|
||||
|
||||
public function getAppendedUserId(string $user_id) : string {
|
||||
return "aaa" . $user_id;
|
||||
}
|
||||
|
||||
public static function doFoo() : string {
|
||||
return "hello";
|
||||
}
|
||||
|
||||
public function deleteUser(PDO $pdo, string $userId, string $userId2) : void {
|
||||
$pdo->exec("delete from users where user_id = " . $userId);
|
||||
|
||||
if (rand(0, 1)) {
|
||||
$pdo->exec("delete from users where user_id = " . $userId2);
|
||||
}
|
||||
}
|
||||
}'
|
||||
);
|
||||
|
||||
$this->analyzeFile('somefile.php', new Context());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testTaintedInParentLoader()
|
||||
{
|
||||
$this->expectException(\Psalm\Exception\CodeException::class);
|
||||
$this->expectExceptionMessage('TaintedInput - somefile.php:24:47 - in path $_GET (somefile.php:28:39) -> c::foo#1 (somefile.php:23:48) out path agrandchild::loadfull#1 (somefile.php:24:47) -> a::loadpartial#1 (somefile.php:6:45) -> pdo::exec#1 (somefile.php:16:40)');
|
||||
|
||||
$this->project_analyzer->trackTaintedInputs();
|
||||
|
||||
$this->addFile(
|
||||
'somefile.php',
|
||||
'<?php
|
||||
abstract class A {
|
||||
abstract public static function loadPartial(string $sink) : void;
|
||||
|
||||
public static function loadFull(string $sink) : void {
|
||||
static::loadPartial($sink);
|
||||
}
|
||||
}
|
||||
|
||||
function getPdo() : PDO {
|
||||
return new PDO("connectionstring");
|
||||
}
|
||||
|
||||
class AChild extends A {
|
||||
public static function loadPartial(string $sink) : void {
|
||||
getPdo()->exec("select * from foo where bar = " . $sink);
|
||||
}
|
||||
}
|
||||
|
||||
class AGrandChild extends AChild {}
|
||||
|
||||
class C {
|
||||
public function foo(string $user_id) : void {
|
||||
AGrandChild::loadFull($user_id);
|
||||
}
|
||||
}
|
||||
|
||||
(new C)->foo((string) $_GET["user_id"]);'
|
||||
);
|
||||
|
||||
$this->analyzeFile('somefile.php', new Context());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testValidatedInputFromParam()
|
||||
{
|
||||
$this->project_analyzer->trackTaintedInputs();
|
||||
|
||||
$this->addFile(
|
||||
'somefile.php',
|
||||
'<?php
|
||||
/**
|
||||
* @psalm-assert-untainted $userId
|
||||
*/
|
||||
function validateUserId(string $userId) : void {
|
||||
if (!is_numeric($userId)) {
|
||||
throw new \Exception("bad");
|
||||
}
|
||||
}
|
||||
|
||||
class A {
|
||||
public function getUserId() : string {
|
||||
return (string) $_GET["user_id"];
|
||||
}
|
||||
|
||||
public function doDelete(PDO $pdo) : void {
|
||||
$userId = $this->getUserId();
|
||||
validateUserId($userId);
|
||||
$this->deleteUser($pdo, $userId);
|
||||
}
|
||||
|
||||
public function deleteUser(PDO $pdo, string $userId) : void {
|
||||
$pdo->exec("delete from users where user_id = " . $userId);
|
||||
}
|
||||
}'
|
||||
);
|
||||
|
||||
$this->analyzeFile('somefile.php', new Context());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testUntaintedInput()
|
||||
{
|
||||
$this->project_analyzer->trackTaintedInputs();
|
||||
|
||||
$this->addFile(
|
||||
'somefile.php',
|
||||
'<?php
|
||||
class A {
|
||||
public function getUserId() : int {
|
||||
return (int) $_GET["user_id"];
|
||||
}
|
||||
|
||||
public function getAppendedUserId() : string {
|
||||
return "aaaa" . $this->getUserId();
|
||||
}
|
||||
|
||||
public function deleteUser(PDO $pdo) : void {
|
||||
$userId = $this->getAppendedUserId();
|
||||
$pdo->exec("delete from users where user_id = " . $userId);
|
||||
}
|
||||
}'
|
||||
);
|
||||
|
||||
$this->analyzeFile('somefile.php', new Context());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testTaintedInputFromProperty()
|
||||
{
|
||||
$this->expectException(\Psalm\Exception\CodeException::class);
|
||||
$this->expectExceptionMessage('TaintedInput');
|
||||
|
||||
$this->project_analyzer->trackTaintedInputs();
|
||||
|
||||
$this->addFile(
|
||||
'somefile.php',
|
||||
'<?php
|
||||
class A {
|
||||
public string $userId;
|
||||
|
||||
public function __construct() {
|
||||
$this->userId = (string) $_GET["user_id"];
|
||||
}
|
||||
|
||||
public function getAppendedUserId() : string {
|
||||
return "aaaa" . $this->userId;
|
||||
}
|
||||
|
||||
public function doDelete(PDO $pdo) : void {
|
||||
$userId = $this->getAppendedUserId();
|
||||
$this->deleteUser($pdo, $userId);
|
||||
}
|
||||
|
||||
public function deleteUser(PDO $pdo, string $userId) : void {
|
||||
$pdo->exec("delete from users where user_id = " . $userId);
|
||||
}
|
||||
}'
|
||||
);
|
||||
|
||||
$this->analyzeFile('somefile.php', new Context());
|
||||
}
|
||||
}
|
@ -111,6 +111,14 @@ class TestCase extends BaseTestCase
|
||||
$codebase->config->shortenFileName($file_path)
|
||||
);
|
||||
$file_analyzer->analyze($context);
|
||||
|
||||
if ($codebase->taint) {
|
||||
while ($codebase->taint->hasNewSinksAndSources()) {
|
||||
$codebase->taint->clearNewSinksAndSources();
|
||||
|
||||
$file_analyzer->analyze($context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
Loading…
Reference in New Issue
Block a user