VendorHardeningPlugin.php

Same filename in other branches
  1. 9 composer/Plugin/VendorHardening/VendorHardeningPlugin.php
  2. 8.9.x composer/Plugin/VendorHardening/VendorHardeningPlugin.php
  3. 10 composer/Plugin/VendorHardening/VendorHardeningPlugin.php

Namespace

Drupal\Composer\Plugin\VendorHardening

File

composer/Plugin/VendorHardening/VendorHardeningPlugin.php

View source
<?php

declare (strict_types=1);
namespace Drupal\Composer\Plugin\VendorHardening;

use Composer\Composer;
use Composer\EventDispatcher\EventSubscriberInterface;
use Composer\Installer\PackageEvent;
use Composer\Installer\PackageEvents;
use Composer\IO\IOInterface;
use Composer\Package\BasePackage;
use Composer\Package\PackageInterface;
use Composer\Plugin\PluginInterface;
use Composer\Script\Event;
use Composer\Script\ScriptEvents;
use Composer\Util\Filesystem;

/**
 * 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
 *
 * @internal
 */
class VendorHardeningPlugin implements PluginInterface, EventSubscriberInterface {
    
    /**
     * Composer object.
     *
     * @var \Composer\Composer
     */
    protected $composer;
    
    /**
     * IO object.
     *
     * @var \Composer\IO\IOInterface
     */
    protected $io;
    
    /**
     * Configuration.
     *
     * @var \Drupal\Composer\Plugin\VendorHardening\Config
     */
    protected $config;
    
    /**
     * List of projects already cleaned.
     *
     * @var string[]
     */
    protected $packagesAlreadyCleaned = [];
    
    /**
     * {@inheritdoc}
     */
    public function activate(Composer $composer, IOInterface $io) : void {
        $this->composer = $composer;
        $this->io = $io;
        // Set up configuration.
        $this->config = new Config($this->composer
            ->getPackage());
    }
    
    /**
     * {@inheritdoc}
     */
    public function deactivate(Composer $composer, IOInterface $io) : void {
    }
    
    /**
     * {@inheritdoc}
     */
    public function uninstall(Composer $composer, IOInterface $io) : void {
    }
    
    /**
     * {@inheritdoc}
     */
    public static function getSubscribedEvents() : array {
        return [
            ScriptEvents::POST_AUTOLOAD_DUMP => 'onPostAutoloadDump',
            ScriptEvents::POST_UPDATE_CMD => 'onPostCmd',
            ScriptEvents::POST_INSTALL_CMD => 'onPostCmd',
            PackageEvents::PRE_PACKAGE_INSTALL => 'onPrePackageInstall',
            PackageEvents::PRE_PACKAGE_UPDATE => 'onPrePackageUpdate',
            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) : void {
        $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) : void {
        $this->cleanAllPackages();
    }
    
    /**
     * PRE_PACKAGE_INSTALL event handler.
     *
     * @param \Composer\Installer\PackageEvent $event
     *   The package event.
     */
    public function onPrePackageInstall(PackageEvent $event) : void {
        
        /** @var \Composer\Package\CompletePackage $package */
        $package = $event->getOperation()
            ->getPackage();
        $this->removeBinBeforeCleanup($package);
    }
    
    /**
     * PRE_PACKAGE_UPDATE event handler.
     *
     * @param \Composer\Installer\PackageEvent $event
     *   The package event.
     */
    public function onPrePackageUpdate(PackageEvent $event) : void {
        
        /** @var \Composer\Package\CompletePackage $package */
        $package = $event->getOperation()
            ->getTargetPackage();
        $this->removeBinBeforeCleanup($package);
    }
    
    /**
     * POST_PACKAGE_INSTALL event handler.
     *
     * @param \Composer\Installer\PackageEvent $event
     *   The package event.
     */
    public function onPostPackageInstall(PackageEvent $event) : void {
        $this->cleanPackage($event->getOperation()
            ->getPackage());
    }
    
    /**
     * POST_PACKAGE_UPDATE event handler.
     *
     * @param \Composer\Installer\PackageEvent $event
     *   The package event.
     */
    public function onPostPackageUpdate(PackageEvent $event) : void {
        $this->cleanPackage($event->getOperation()
            ->getTargetPackage());
    }
    
    /**
     * Remove bin config for packages that would have the bin file removed.
     *
     * Where the configured bin files are in the directories to be removed, remove
     * the bin config.
     *
     * @param \Composer\Package\BasePackage $package
     *   The package we're cleaning up.
     */
    protected function removeBinBeforeCleanup(BasePackage $package) : void {
        // We can process AliasPackage and Package objects, and they share the
        // BasePackage parent class. However, since there is no common interface for
        // these package types that allow for the setBinaries() method, and since
        // BasePackage does not include the setBinaries() method, we have to make
        // sure we're processing a class with a setBinaries() method.
        if (!method_exists($package, 'setBinaries')) {
            return;
        }
        $binaries = $package->getBinaries();
        $clean_paths = $this->config
            ->getPathsForPackage($package->getName());
        // Only do this if there are binaries and cleanup paths.
        if (!$binaries || !$clean_paths) {
            return;
        }
        if ($unset_these_binaries = $this->findBinOverlap($binaries, $clean_paths)) {
            $this->io
                ->writeError(sprintf('%sModifying bin config for <info>%s</info> which overlaps with cleanup directories.', str_repeat(' ', 4), $package->getName()), TRUE, IOInterface::VERBOSE);
            $modified_binaries = [];
            foreach ($binaries as $binary) {
                if (!in_array($binary, $unset_these_binaries)) {
                    $modified_binaries[] = $binary;
                }
            }
            $package->setBinaries($modified_binaries);
        }
    }
    
    /**
     * Find bin files which are inside cleanup directories.
     *
     * @param string[] $binaries
     *   'Bin' configuration from the package we're cleaning up.
     * @param string[] $clean_paths
     *   The paths we're cleaning up.
     *
     * @return string[]
     *   Bin files to remove, with the file as both the key and the value.
     */
    protected function findBinOverlap(array $binaries, array $clean_paths) : array {
        // Make a filesystem model to explore. This is a keyed array that looks like
        // all the places that will be removed by cleanup. 'tests/src' becomes
        // $filesystem['tests']['src'] = TRUE;
        $filesystem = [];
        foreach ($clean_paths as $clean_path) {
            $clean_pieces = explode("/", $clean_path);
            // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis.UnusedVariable
            $current =& $filesystem;
            foreach ($clean_pieces as $clean_piece) {
                $current =& $current[$clean_piece];
            }
            $current = TRUE;
        }
        // Explore the filesystem with our bin config.
        $unset_these_binaries = [];
        foreach ($binaries as $binary) {
            $binary_pieces = explode('/', $binary);
            $current =& $filesystem;
            foreach ($binary_pieces as $binary_piece) {
                if (!isset($current[$binary_piece])) {
                    break;
                }
                else {
                    // Value of TRUE means we're at the end of the path.
                    if ($current[$binary_piece] === TRUE) {
                        $unset_these_binaries[$binary] = $binary;
                        break;
                    }
                }
                $current =& $filesystem[$binary_piece];
            }
        }
        return $unset_these_binaries;
    }
    
    /**
     * Gets a list of all installed packages from Composer.
     *
     * @return \Composer\Package\PackageInterface[]
     *   The list of installed packages.
     */
    protected function getInstalledPackages() : array {
        return $this->composer
            ->getRepositoryManager()
            ->getLocalRepository()
            ->getPackages();
    }
    
    /**
     * Gets the installed path for a package.
     *
     * @param \Composer\Package\PackageInterface $package
     *   The package.
     *
     * @return string
     *   Path to the install path for the package, relative to the project. This
     *   accounts for changes made by composer/installers, if any.
     */
    protected function getInstallPathForPackage(PackageInterface $package) : string {
        return $this->composer
            ->getInstallationManager()
            ->getInstallPath($package);
    }
    
    /**
     * Clean all configured packages.
     *
     * This applies in the context of a post-command event.
     */
    public function cleanAllPackages() : void {
        // 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;
        }
        $all_cleanup_paths = $this->config
            ->getAllCleanupPaths();
        // Get all the packages that we should clean up but haven't already.
        $cleanup_paths = array_diff_key($all_cleanup_paths, $this->packagesAlreadyCleaned);
        // Get all the packages that are installed that we should clean up.
        $packages_to_be_cleaned = array_intersect_key($cleanup_paths, $installed_packages);
        if (!$packages_to_be_cleaned) {
            $this->io
                ->writeError('<info>Packages already clean.</info>');
            return;
        }
        $this->io
            ->writeError('<info>Cleaning installed packages.</info>');
        foreach ($packages_to_be_cleaned as $package_name => $paths) {
            $this->cleanPathsForPackage($installed_packages[$package_name], $all_cleanup_paths[$package_name]);
        }
    }
    
    /**
     * Clean a single package.
     *
     * This applies in the context of a package post-install or post-update event.
     *
     * @param \Composer\Package\PackageInterface $package
     *   The package to clean.
     */
    public function cleanPackage(PackageInterface $package) : void {
        // Normalize package names to lower case.
        $package_name = strtolower($package->getName());
        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($package, $paths_for_package);
        }
    }
    
    /**
     * Clean the installed directories for a named package.
     *
     * @param \Composer\Package\PackageInterface $package
     *   The package to clean.
     * @param string[] $paths_for_package
     *   List of directories in $package_name to remove
     */
    protected function cleanPathsForPackage(PackageInterface $package, $paths_for_package) : void {
        // Whatever happens here, this package counts as cleaned so that we don't
        // process it more than once.
        $package_name = strtolower($package->getName());
        $this->packagesAlreadyCleaned[$package_name] = TRUE;
        $package_dir = $this->getInstallPathForPackage($package);
        if (!is_dir($package_dir)) {
            return;
        }
        $this->io
            ->writeError(sprintf('%sCleaning paths 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 (!file_exists($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>Path '%s' does not exist.</comment>", str_repeat(' ', 6), $cleanup_path), TRUE, IOInterface::VERY_VERBOSE);
                continue;
            }
            if (!$fs->remove($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 path '%s'</error> in package <comment>%s</comment>.", str_repeat(' ', 6), $cleanup_item, $package_name), TRUE, IOInterface::NORMAL);
                continue;
            }
            $this->io
                ->writeError(sprintf("%sRemoving path <info>'%s'</info>", str_repeat(' ', 4), $cleanup_item), TRUE, IOInterface::VERBOSE);
        }
    }
    
    /**
     * Place .htaccess file into the vendor directory.
     *
     * @param string $vendor_dir
     *   Path to vendor directory.
     */
    public function writeAccessRestrictionFiles(string $vendor_dir) : void {
        $this->io
            ->writeError('<info>Hardening vendor directory with .htaccess file.</info>');
        // Prevent access to vendor directory on Apache servers.
        FileSecurity::writeHtaccess($vendor_dir, TRUE);
    }

}

Classes

Title Deprecated Summary
VendorHardeningPlugin A Composer plugin to clean out your project's vendor directory.

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.