Introduction

There’s been a lot of discussion recently around dependency confusion and supply chain based attack vectors. Most notably, this post outlines an effective campaign carried out at high scale. This post will cover some techniques for better managing your dependencies and ensuring you don’t fall victim to this type of attack.

Much like my other posts on software security, this will focus on rigor and discipline in your software development process, but can be assisted, and sometimes automated, by freely available tools. This post is going to cover Maven based Java projects, but the ideas and tooling have parallels in most major languages with first class dependency management tooling.

Getting a look at your dependency graph

In my previous post, I discussed some basic dependency management habits. I also split the refactoring into its own library. I’ll use this repository for the examples in this post, and all ideas discussed here will be represented in total there. When I approach dependency management the first thing I do is get an idea of how many dependencies a project has. We can do that in Maven using the following command:

mvn dependency:tree

Because this library is small it only contains a small handful of dependencies. The resulting graph paints an easy picture. Your project will be much different. If you have to work to count the number of dependencies in your project, it may be time to consider evaluating those needs. We will discuss pruning dependencies later in this post, but for now lets look through the results:

[INFO] com.aaronbedra:chronometrophobia:jar:1.1-SNAPSHOT
[INFO] +- commons-codec:commons-codec:jar:1.15:compile
[INFO] +- com.jnape.palatable:lambda:jar:5.3.0:compile
[INFO] +- com.jnape.palatable:shoki:jar:1.0-alpha-2:compile
[INFO] +- org.projectlombok:lombok:jar:1.18.18:provided
[INFO] +- junit:junit:jar:4.13.2:test
[INFO] |  \- org.hamcrest:hamcrest-core:jar:1.3:test
[INFO] \- com.jnape.palatable:lambda:test-jar:tests:5.3.0:test
[INFO] ------------------------------------------------------------------------

The output tells us the project has 7 dependencies. In our pom.xml, we only define 6, but resolving the junit dependency requires the hamcrest-core library, so that is added to our list. It’s just as important to review the dependencies of your dependencies to make sure you aren’t including anything that you don’t absolutely need to complete your project.

Looking at this list there’s something we can correct. The shoki dependency is only used in the project’s test suite, so we can scope it as such. We can also see that the lombok dependency is scoped provided to make sure it’s not exported in the resulting published jar. A quick rerun of dependency:tree results in the following:

[INFO] com.aaronbedra:chronometrophobia:jar:1.1-SNAPSHOT
[INFO] +- commons-codec:commons-codec:jar:1.15:compile
[INFO] +- com.jnape.palatable:lambda:jar:5.3.0:compile
[INFO] +- org.projectlombok:lombok:jar:1.18.18:provided
[INFO] +- com.jnape.palatable:shoki:jar:1.0-alpha-2:test
[INFO] +- junit:junit:jar:4.13.2:test
[INFO] |  \- org.hamcrest:hamcrest-core:jar:1.3:test
[INFO] \- com.jnape.palatable:lambda:test-jar:tests:5.3.0:test
[INFO] ------------------------------------------------------------------------

We can now see that we are now down to 3 library dependencies and 4 test dependencies. We will revisit this list in a bit, but for now we have an understanding of what this project requires. If you are doing this exercise with your project and don’t know what a dependency does, take the time to find out, and make sure you have access to its source if possible so you can understand how it works.

Pinning versions

Maven is kind enough to let you know when you have specified a dependency without a version. You will see a message something like:

[WARNING] Some problems were encountered while building the effective model for com.aaronbedra:chronometrophobia:jar:1.1-SNAPSHOT
[WARNING] 'build.plugins.plugin.version' for org.apache.maven.plugins:maven-compiler-plugin is missing. @ line 116, column 21

This is a warning that should be eliminated from every build. There should be absolutely zero variance in what dependency is resolved when your dependency manager is run. It may be convenient to use version bands or not versions at all, but this practice results in unexpected behavior, bugs, CI problems, and a host of maintenance issues that are purely distractions. New versions of dependencies can also pull in new dependencies, which could introduce functionality you don’t intend to. If one of your dependencies accidentally falls victim to a dependency confusion attack, your project could transitively be impacted by just creating a new build or running your dependency manager. It’s best to lock your versions and only change them when necessary.

Checking for available updates

In order to understand if a project has dependencies or plugins with available updates, we can enlist the help of the Versions Maven Plugin.

<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>versions-maven-plugin</artifactId>
    <version>2.8.1</version>
</plugin>

With this plugin you can run the following targets to get the information:

$ mvn versions:display-dependency-updates
[INFO] ------------------< com.aaronbedra:chronometrophobia >------------------
[INFO] Building chronometrophobia 1.1-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- versions-maven-plugin:2.8.1:display-dependency-updates (default-cli) @ chronometrophobia ---
[INFO] No dependencies in Dependency Management have newer versions.
[INFO]
[INFO] No dependencies in Dependencies have newer versions.
[INFO]
[INFO] ------------------------------------------------------------------------
$ mvn versions:display-plugin-updates
[INFO] The following plugin updates are available:
[INFO]   maven-javadoc-plugin ............................... 3.1.1 -> 3.2.0
[INFO]   maven-source-plugin ................................ 3.2.0 -> 3.2.1
[INFO]   org.owasp:dependency-check-maven ................... 6.0.5 -> 6.1.1
[INFO]   org.sonatype.plugins:nexus-staging-maven-plugin .... 1.6.3 -> 1.6.8
[INFO]
[WARNING] The following plugins do not have their version specified:
[WARNING]   maven-clean-plugin ...................... (from super-pom) 3.1.0
[WARNING]   maven-deploy-plugin .................. (from super-pom) 3.0.0-M1
[WARNING]   maven-install-plugin ................. (from super-pom) 3.0.0-M1
[WARNING]   maven-jar-plugin ........................ (from super-pom) 3.2.0
[WARNING]   maven-resources-plugin .................. (from super-pom) 3.2.0
[WARNING]   maven-site-plugin ....................... (from super-pom) 3.9.1
[WARNING]   maven-surefire-plugin ................ (from super-pom) 3.0.0-M5
[INFO]
[INFO] Project inherits minimum Maven version as: 3.5.*
[INFO] Plugins require minimum Maven version of: 3.1.0
[INFO] Note: the super-pom from Maven 3.6.3 defines some of the plugin
[INFO]       versions and may be influencing the plugins required minimum Maven
[INFO]       version.
[INFO]
[INFO] No plugins require a newer version of Maven than specified by the pom.
[INFO]
[INFO] ------------------------------------------------------------------------

While this project’s direct dependencies are up to date, it looks like several of the build plugins have available updates. Your build plugins are still code that is executed within your project and they should be treated with the same care as your other dependencies. A couple quick version updates in the project’s pom.xml will take care of these notifications. This report also warns that there are transitive plugin dependencies that do not have their versions specified. This is because they aren’t specifically defined inside pom.xml. Our best course of action is to add each of them to the <plugins> section of our pom.xml using the versions reported above. This will allow us to keep these plugins up to date as well as follow the version pinning rule presented above.

Dependency Convergence

Sometimes, your dependencies have dependencies. With popular dependencies used across many projects, a project can end up with multiple versions of the same dependency. Once you have multiple versions in your project, it may not be obvious which version actually gets used when your project is run. This can lead to difficult to debug issues and potentially re-exposure to vulnerable dependencies you may have thought you already eliminated. In the case of Maven, it will resolve all versions of all dependencies specified and include them. Luckily, the Maven Enforcer Plugin can help us shed some light on our issue.

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-enforcer-plugin</artifactId>
    <version>3.0.0-M3</version>
    <executions>
        <execution>
            <id>enforce-all</id>
            <goals>
                <goal>enforce</goal>
            </goals>
            <configuration>
                <fail>true</fail>
                <rules>
                    <requireMavenVersion>
                        <version>3.5.*</version>
                    </requireMavenVersion>
                    <requireJavaVersion>
                        <version>11</version>
                    </requireJavaVersion>
                    <dependencyConvergence />
                </rules>
            </configuration>
        </execution>
    </executions>
</plugin>

There are a few configuration options specified here, but the one we really came for is <dependencyConvergence/>. This option will cause the plugin to report on all dependencies with multiple versions. Combined with the <fail>true</fail> setting above, it will actually fail the build until resolved. This plugin is run as part of the verify target.

$ mvn verify
[WARNING]
Dependency convergence error for com.jnape.palatable:lambda:5.3.0 paths to dependency are:
+-com.aaronbedra:chronometrophobia:1.1-SNAPSHOT
  +-com.jnape.palatable:lambda:5.3.0
and
+-com.aaronbedra:chronometrophobia:1.1-SNAPSHOT
  +-com.jnape.palatable:shoki:1.0-alpha-2
    +-com.jnape.palatable:lambda:5.2.0
and
+-com.aaronbedra:chronometrophobia:1.1-SNAPSHOT
  +-com.jnape.palatable:lambda:5.3.0

[WARNING] Rule 2: org.apache.maven.plugins.enforcer.DependencyConvergence failed with message:
Failed while enforcing releasability. See above detailed error message.
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------

It looks like the shoki plugin requires a version of lambda older than the one we require directly. Maven provides several ways to solve this problem, but we will use <dependencyManagement> to take care of this particular issue:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.jnape.palatable</groupId>
            <artifactId>lambda</artifactId>
            <version>5.3.0</version>
        </dependency>
    </dependencies>
</dependencyManagement>

Explaining to Maven how to solve this makes our build successful again. There are quite a few other options available with this plugin, but dependency convergence is one of the important missing pieces of our puzzle.

Checking for Vulnerable Dependencies

Now that we have full control of our dependencies and how they are resolved, we need to make none of our dependencies have known vulnerabilities. We will use the OWASP Dependency Check Plugin accomplish this:

<plugin>
    <groupId>org.owasp</groupId>
    <artifactId>dependency-check-maven</artifactId>
    <version>6.1.1</version>
    <configuration>
        <failBuildOnCVSS>1</failBuildOnCVSS>
    </configuration>
    <executions>
        <execution>
            <goals>
                <goal>check</goal>
            </goals>
        </execution>
    </executions>
</plugin>

I prefer to set the failBuildOnCVSS option to 1. This forces evaluation and explicit action on everything found. It is acceptable to suppress a vulnerability if it does not impact your system, and using the value of 1 here will force you to upgrade or suppress. When suppression is chosen, be sure to add a detailed commit message that explains your choice and why it should be suppressed rather than updated. If you couple this with peer review, you can get into a workflow where all suppressions have approval from another person and there’s ample opportunity for discussion. Adding peer approved suppression is just as important as adding the dependency analysis tooling and should be considered an absolute requirement.

The first run of Dependency Check can take a while because of the CVE database updates. Now is a good time to get the first run out of the way so subsequent runs and execute faster.

Reducing Your Use of Dependencies

This is a more complicated subject. On one hand, pulling in a dependency to get the job done quickly has value. On the other, every dependency is a liability. My guideline for including a dependency is, if you can write it and fully test it in half a day or less, do that rather than include a dependency. This has several consequences:

  • You end up with a collection of tooling that you can take with you between projects, or even package as a dependency
  • You will learn more about fundamental concepts that will likely make you a better developer
  • You will solve the problems you need to solve, and not try to make code written by someone else for something else work for you
  • You will have explicit tests that prove the problem you are trying to solve is solved correctly

With all of this in mind, let’s revisit the dependencies of our project:

[INFO] com.aaronbedra:chronometrophobia:jar:1.1-SNAPSHOT
[INFO] +- commons-codec:commons-codec:jar:1.15:compile
[INFO] +- com.jnape.palatable:lambda:jar:5.3.0:compile
[INFO] +- com.jnape.palatable:shoki:jar:1.0-alpha-2:compile
[INFO] +- org.projectlombok:lombok:jar:1.18.18:provided
[INFO] +- junit:junit:jar:4.13.2:test
[INFO] |  \- org.hamcrest:hamcrest-core:jar:1.3:test
[INFO] \- com.jnape.palatable:lambda:test-jar:tests:5.3.0:test

There’s not much here that isn’t absolutely necessary, but let’s take a look at where commons-codec is used:

public static ReaderT<SecureRandom, IO<?>, Seed> generateSeed(int length) {
    return readerT(secureRandom -> io(() -> {
        byte[] randomBytes = new byte[length];
        secureRandom.nextBytes(randomBytes);
        return seed(encodeHexString(randomBytes));
    }));
}

The only use of this library is the encodeHexString method. Encoding a byte array to a hex string is a small enough exercise that it is a perfect candidate for replacement. The result is committed to our library and is a significant reduction in overall code surface with the commons-code library removed.

It’s worth mentioning that this is unfortunately not a broadly accepted habit. We have seen entire ecosystems crumble because of dependencies on incredibly simple ideas.

Wrap-Up

With great power comes great responsibility. Dependency management and resolution is an incredibly difficult problem to solve. Most software projects will benefit from using dependencies. At the end of the day, if you choose to pull external dependencies into your software, you are assuming responsibility for each and every dependency until you choose to remove them. Using dependency managers is easy. Managing dependencies once you have added them requires care and diligence. As with most of my advice, this is rooted in rigor and discipline, which will continue to be the cornerstone of any good Software Security practice.