Search code examples
phpcomposer-phpgit-tag

How does composer.lock protects your project of malicious dependencies


In my project I check in the composer.lock file on github. Suppose I require a healthy dependency in the composer.json like:

"require": {
    "foo/bar": "v3.0"
  },

After I call composer install a composer.lock file is created.

"packages": [
        {
            "name": "foo/bar",
            "version": "v3.0",
            "source": {
                "type": "git",
                "url": "https://github.com/foo/bar.git",
                "reference": "bbafb0edb791b23220563d113d00371ea42aedaa"
            },
            "type": "project",
            "license": [
                "MIT"
            ],
            "authors": [
                {
                    "name": "Mr.Foo",
                    "email": "mr.foo@bar.de"
                }
            ],
            "time": "2019-09-30T12:13:55+00:00"
        }

Suppose an attacker owning the foo/bar repository would delete the v3.0 tag. The attacker would than name a different commit to v3.0. Can someone confirm that composer install will always check composer.lock installing dependencies? If I run a composer install without a composer.lock file composer will create a new .lock file with a new reference (commit id). If I run composer install with a composer.lock file composer will stick to the commit id ("reference": "bbafb0edb791b23220563d113d00371ea42aedaa" , old v3.0). Composer would not load the malicious fake v3.0. The v3.0 points to a new commit id on github.

Can someone confirm that composer.lock`s reference tag has a "higher priority" than the version tag? Does composer protects my project completely from those kinds of attacks?


Solution

  • TL;DR;

    Definitely, the answer to your question is:

    Yes, composer will protect you

    Either it will install the packages based on the commit hash stated in your composer.lock, if it exists on the repository, just ignoring the mismatch between the commit and the version, either it will fail with a pretty straight forwardly expressed reason: "history was rewritten?"


    The question did pique my curiosity: I would have said yes, because otherwise, locking the commit hash in the lock file would be useless, but I had to test it for the sake of correctness.

    So here is what I did:

    1. There is the "obvious" scenario where the attacker would really rewrite history and delete the commit that had the tag matching your composer.json constraint
    2. On a second thought, there is also the case where the attacker would keep the commit that was tagged as your constraint, but would add a newer commit and retag the new commit with the tag matching your constraint

    First scenario:

    I installed a basic package, to some specific version (not the latest, just to have a version constraint):

    $ composer require psr/log:1.0.0
    

    Which made me end up with this pretty simple composer.json

    {
        "require": {
            "psr/log": "1.0.0"
        }
    }
    

    And this composer.lock

    {
        "_readme": [
            "This file locks the dependencies of your project to a known state",
            "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
            "This file is @generated automatically"
        ],
        "content-hash": "2865f724e23cffb23b3afd3a968e0359",
        "packages": [
            {
                "name": "psr/log",
                "version": "1.0.0",
                "source": {
                    "type": "git",
                    "url": "https://github.com/php-fig/log.git",
                    "reference": "fe0936ee26643249e916849d48e3a51d5f5e278b"
                },
                "dist": {
                    "type": "zip",
                    "url": "https://api.github.com/repos/php-fig/log/zipball/fe0936ee26643249e916849d48e3a51d5f5e278b",
                    "reference": "fe0936ee26643249e916849d48e3a51d5f5e278b",
                    "shasum": ""
                },
                "type": "library",
                "autoload": {
                    "psr-0": {
                        "Psr\\Log\\": ""
                    }
                },
                "notification-url": "https://packagist.org/downloads/",
                "license": [
                    "MIT"
                ],
                "authors": [
                    {
                        "name": "PHP-FIG",
                        "homepage": "http://www.php-fig.org/"
                    }
                ],
                "description": "Common interface for logging libraries",
                "keywords": [
                    "log",
                    "psr",
                    "psr-3"
                ],
                "time": "2012-12-21T11:40:51+00:00"
            }
        ],
        "packages-dev": [],
        "aliases": [],
        "minimum-stability": "stable",
        "stability-flags": [],
        "prefer-stable": false,
        "prefer-lowest": false,
        "platform": [],
        "platform-dev": []
    }
    

    Then to test it, I just altered the commit hash fe0936ee26643249e916849d48e3a51d5f5e278b everywhere I would find it in the composer.lock by one character: fe0936ee26643249e916849d48e3a51d5f5e278c (the last b became a c); ending with this composer.lock:

    {
        "_readme": [
            "This file locks the dependencies of your project to a known state",
            "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
            "This file is @generated automatically"
        ],
        "content-hash": "2865f724e23cffb23b3afd3a968e0359",
        "packages": [
            {
                "name": "psr/log",
                "version": "1.0.0",
                "source": {
                    "type": "git",
                    "url": "https://github.com/php-fig/log.git",
                    "reference": "fe0936ee26643249e916849d48e3a51d5f5e278c"
                },
                "dist": {
                    "type": "zip",
                    "url": "https://api.github.com/repos/php-fig/log/zipball/fe0936ee26643249e916849d48e3a51d5f5e278c",
                    "reference": "fe0936ee26643249e916849d48e3a51d5f5e278c",
                    "shasum": ""
                },
                "type": "library",
                "autoload": {
                    "psr-0": {
                        "Psr\\Log\\": ""
                    }
                },
                "notification-url": "https://packagist.org/downloads/",
                "license": [
                    "MIT"
                ],
                "authors": [
                    {
                        "name": "PHP-FIG",
                        "homepage": "http://www.php-fig.org/"
                    }
                ],
                "description": "Common interface for logging libraries",
                "keywords": [
                    "log",
                    "psr",
                    "psr-3"
                ],
                "time": "2012-12-21T11:40:51+00:00"
            }
        ],
        "packages-dev": [],
        "aliases": [],
        "minimum-stability": "stable",
        "stability-flags": [],
        "prefer-stable": false,
        "prefer-lowest": false,
        "platform": [],
        "platform-dev": []
    }
    

    Note that: if you try this in your browser as composer would later do it for you, you will end up with a 404 page: https://api.github.com/repos/php-fig/log/zipball/fe0936ee26643249e916849d48e3a51d5f5e278c

    I removed my vendor folder, for the sake of it:

    $ rm -Rf vendor
    

    Then, rerun a dependency installation, ending with this output:

    $ composer install
    Loading composer repositories with package information
    Installing dependencies (including require-dev) from lock file
    Package operations: 1 install, 0 updates, 0 removals
      - Installing psr/log (1.0.0): Downloading (0%)    Failed to download psr/log from dist: The "https://codeload.github.com/php-fig/log/legacy.zip/fe0936ee26643249e916849d48e3a51d5f5e278c" file could not be downloaded (HTTP/1.1 404 Not Found)
        Now trying to download from source
      - Installing psr/log (1.0.0): Cloning fe0936ee26 from cache
        fe0936ee26643249e916849d48e3a51d5f5e278c is gone (history was rewritten?)
    
    
      [RuntimeException]                                                                                                                  
      Failed to execute git checkout 'fe0936ee26643249e916849d48e3a51d5f5e278c' -- && git reset --hard 'fe0936ee26643249e916849d48e3a51d  
      5f5e278c' --                                                                                                                        
    
      fatal: reference is not a tree: fe0936ee26643249e916849d48e3a51d5f5e278c                                                            
    
    
    install [--prefer-source] [--prefer-dist] [--dry-run] [--dev] [--no-dev] [--no-custom-installers] [--no-autoloader] [--no-scripts] [--no-progress] [--no-suggest] [-v|vv|vvv|--verbose] [-o|--optimize-autoloader] [-a|--classmap-authoritative] [--apcu-autoloader] [--ignore-platform-reqs] [--] [<packages>]...
    

    If you only had one line to read out of this output, it would be:

    fe0936ee26643249e916849d48e3a51d5f5e278c is gone (history was rewritten?)

    Second scenario:

    This time, I did a little digging in the repository of php-fig/log to find the initial commit of the repository: https://github.com/php-fig/log/commit/a7ab552fdb2efb80aeca09da3bbd9335fc945ff0

    And, in the same fashion, I edited my composer.lock, but this time faking the fact that the initial commit of the repo is the one tagged 1.0.0, when it is obviously not.

    This got me this composer.lock

    {
        "_readme": [
            "This file locks the dependencies of your project to a known state",
            "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
            "This file is @generated automatically"
        ],
        "content-hash": "2865f724e23cffb23b3afd3a968e0359",
        "packages": [
            {
                "name": "psr/log",
                "version": "1.0.0",
                "source": {
                    "type": "git",
                    "url": "https://github.com/php-fig/log.git",
                    "reference": "a7ab552fdb2efb80aeca09da3bbd9335fc945ff0"
                },
                "dist": {
                    "type": "zip",
                    "url": "https://api.github.com/repos/php-fig/log/zipball/a7ab552fdb2efb80aeca09da3bbd9335fc945ff0",
                    "reference": "a7ab552fdb2efb80aeca09da3bbd9335fc945ff0",
                    "shasum": ""
                },
                "type": "library",
                "autoload": {
                    "psr-0": {
                        "Psr\\Log\\": ""
                    }
                },
                "notification-url": "https://packagist.org/downloads/",
                "license": [
                    "MIT"
                ],
                "authors": [
                    {
                        "name": "PHP-FIG",
                        "homepage": "http://www.php-fig.org/"
                    }
                ],
                "description": "Common interface for logging libraries",
                "keywords": [
                    "log",
                    "psr",
                    "psr-3"
                ],
                "time": "2012-12-21T11:40:51+00:00"
            }
        ],
        "packages-dev": [],
        "aliases": [],
        "minimum-stability": "stable",
        "stability-flags": [],
        "prefer-stable": false,
        "prefer-lowest": false,
        "platform": [],
        "platform-dev": []
    }
    

    Note that: trying it this time, would download the zip containing the repository state as it was at the initial commit: https://api.github.com/repos/php-fig/log/zipball/a7ab552fdb2efb80aeca09da3bbd9335fc945ff0

    Repeated the above deletion of the vendor folder

    $ rm -Rf vendor
    

    Cleared composer cache, also, this time, because, spoiler alert, the installation will succeed:

    $ composer clearcache && rm -Rf vendor
    Clearing cache (cache-vcs-dir): /tmp/cache/vcs
    Clearing cache (cache-repo-dir): /tmp/cache/repo
    Clearing cache (cache-files-dir): /tmp/cache/files
    Clearing cache (cache-dir): /tmp/cache
    All caches cleared.
    

    Then, rerun a dependency installation, ending with this output:

    $ composer install
    Loading composer repositories with package information
    Installing dependencies (including require-dev) from lock file
    Package operations: 1 install, 0 updates, 0 removals
      - Installing psr/log (1.0.0): Downloading (100%)         
    Generating autoload files
    

    Curious about the installation going too well to my likening, I re-run the process, more verbosely, to know what composer was really doing:

    $ rm -Rf vendor/ && composer clearcache && composer install -vvv
    Cache directory does not exist (cache-vcs-dir): 
    Clearing cache (cache-repo-dir): /tmp/cache/repo
    Clearing cache (cache-files-dir): /tmp/cache/files
    Clearing cache (cache-dir): /tmp/cache
    All caches cleared.
    Reading ./composer.json
    Loading config file ./composer.json
    Checked CA file /etc/ssl/certs/ca-certificates.crt: valid
    Executing command (/app): git branch --no-color --no-abbrev -v
    Executing command (/app): git describe --exact-match --tags
    Executing command (/app): git log --pretty="%H" -n1 HEAD
    Executing command (/app): hg branch
    Executing command (/app): fossil branch list
    Executing command (/app): fossil tag list
    Executing command (/app): svn info --xml
    Failed to initialize global composer: Composer could not find the config file: /tmp/composer.json
    To initialize a project, please create a composer.json file as described in the https://getcomposer.org/ "Getting Started" section
    Running 1.8.6 (2019-06-11 15:03:05) with PHP 7.3.8 on Linux / 4.9.184-linuxkit
    Reading ./composer.lock
    Loading composer repositories with package information
    Installing dependencies (including require-dev) from lock file
    Reading ./composer.lock
    Resolving dependencies through SAT
    Looking at all rules.
    
    Dependency resolution completed in 0.000 seconds
    Analyzed 43 packages to resolve dependencies
    Analyzed 43 rules to resolve dependencies
    Package operations: 1 install, 0 updates, 0 removals
    Installs: psr/log:1.0.0
      - Installing psr/log (1.0.0): Downloading https://api.github.com/repos/php-fig/log/zipball/a7ab552fdb2efb80aeca09da3bbd9335fc945ff0
    Downloading (connecting...)
    Following redirect (2) https://codeload.github.com/php-fig/log/legacy.zip/a7ab552fdb2efb80aeca09da3bbd9335fc945ff0
    Downloading https://codeload.github.com/php-fig/log/legacy.zip/a7ab552fdb2efb80aeca09da3bbd9335fc945ff0
    Downloading (100%)Writing /tmp/cache/files/psr/log/6e79f232da13c50e0fd07e74eb2d58c350e71a60.zip into cache from /app/vendor/psr/log/4ff496e542e24af2efd56eaf051e132b
    
     Extracting archiveExecuting command (CWD): unzip -qq  '/app/vendor/psr/log/4ff496e542e24af2efd56eaf051e132b' -d '/app/vendor/composer/9c2feb29'
        REASON: Required by the root package: Install command rule (install psr/log 1.0.0)
    
    Generating autoload files
    

    Where you can see it install the library at the commit hash a7ab552fdb2efb80aeca09da3bbd9335fc945ff0, trusting the composer.lock instructions to do so.

    - Installing psr/log (1.0.0): Downloading https://api.github.com/repos/php-fig/log/zipball/a7ab552fdb2efb80aeca09da3bbd9335fc945ff0

    There is a gotcha in the fact that, because the lock files says this commit is the version 1.0.0 it prompts me that it installed the package at that version, but this is a minor issue.