Commit a42b1c5e authored by larowlan's avatar larowlan

Issue #3057094 by Mile23, hussainweb, greg.1.anderson, Mixologic, alexpott,...

Issue #3057094 by Mile23, hussainweb, greg.1.anderson, Mixologic, alexpott, catch, bojanz, andypost, leolando.tan: Add Composer vendor/ hardening plugin to core
parent 3003cc65
<?php
namespace Drupal\Composer\Plugin\VendorHardening;
use Composer\Package\RootPackageInterface;
/**
* Determine configuration.
*
* Default configuration is merged with the root package's
* extra:drupal-core-vendor-hardening configuration.
*/
class Config {
/**
* The default configuration which will always be merged with user config.
*
* @var array
*/
protected static $defaultConfig = [
'behat/mink' => ['tests', 'driver-testsuite'],
'behat/mink-browserkit-driver' => ['tests'],
'behat/mink-goutte-driver' => ['tests'],
'behat/mink-selenium2-driver' => ['tests'],
'brumann/polyfill-unserialize' => ['tests'],
'composer/composer' => ['bin'],
'drupal/coder' => [
'coder_sniffer/Drupal/Test',
'coder_sniffer/DrupalPractice/Test',
],
'doctrine/cache' => ['tests'],
'doctrine/collections' => ['tests'],
'doctrine/common' => ['tests'],
'doctrine/inflector' => ['tests'],
'doctrine/instantiator' => ['tests'],
'easyrdf/easyrdf' => ['scripts'],
'egulias/email-validator' => ['documentation', 'tests'],
'fabpot/goutte' => ['Goutte/Tests'],
'guzzlehttp/promises' => ['tests'],
'guzzlehttp/psr7' => ['tests'],
'instaclick/php-webdriver' => ['doc', 'test'],
'jcalderonzumba/gastonjs' => ['docs', 'examples', 'tests'],
'jcalderonzumba/mink-phantomjs-driver' => ['tests'],
'justinrainbow/json-schema' => ['demo'],
'masterminds/html5' => ['bin', 'test'],
'mikey179/vfsstream' => ['src/test'],
'myclabs/deep-copy' => ['doc'],
'paragonie/random_compat' => ['tests'],
'pear/archive_tar' => ['docs', 'tests'],
'pear/console_getopt' => ['tests'],
'pear/pear-core-minimal' => ['tests'],
'pear/pear_exception' => ['tests'],
'phar-io/manifest' => ['examples', 'tests'],
'phar-io/version' => ['tests'],
'phpdocumentor/reflection-docblock' => ['tests'],
'phpspec/prophecy' => ['fixtures', 'spec', 'tests'],
'phpunit/php-code-coverage' => ['tests'],
'phpunit/php-timer' => ['tests'],
'phpunit/php-token-stream' => ['tests'],
'phpunit/phpunit' => ['tests'],
'phpunit/phpunit-mock-objects' => ['tests'],
'sebastian/code-unit-reverse-lookup' => ['tests'],
'sebastian/comparator' => ['tests'],
'sebastian/diff' => ['tests'],
'sebastian/environment' => ['tests'],
'sebastian/exporter' => ['tests'],
'sebastian/global-state' => ['tests'],
'sebastian/object-enumerator' => ['tests'],
'sebastian/object-reflector' => ['tests'],
'sebastian/recursion-context' => ['tests'],
'seld/jsonlint' => ['tests'],
'squizlabs/php_codesniffer' => ['tests'],
'stack/builder' => ['tests'],
'symfony/browser-kit' => ['Tests'],
'symfony/class-loader' => ['Tests'],
'symfony/console' => ['Tests'],
'symfony/css-selector' => ['Tests'],
'symfony/debug' => ['Tests'],
'symfony/dependency-injection' => ['Tests'],
'symfony/dom-crawler' => ['Tests'],
'symfony/filesystem' => ['Tests'],
'symfony/finder' => ['Tests'],
'symfony/event-dispatcher' => ['Tests'],
'symfony/http-foundation' => ['Tests'],
'symfony/http-kernel' => ['Tests'],
'symfony/phpunit-bridge' => ['Tests'],
'symfony/process' => ['Tests'],
'symfony/psr-http-message-bridge' => ['Tests'],
'symfony/routing' => ['Tests'],
'symfony/serializer' => ['Tests'],
'symfony/translation' => ['Tests'],
'symfony/validator' => ['Tests', 'Resources'],
'symfony/yaml' => ['Tests'],
'symfony-cmf/routing' => ['Test', 'Tests'],
'theseer/tokenizer' => ['tests'],
'twig/twig' => ['doc', 'ext', 'test'],
'zendframework/zend-escaper' => ['doc'],
'zendframework/zend-feed' => ['doc'],
'zendframework/zend-stdlib' => ['doc'],
];
/**
* The root package.
*
* @var Composer\Package\RootPackageInterface
*/
protected $rootPackage;
/**
* Configuration gleaned from the root package.
*
* @var array
*/
protected $configData = [];
/**
* Construct a Config object.
*
* @param \Composer\Package\RootPackageInterface $root_package
* Composer package object for the root package.
*/
public function __construct(RootPackageInterface $root_package) {
$this->rootPackage = $root_package;
}
/**
* Gets the configured list of directories to remove from the root package.
*
* This is stored in composer.json extra:drupal-core-vendor-hardening.
*
* @return array[]
* An array keyed by package name. Each array value is an array of paths,
* relative to the package.
*/
public function getAllCleanupPaths() {
if ($this->configData) {
return $this->configData;
}
// Get the root package config.
$package_config = $this->rootPackage->getExtra();
if (isset($package_config['drupal-core-vendor-hardening'])) {
$this->configData = array_change_key_case($package_config['drupal-core-vendor-hardening'], CASE_LOWER);
}
// Ensure the values are arrays.
$this->configData = array_map(function ($paths) {
return (array) $paths;
}, $this->configData);
// Merge root config with defaults.
foreach (array_change_key_case(static::$defaultConfig, CASE_LOWER) as $package => $paths) {
$this->configData[$package] = array_merge(
isset($this->configData[$package]) ? $this->configData[$package] : [],
$paths);
}
return $this->configData;
}
/**
* Get a list of paths to remove for the given package.
*
* @param string $package
* The package name.
*
* @return string[]
* Array of paths to remove, relative to the package.
*/
public function getPathsForPackage($package) {
$package = strtolower($package);
$paths = $this->getAllCleanupPaths();
return isset($paths[$package]) ? $paths[$package] : [];
}
}
This diff is collapsed.
The Drupal Vendor Hardening Composer Plugin
===========================================
Thanks for using this Drupal component.
You can participate in its development on Drupal.org, through our issue system:
https://www.drupal.org/project/issues/drupal
You can get the full Drupal repo here:
https://www.drupal.org/project/drupal/git-instructions
You can browse the full Drupal repo here:
http://cgit.drupalcode.org/drupal
What does it do?
----------------
This Composer plugin does two things:
1) It removes extraneous directories from the project's vendor directory.
They're typically directories which might contain executable files, such as test
directories.
This sort of processing is required for projects that have a vendor directory
inside the HTTP server docroot. This is a common layout for Drupal.
By default, the plugin knows how to clean up packages for Drupal core, so you
can require drupal/core-vendor-hardening in your project and the rest will
happen automatically.
The plugin can also be configured to clean up additional packages using the
project's composer.json extra field.
2) The plugin also adds .htaccess and web.config files to the root of the
project's vendor directory. These files will do due diligence to keep the web
server from serving files from within the vendor directory.
How do I set it up?
-------------------
Require this Composer plugin into your project:
composer require drupal/core-vendor-hardening
When you install or update, this plugin will look through each package and
remove directories it knows about.
You can see the list of default package cleanups for this plugin in Config.php.
If you discover that this list needs updating, please file an issue about it:
https://www.drupal.org/project/issues/drupal
In addition to the default list of packages, you can configure the plugin using
the root package's composer.json extra field, like this:
"extra": {
"drupal-core-vendor-hardening": {
"vendor/package": ["test", "documentation"]
}
}
The above code will tell the plugin to remove the test/ and documentation/
directories from the 'vendor/package' package when it is installed or updated.
HOW-TO: Test this Drupal component
In order to test this component, you'll need to get the entire Drupal repo and
run the tests there.
You'll find the tests under core/tests/Drupal/Tests/Plugin.
You can get the full Drupal repo here:
https://www.drupal.org/project/drupal/git-instructions
You can find more information about running PHPUnit tests with Drupal here:
https://www.drupal.org/node/2116263
Each component in the Drupal\Composer namespace has its own annotated test
group. You can use this group to run only the tests for this component. Like
this:
$ ./vendor/bin/phpunit -c core --group VendorHardening
<?php
namespace Drupal\Composer\Plugin\VendorHardening;
use Composer\Composer;
use Composer\EventDispatcher\EventSubscriberInterface;
use Composer\Installer\PackageEvent;
use Composer\IO\IOInterface;
use Composer\Plugin\PluginInterface;
use Composer\Script\ScriptEvents;
use Composer\Util\Filesystem;
use Composer\Script\Event;
use Composer\Installer\PackageEvents;
use Drupal\Component\FileSecurity\FileSecurity;
/**
* A Composer plugin to clean out your project's vendor directory.
*
* This plugin will remove directory paths within installed packages. You might
* use this in order to mitigate the security risks of having your vendor
* directory within an HTTP server's docroot.
*
* @see https://www.drupal.org/docs/develop/using-composer/using-drupals-vendor-cleanup-composer-plugin
*/
class VendorHardeningPlugin implements PluginInterface, EventSubscriberInterface {
/**
* Composer object.
*
* @var \Composer\Composer
*/
protected $composer;
/**
* IO object.
*
* @var \Composer\IO\IOInterface
*/
protected $io;
/**
* Configuration.
*
* @var \Drupal\Composer\VendorHardening\Config
*/
protected $config;
/**
* List of projects already cleaned
*
* @var string[]
*/
protected $packagesAlreadyCleaned = [];
/**
* {@inheritdoc}
*/
public function activate(Composer $composer, IOInterface $io) {
$this->composer = $composer;
$this->io = $io;
// Set up configuration.
$this->config = new Config($this->composer->getPackage());
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
return [
ScriptEvents::POST_AUTOLOAD_DUMP => 'onPostAutoloadDump',
ScriptEvents::POST_UPDATE_CMD => 'onPostCmd',
ScriptEvents::POST_INSTALL_CMD => 'onPostCmd',
PackageEvents::POST_PACKAGE_INSTALL => 'onPostPackageInstall',
PackageEvents::POST_PACKAGE_UPDATE => 'onPostPackageUpdate',
];
}
/**
* POST_AUTOLOAD_DUMP event handler.
*
* @param \Composer\Script\Event $event
* The Composer event.
*/
public function onPostAutoloadDump(Event $event) {
$this->writeAccessRestrictionFiles($this->composer->getConfig()->get('vendor-dir'));
}
/**
* POST_UPDATE_CMD and POST_INSTALL_CMD event handler.
*
* @param \Composer\Script\Event $event
* The Composer event.
*/
public function onPostCmd(Event $event) {
$this->cleanAllPackages($this->composer->getConfig()->get('vendor-dir'));
}
/**
* POST_PACKAGE_INSTALL event handler.
*
* @param \Composer\Installer\PackageEvent $event
*/
public function onPostPackageInstall(PackageEvent $event) {
/** @var \Composer\Package\CompletePackage $package */
$package = $event->getOperation()->getPackage();
$package_name = $package->getName();
$this->cleanPackage($this->composer->getConfig()->get('vendor-dir'), $package_name);
}
/**
* POST_PACKAGE_UPDATE event handler.
*
* @param \Composer\Installer\PackageEvent $event
*/
public function onPostPackageUpdate(PackageEvent $event) {
/** @var \Composer\Package\CompletePackage $package */
$package = $event->getOperation()->getTargetPackage();
$package_name = $package->getName();
$this->cleanPackage($this->composer->getConfig()->get('vendor-dir'), $package_name);
}
/**
* Gets a list of all installed packages from Composer.
*
* @return \Composer\Package\PackageInterface[]
* The list of installed packages.
*/
protected function getInstalledPackages() {
return $this->composer->getRepositoryManager()->getLocalRepository()->getPackages();
}
/**
* Clean all configured packages.
*
* This applies in the context of a post-command event.
*
* @param string $vendor_dir
* Path to vendor directory
*/
public function cleanAllPackages($vendor_dir) {
// Get a list of all the packages available after the update or install
// command.
$installed_packages = [];
foreach ($this->getInstalledPackages() as $package) {
// Normalize package names to lower case.
$installed_packages[strtolower($package->getName())] = $package;
}
// Get all the packages that we should clean up but haven't already.
$cleanup_packages = array_diff_key($this->config->getAllCleanupPaths(), $this->packagesAlreadyCleaned);
// Get all the packages that are installed that we should clean up.
$packages_to_be_cleaned = array_intersect_key($cleanup_packages, $installed_packages);
if (!$packages_to_be_cleaned) {
$this->io->writeError('<info>Vendor directory already clean.</info>');
return;
}
$this->io->writeError('<info>Cleaning vendor directory.</info>');
foreach ($packages_to_be_cleaned as $package_name => $paths_for_package) {
$this->cleanPathsForPackage($vendor_dir, $package_name, $paths_for_package);
}
}
/**
* Clean a single package.
*
* This applies in the context of a package post-install or post-update event.
*
* @param string $vendor_dir
* Path to vendor directory
* @param string $package_name
* Name of the package to clean
*/
public function cleanPackage($vendor_dir, $package_name) {
// Normalize package names to lower case.
$package_name = strtolower($package_name);
if (isset($this->packagesAlreadyCleaned[$package_name])) {
$this->io->writeError(sprintf('%s<info>%s</info> already cleaned.', str_repeat(' ', 4), $package_name), TRUE, IOInterface::VERY_VERBOSE);
return;
}
$paths_for_package = $this->config->getPathsForPackage($package_name);
if ($paths_for_package) {
$this->io->writeError(sprintf('%sCleaning: <info>%s</info>', str_repeat(' ', 4), $package_name));
$this->cleanPathsForPackage($vendor_dir, $package_name, $paths_for_package);
}
}
/**
* Clean the installed directories for a named package.
*
* @param string $vendor_dir
* Path to vendor directory.
* @param string $package_name
* Name of package to sanitize.
* @param string $paths_for_package
* List of directories in $package_name to remove
*/
protected function cleanPathsForPackage($vendor_dir, $package_name, $paths_for_package) {
// Whatever happens here, this package counts as cleaned so that we don't
// process it more than once.
$this->packagesAlreadyCleaned[$package_name] = TRUE;
$package_dir = $vendor_dir . '/' . $package_name;
if (!is_dir($package_dir)) {
return;
}
$this->io->writeError(sprintf('%sCleaning directories in <comment>%s</comment>', str_repeat(' ', 4), $package_name), TRUE, IOInterface::VERY_VERBOSE);
$fs = new Filesystem();
foreach ($paths_for_package as $cleanup_item) {
$cleanup_path = $package_dir . '/' . $cleanup_item;
if (!is_dir($cleanup_path)) {
// If the package has changed or the --prefer-dist version does not
// include the directory. This is not an error.
$this->io->writeError(sprintf("%s<comment>Directory '%s' does not exist.</comment>", str_repeat(' ', 6), $cleanup_path), TRUE, IOInterface::VERY_VERBOSE);
continue;
}
if (!$fs->removeDirectory($cleanup_path)) {
// Always display a message if this fails as it means something
// has gone wrong. Therefore the message has to include the
// package name as the first informational message might not
// exist.
$this->io->writeError(sprintf("%s<error>Failure removing directory '%s'</error> in package <comment>%s</comment>.", str_repeat(' ', 6), $cleanup_item, $package_name), TRUE, IOInterface::NORMAL);
continue;
}
$this->io->writeError(sprintf("%sRemoving directory <info>'%s'</info>", str_repeat(' ', 4), $cleanup_item), TRUE, IOInterface::VERBOSE);
}
}
/**
* Place .htaccess and web.config files into the vendor directory.
*
* @param string $vendor_dir
* Path to vendor directory.
*/
public function writeAccessRestrictionFiles($vendor_dir) {
$this->io->writeError('<info>Hardening vendor directory with .htaccess and web.config files.</info>');
// Prevent access to vendor directory on Apache servers.
FileSecurity::writeHtaccess($vendor_dir, TRUE);
// Prevent access to vendor directory on IIS servers.
FileSecurity::writeWebConfig($vendor_dir);
}
}
{
"name": "drupal/core-vendor-hardening",
"description": "Hardens the vendor directory for when it's in the docroot.",
"keywords": ["drupal"],
"homepage": "https://www.drupal.org/project/drupal",
"license": "GPL-2.0-or-later",
"type": "composer-plugin",
"autoload": {
"psr-4": {
"Drupal\\Composer\\Plugin\\VendorHardening\\": "."
}
},
"extra": {
"class": "Drupal\\Composer\\Plugin\\VendorHardening\\VendorHardeningPlugin"
},
"require": {
"php": ">=7.0.8",
"composer-plugin-api": "^1.1",
"drupal/core-file-security": "^8.8"
}
}
<?php
namespace Drupal\Tests\Composer\Plugin\VendorHardening;
use Composer\Package\RootPackageInterface;
use Drupal\Composer\Plugin\VendorHardening\Config;
use PHPUnit\Framework\TestCase;
/**
* @coversDefaultClass Drupal\Composer\Plugin\VendorHardening\Config
* @group VendorHardening
*/
class ConfigTest extends TestCase {
/**
* @covers ::getPathsForPackage
*/
public function testGetPathsForPackageMixedCase() {
$config = $this->getMockBuilder(Config::class)
->setMethods(['getAllCleanupPaths'])
->disableOriginalConstructor()
->getMock();
$config->expects($this->once())
->method('getAllCleanupPaths')
->willReturn(['package' => ['path']]);
$this->assertSame(['path'], $config->getPathsForPackage('pACKage'));
}
/**
* @covers ::getAllCleanupPaths
*/
public function testNoRootMergeConfig() {
// Root package has no extra field.
$root = $this->getMockBuilder(RootPackageInterface::class)
->setMethods(['getExtra'])
->getMockForAbstractClass();
$root->expects($this->once())
->method('getExtra')
->willReturn([]);
$config = new Config($root);
$ref_default = new \ReflectionProperty($config, 'defaultConfig');
$ref_default->setAccessible(TRUE);
$ref_plugin_config = new \ReflectionMethod($config, 'getAllCleanupPaths');
$ref_plugin_config->setAccessible(TRUE);
$this->assertEquals(
$ref_default->getValue($config), $ref_plugin_config->invoke($config)
);
}
/**
* @covers ::getAllCleanupPaths
*/
public function testRootMergeConfig() {
// Root package has configuration in extra.
$root = $this->getMockBuilder(RootPackageInterface::class)
->setMethods(['getExtra'])
->getMockForAbstractClass();
$root->expects($this->once())
->method('getExtra')
->willReturn([
'drupal-core-vendor-hardening' => [
'isa/string' => 'test_dir',
'an/array' => ['test_dir', 'doc_dir'],
],
]);
$config = new Config($root);
$ref_plugin_config = new \ReflectionMethod($config, 'getAllCleanupPaths');
$ref_plugin_config->setAccessible(TRUE);
$plugin_config = $ref_plugin_config->invoke($config);
$this->assertArraySubset([
'isa/string' => ['test_dir'],
'an/array' => ['test_dir', 'doc_dir'],
], $plugin_config);
}
/**
* @covers ::getAllCleanupPaths
*/
public function testMixedCaseConfigCleanupPackages() {
// Root package has configuration in extra.
$root = $this->getMockBuilder(RootPackageInterface::class)
->setMethods(['getExtra'])
->getMockForAbstractClass();
$root->expects($this->once())
->method('getExtra')
->willReturn([
'drupal-core-vendor-hardening' => [
'NotMikey179/vfsStream' => ['src/test'],
],
]);
$config = new Config($root);
$ref_plugin_config = new \ReflectionMethod($config, 'getAllCleanupPaths');
$ref_plugin_config->setAccessible(TRUE);
// Put some mixed-case in the defaults.
$ref_default = new \ReflectionProperty($config, 'defaultConfig');
$ref_default->setAccessible(TRUE);
$ref_default->setValue($config, [
'BeHatted/Mank' => ['tests'],
'SymFunic/HTTPFoundational' => ['src'],
]);
$plugin_config = $ref_plugin_config->invoke($config);
foreach (array_keys($plugin_config) as $package_name) {
$this->assertNotRegExp('/[A-Z]/', $package_name);
}
}
}
<?php
namespace Drupal\Tests\Composer\Plugin\VendorHardening;
use Composer\Composer;
use Composer\IO\IOInterface;
use Composer\Package\PackageInterface;
use Composer\Package\RootPackageInterface;
use Drupal\Composer\VendorHardening\Config;
use Drupal\Composer\Plugin\VendorHardening\VendorHardeningPlugin;
use org\bovigo\vfs\vfsStream;
use PHPUnit\Framework\TestCase;
/**
* @coversDefaultClass Drupal\Composer\Plugin\VendorHardening\VendorHardeningPlugin
* @group VendorHardening
*/
class VendorHardeningPluginTest extends TestCase {
public function setUp() {
parent::setUp();
vfsStream::setup('vendor', NULL, [
'drupal' => [
'package' => [
'tests' => [
'SomeTest.php' => '<?php',
],
],
],
]);
}
/**
* @covers ::cleanPackage
*/
public function testCleanPackage() {
$config = $this->getMockBuilder(Config::class)
->setMethods(['getPathsForPackage'])
->disableOriginalConstructor()
->getMock();
$config->expects($this->once())
->method('getPathsForPackage')
->willReturn(['tests']);
$plugin = new VendorHardeningPlugin();
$ref_config = new \ReflectionProperty($plugin, 'config');
$ref_config->setAccessible(TRUE);
$ref_config->setValue($plugin, $config);
$io = $this->prophesize(IOInterface::class);
$ref_io = new \ReflectionProperty($plugin, 'io');
$ref_io->setAccessible(TRUE);
$ref_io->setValue($plugin, $io->reveal());