drupal/composer/Plugin/VendorHardening/VendorHardeningPlugin.php

251 lines
8.1 KiB
PHP

<?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;
/**
* 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);
}
}