Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RFR] Tainting twig #61

Merged
merged 35 commits into from
Sep 13, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
5c802fb
Taint Request::get and Response::__construct
adrienlucas Jun 26, 2020
cbdcf78
Taint Request::request, Request::query & Request::cookies
adrienlucas Jul 15, 2020
b456bb5
Taint Request::headers (only for the user-agent header)
adrienlucas Jul 15, 2020
d694407
Taint HeaderBag::__toString (and a fix of psalm taint api usage)
adrienlucas Jul 15, 2020
a28c180
Taint InputBag::all
adrienlucas Jul 15, 2020
9d6a6ba
Fix type resolving
adrienlucas Jul 15, 2020
8e3df52
Bump psalm requirement
adrienlucas Jul 15, 2020
1eb45fe
refactoring first scenario outline
adrienlucas Jul 16, 2020
df98124
Refactor RequestTaint
adrienlucas Jul 16, 2020
3f6bed7
no message
seferov Jul 20, 2020
dc27963
Taint HeaderBag using the MethodReturnTypeProviderInterface
adrienlucas Jul 22, 2020
b0f8a98
Skip tests when dependencies too low
adrienlucas Aug 21, 2020
cae148d
Taint Request::get and Response::__construct
adrienlucas Jun 26, 2020
8b3b629
Taint Request::headers (only for the user-agent header)
adrienlucas Jul 15, 2020
b783632
wip
adrienlucas Jul 17, 2020
04fcf8e
Try using MethodReturnTypeProviderInterface
adrienlucas Jul 17, 2020
c57e1bf
Compiling templates
adrienlucas Jul 17, 2020
56e136a
Make it work using fake call
adrienlucas Jul 22, 2020
eb01ada
Explicitely load compliled twig files
adrienlucas Jul 23, 2020
a2b551b
wip
adrienlucas Jul 23, 2020
d520fe1
Add more direct way of tainting output
muglug Jul 23, 2020
ca28ebd
wip
adrienlucas Jul 24, 2020
79523f6
Add real twig template analyzer
adrienlucas Jul 26, 2020
e269c71
Refactor
adrienlucas Jul 26, 2020
22b0feb
Little fixes
adrienlucas Jul 27, 2020
10b2249
Change namespace from Taint to Twig
adrienlucas Jul 27, 2020
a7823b6
Refactoring
adrienlucas Aug 14, 2020
6d36ac2
Some type fixing
adrienlucas Aug 21, 2020
d23413b
Fix CS
adrienlucas Aug 21, 2020
4ba99a1
Guess template-to-cache mapping instead of using the twig env.
adrienlucas Aug 25, 2020
486a880
Fix typing
adrienlucas Aug 25, 2020
9b61307
Last minutes changes
adrienlucas Aug 26, 2020
446833b
Change test group
adrienlucas Aug 28, 2020
8a3aca6
Update README
adrienlucas Sep 10, 2020
2607c82
fix TemplateFileAnalyzer path in readme
adrienlucas Sep 11, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ vendor/bin/psalm-plugin enable psalm/plugin-symfony
- Detect `ContainerInterface::get()` result type. Works better if you [configure](#configuration) compiled container XML file.
- Detect return type of console arguments (`InputInterface::getArgument()`) and options (`InputInterface::getOption()`). Enforces
to use InputArgument and InputOption constants as a part of best practise.
- Detects correct Doctrine repository class if entities are configured with annotations.
- Detects correct Doctrine repository class if entities are configured with annotations.
- Fixes `PossiblyInvalidArgument` for `Symfony\Component\HttpFoundation\Request::getContent`.
The plugin calculates real return type by checking the given argument and marks return type as either string or resource.
- Detect return type of `Symfony\Component\HttpFoundation\HeaderBag::get` (by checking default value and third argument for < Symfony 4.4)
Expand Down Expand Up @@ -67,6 +67,30 @@ Example:
</pluginClass>
```

#### Twig tainting configuration

There are two approaches to including twig templates for taint analysis :

- one based on a specific file analyzer which uses the twig parser to taint twig's AST nodes
- one based on the already compiled twig templates

To leverage the real Twig file analyzer, you have to configure the `.twig` extension as follows :

```xml
<fileExtensions>
<extension name=".php" />
<extension name=".twig" checker="./vendor/psalm/plugin-symfony/src/Twig/TemplateFileAnalyzer.php"/>
</fileExtensions>
```

To allow the analysis through the cached template files, you have to add the `twigCachePath` entry to the plugin configuration :

```xml
<pluginClass class="Psalm\SymfonyPsalmPlugin\Plugin">
<twigCachePath>/cache/twig</twigCachePath>
</pluginClass>
```

### Credits

- Plugin created by [@seferov](/~https://github.com/seferov)
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"symfony/messenger": "^4.2 || ^5.0",
"symfony/security-guard": "^4.0 || ^5.0",
"symfony/validator": "*",
"twig/twig": "^2.10 || ^3.0",
"weirdan/codeception-psalm-module": "~0.8"
},
"autoload": {
Expand Down
21 changes: 21 additions & 0 deletions src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Psalm\SymfonyPsalmPlugin;

use Doctrine\Common\Annotations\AnnotationRegistry;
use Psalm\Exception\ConfigException;
use Psalm\Plugin\PluginEntryPointInterface;
use Psalm\Plugin\RegistrationInterface;
use Psalm\SymfonyPsalmPlugin\Handler\AnnotationHandler;
Expand All @@ -12,6 +13,9 @@
use Psalm\SymfonyPsalmPlugin\Handler\DoctrineRepositoryHandler;
use Psalm\SymfonyPsalmPlugin\Handler\HeaderBagHandler;
use Psalm\SymfonyPsalmPlugin\Symfony\ContainerMeta;
use Psalm\SymfonyPsalmPlugin\Twig\AnalyzedTemplatesTainter;
use Psalm\SymfonyPsalmPlugin\Twig\CachedTemplatesMapping;
use Psalm\SymfonyPsalmPlugin\Twig\CachedTemplatesTainter;
use SimpleXMLElement;
use Symfony\Component\HttpKernel\Kernel;

Expand Down Expand Up @@ -77,5 +81,22 @@ public function __invoke(RegistrationInterface $api, SimpleXMLElement $config =
foreach ($stubs as $stubFilePath) {
$api->addStubFile($stubFilePath);
}

if (isset($config->twigCachePath)) {
$twig_cache_path = getcwd().DIRECTORY_SEPARATOR.ltrim((string) $config->twigCachePath, DIRECTORY_SEPARATOR);
if (!is_dir($twig_cache_path) || !is_readable($twig_cache_path)) {
throw new ConfigException(sprintf('The twig directory %s is missing or not readable.', $twig_cache_path));
}

require_once __DIR__.'/Twig/CachedTemplatesTainter.php';
$api->registerHooksFromClass(CachedTemplatesTainter::class);

require_once __DIR__.'/Twig/CachedTemplatesMapping.php';
$api->registerHooksFromClass(CachedTemplatesMapping::class);
CachedTemplatesMapping::setCachePath($twig_cache_path);
}

require_once __DIR__.'/Twig/AnalyzedTemplatesTainter.php';
$api->registerHooksFromClass(AnalyzedTemplatesTainter::class);
}
}
14 changes: 14 additions & 0 deletions src/Stubs/common/TwigEnvironment.stubphp
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Twig;


class Environment
{
/**
* @param string|TemplateWrapper $name
*
* @psalm-taint-specialize
*/
public function render($name, array $context = []): string {}
}
13 changes: 13 additions & 0 deletions src/Stubs/common/TwigTemplate.stubphp
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Twig;


class Template
{
/**
* @psalm-return void
* @psalm-taint-specialize
*/
protected function doDisplay(array $context, array $blocks = []) {}
}
8 changes: 8 additions & 0 deletions src/Stubs/common/twig_functions.stubphp
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

/**
* @psalm-pure
* @psalm-flow ($string) -> return
*
*/
function twig_raw_filter(string $string): string {}
75 changes: 75 additions & 0 deletions src/Test/CodeceptionModule.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,60 @@
use Codeception\Module as BaseModule;
use Composer\InstalledVersions;
use Composer\Semver\VersionParser;
use InvalidArgumentException;
use PackageVersions\Versions;
use PHPUnit\Framework\SkippedTestError;
use RuntimeException;
use Twig\Cache\FilesystemCache;
use Twig\Environment;
use Twig\Extension\AbstractExtension;
use Twig\Loader\FilesystemLoader;

/**
* @psalm-suppress UnusedClass
* This class is to be used in codeception configuration - like in tests/acceptance/acceptance.suite.yml.
*/
class CodeceptionModule extends BaseModule
{
/** @var array<string,string> */
protected $config = [
'default_dir' => 'tests/_run/',
];

public const TWIG_TEMPLATES_DIR = 'templates';

/**
* @Given I have the following :templateName template :code
*/
public function haveTheFollowingTemplate(string $templateName, string $code): void
{
$rootDirectory = rtrim($this->config['default_dir'], DIRECTORY_SEPARATOR);
$templateRootDirectory = $rootDirectory.DIRECTORY_SEPARATOR.self::TWIG_TEMPLATES_DIR;
if (!file_exists($templateRootDirectory)) {
mkdir($templateRootDirectory);
}

file_put_contents(
$templateRootDirectory.DIRECTORY_SEPARATOR.$templateName,
$code
);
}

/**
* @Given the :templateName template is compiled in the :cacheDirectory directory
*/
public function haveTheTemplateCompiled(string $templateName, string $cacheDirectory): void
{
$rootDirectory = rtrim($this->config['default_dir'], DIRECTORY_SEPARATOR);
$cacheDirectory = $rootDirectory.DIRECTORY_SEPARATOR.ltrim($cacheDirectory, DIRECTORY_SEPARATOR);
if (!file_exists($cacheDirectory)) {
mkdir($cacheDirectory, 0755, true);
}

$twigEnvironment = self::getEnvironment($rootDirectory, $cacheDirectory);
$twigEnvironment->load($templateName);
}

/**
* @Given I have the :package package satisfying the :versionConstraint
*/
Expand All @@ -43,4 +87,35 @@ public function haveADependencySatisfied(string $package, string $versionConstra
throw new SkippedTestError("This scenario requires $package to match $versionConstraint");
}
}

private static function getEnvironment(string $rootDirectory, string $cacheDirectory): Environment
{
if (!file_exists($rootDirectory.DIRECTORY_SEPARATOR.self::TWIG_TEMPLATES_DIR)) {
mkdir($rootDirectory.DIRECTORY_SEPARATOR.self::TWIG_TEMPLATES_DIR);
}

$loader = new FilesystemLoader(self::TWIG_TEMPLATES_DIR, $rootDirectory);
adrienlucas marked this conversation as resolved.
Show resolved Hide resolved

if (!is_dir($cacheDirectory)) {
throw new InvalidArgumentException(sprintf('The %s twig cache directory does not exist or is not readable.', $cacheDirectory));
}
$cache = new FilesystemCache($cacheDirectory);

$twigEnvironment = new Environment($loader, [
'cache' => $cache,
'auto_reload' => true,
'debug' => true,
'optimizations' => 0,
'strict_variables' => false,
]);

// The following is a trick to have a different twig cache hash everytime, preventing collisions from one test to another :
// the extension construction has to be evaled so the class name will change each time,
// making the env calculate a different Twig\Environment::$optionHash (which is partly based on the extension names).
/** @var AbstractExtension $ext */
$ext = eval('use Twig\Extension\AbstractExtension; return new class() extends AbstractExtension {};');
$twigEnvironment->addExtension($ext);

return $twigEnvironment;
}
}
50 changes: 50 additions & 0 deletions src/Twig/AnalyzedTemplatesTainter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace Psalm\SymfonyPsalmPlugin\Twig;

use PhpParser\Node\Expr;
use PhpParser\Node\Expr\Array_;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Scalar\String_;
use Psalm\Codebase;
use Psalm\Context;
use Psalm\Plugin\Hook\AfterMethodCallAnalysisInterface;
use Psalm\StatementsSource;
use Psalm\Type\Union;
use Twig\Environment;

/**
* This hook adds paths from all taint sources going to a `Twig\Environment::render()` call to all taint sinks of the corresponding template.
* The TemplateFileAnalyzer should be declared in configuration.
*/
class AnalyzedTemplatesTainter implements AfterMethodCallAnalysisInterface
{
public static function afterMethodCallAnalysis(Expr $expr, string $method_id, string $appearing_method_id, string $declaring_method_id, Context $context, StatementsSource $statements_source, Codebase $codebase, array &$file_replacements = [], Union &$return_type_candidate = null): void
{
if (
null === $codebase->taint
|| !$expr instanceof MethodCall || $method_id !== Environment::class.'::render' || empty($expr->args)
|| !isset($expr->args[0]->value) || !$expr->args[0]->value instanceof String_
|| !isset($expr->args[1]->value) || !$expr->args[1]->value instanceof Array_
) {
return;
}

$template_name = $expr->args[0]->value->value;
$twig_arguments_type = $statements_source->getNodeTypeProvider()->getType($expr->args[1]->value);

if (null === $twig_arguments_type || null === $twig_arguments_type->parent_nodes) {
return;
}

foreach ($twig_arguments_type->parent_nodes as $source_taint) {
preg_match('/array\[\'([a-zA-Z]+)\'\]/', $source_taint->label, $matches);
$sink_taint = TemplateFileAnalyzer::getTaintNodeForTwigNamedVariable(
$template_name, $matches[1]
);
$codebase->taint->addPath($source_taint, $sink_taint, 'arg');
}
}
}
72 changes: 72 additions & 0 deletions src/Twig/CachedTemplatesMapping.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace Psalm\SymfonyPsalmPlugin\Twig;

use Psalm\Codebase;
use Psalm\Context;
use Psalm\Plugin\Hook\AfterFileAnalysisInterface;
use Psalm\StatementsSource;
use Psalm\Storage\FileStorage;
use RuntimeException;

/**
* This class is used to store a mapping of all analyzed twig template cache files with their corresponding actual templates.
*/
class CachedTemplatesMapping implements AfterFileAnalysisInterface
{
/**
* @var string
*/
private const CACHED_TEMPLATE_HEADER_PATTERN = 'use Twig\\\\Template;\n\n\/\* (@?.+\.twig) \*\/\nclass __TwigTemplate';

/**
* @var string|null
*/
public static $cache_path;

/**
* @var array<string, string>
*/
private static $mapping = [];

public static function afterAnalyzeFile(StatementsSource $statements_source, Context $file_context, FileStorage $file_storage, Codebase $codebase): void
{
if (null === self::$cache_path || 0 !== strpos($file_storage->file_path, self::$cache_path)) {
return;
}

$rawSource = file_get_contents($file_storage->file_path);
if (!preg_match('/'.self::CACHED_TEMPLATE_HEADER_PATTERN.'/m', $rawSource, $matchingParts)) {
return;
}

/** @var string|null $cacheClassName */
[$cacheClassName] = array_values($file_storage->classlikes_in_file);
if (null === $cacheClassName) {
return;
}

self::registerNewCache($cacheClassName, $matchingParts[1]);
}

public static function setCachePath(string $cache_path): void
{
static::$cache_path = $cache_path;
}

private static function registerNewCache(string $cacheClassName, string $templateName): void
{
static::$mapping[$templateName] = $cacheClassName;
}

public static function getCacheClassName(string $templateName): string
{
if (!array_key_exists($templateName, static::$mapping)) {
throw new RuntimeException(sprintf('The template %s was not found.', $templateName));
}

return static::$mapping[$templateName];
}
}
Loading