Modern software applications typically have numerous dependencies on 3rd party components, sometimes up to several hundreds, many of which are produced as part of open source projects.
So-called package managers are responsible for downloading components and making them available to the application as needed. Example package managers are Maven, which downloads Java dependencies from repositories such as Maven Central and npm, which manages dependencies for Node.js applications.
But not all dependencies are used in the same way: Some are required during all phases of the software development lifecycle, while others are only needed at certain times, e.g., during compilation or tests. When specifying a dependency during application development, developers can choose when such dependency has to be available.
Npm, for instance, distinguishes dependencies and devDependencies1. The former are required by the application when deployed in a production environment, while the latter are only required at development time, thus, not in production. Maven, on the other hand, introduces a total of six so-called dependency scopes to allow for a more fine-grained differentiation2, whose meaning and security implications will be explained in the remainder of this post.
What are Maven dependency scopes?
In the Java world, dependencies become available at different stages of the development lifecycle by being included or excluded from the Java classpath, which is a list of directories and archives with compiled Java classes. Simply put, classes on the classpath are visible to all other classes and can be referenced and used according to their API.
If Java is run from the command line, you can specify the classpath by using the -cp or -classpath options, which take as argument a list of directories and JAR/ZIP files. But when using Maven, the classpath is automatically built on the basis of the scope chosen by the developer when declaring the dependency.
To this end, Maven distinguishes several lifecycle phases3, e.g., generate-sources, compile, test-compile or package, which either have an associated Java classpath (passed on to JDK’s java or javac executables), or make some assumptions about it. To understand Maven dependency scopes, it is important to distinguish the following classpaths: compilation of project classes, compilation and execution of test classes and project runtime.
Which scopes exist?
Maven distinguishes six dependency scopes: compile, runtime, test, provided, system and import.
The compile scope contains dependencies that are required for the compilation of the project under development. In other words, the source code of project classes directly or indirectly references classes of compile dependencies, which requires those compile dependencies to be present in all lifecycle phases, from compilation and test to the production environment.
The runtime scope contains dependencies whose classes are not referenced directly in code, but are loaded dynamically, using reflection or a Java ServiceLoader4, for example. Their classes also provide functionality required by the project under development, thus, require being on the classpath during tests and runtime.
The provided scope contains classes that will be provided by the project’s runtime environment and thus do not need to be included in the artifact produced by the development project. A typical example is the Java Servlet API, which will be provided by the Web application container, e.g., Apache Tomcat. For this reason, provided dependencies are on the classpath during compilation and test, but not included when creating the project artifact that will be deployed into production.
The (deprecated) system scope is comparable to the provided scope with the difference that the Java artifact corresponding to the dependency is not available on any package repository like Maven Central, but in the file system where the Maven build takes place, e.g., dependencies provided by the JDK or in a subdirectory of the development project.
The test scope is chosen for dependencies that are only required on the classpath during test compilation and execution. Typical examples are Junit or mocking libraries.
The following picture summarizes the above-described availability of dependencies with the respective scopes in the classpaths for project compilation, test compilation and execution as well as project runtime (production).
But let’s not forget the import scope, which is a little special. These are not “regular” dependencies on artifacts of type jar or war, which contain compiled Java classes. Instead, a dependency declaration with scope import must refer to an artifact of type pom, and can only appear in the dependencyManagement section. This declaration will be replaced by the dependencies in the referenced project’s dependencyManagement section.
Finally, it is also interesting to note that the dependency scope declared by a development project propagates to transitive dependencies. For instance, take a project with a direct test dependency having multiple compile and runtime dependencies. The test scope of the direct dependency is propagated to all those compile and runtime dependencies, thus, they are only available in the test compilation and execution classpaths.
The following screenshot shows (an excerpt of) the dependency graph of Apache Log4j v2.19.0, specifically module org.apache.logging.log4j:log4j-core:jar:2.19.0, produced by running the maven-dependency-plugin as follows in the root directory of the Log4j Git repository5: mvn dependency:tree
Each node of the module’s dependency tree comprises the following elements, separated by colon: group identifier, artifact identifier, type (or packaging), classifier (not present in this example), version and scope, e.g., com.lmax:disruptor:jar:3.4.4:compile.
How do scopes help for vulnerability triaging?
The Equifax breach in 2017 or the Log4Shell vulnerability at the end of 2021 demonstrated the potentially disastrous impact of vulnerabilities in software components on applications depending on them.
Open source vulnerability scanners such as provided by Endor Labs or open source solutions like OWASP Dependency Check or Eclipse Steady, support developers in the detection of vulnerable dependencies and in assessing whether the respective vulnerability matters in their specific application context or not.
And this is when the scope of a dependency matters: Vulnerabilities in project dependencies can only be exploited if the respective component is present in the production environment. If the respective code is not present, attackers can obviously not take any advantage of its vulnerabilities.
This is why compile and runtime dependencies have to be addressed with highest priority, as those are present as-is in the production environment of the application. Dependencies used for testing, on the other hand, do not matter, since they should not be present in production.
But it gets more tricky with provided and system dependencies. They will not be considered by Maven when creating a deployable application package. But they are still expected to be present in the runtime environment (more precisely: their classes), however, not necessarily in the same version or not even with the same group and artifact identifier. In such cases, the developers are advised to determine the actual component and version used in production - which is relatively easy in case of cloud solutions operated by the very same organization, and more difficult in case of on-premise apps operated by other entities.
And Supply Chain Attacks?
The problem of known vulnerabilities in application dependencies has been known for a long time, it is part of the OWASP Top-10 security risks since 2013. But more recently, adversaries started conducting supply chain attacks on open source projects.
Rather than waiting for vulnerabilities to appear in production applications, they deliberately inject malicious code in open source projects such that this code is downloaded and executed by downstream users - either developers or application end-users, e.g., to install and run cryptominers. The risk explorer for software supply chains6 provides an overview about all the different ways to sneak malicious code into open source based development projects.
Unfortunately, all scopes matter in the same way for such supply chain attacks, the reason being that all dependencies, no matter the scope, run on security sensitive resources of the development project, e.g., developer workstations or build systems. As such, they have access to sensitive information like developer credentials, which can be exfiltrated, or application code, which can be modified to include a backdoor. A malicious plugin or test dependency, for instance, could alter the compiled classes of the project in order to inject an intentional vulnerability, without such changes ever being visible in the project’s source code.
The good news is that the Java and Maven ecosystem is not as heavily attacked as others. So far, only few malicious Java packages have been discovered, compared to hundreds of attacks on the Python, npm and Ruby ecosystems. Three such cases have been reported in January 20207, e.g., the malicious package com.github.codingandcoding:maven-compiler-plugin:3.9.0. Another example is the Octopus Scanner Malware discovered in May 20208.
Those examples, however, make evident that the above-mentioned application dependencies are not the only components that need to be carefully selected and monitored. Developers should use the same scrutiny when selecting Maven plugin dependencies9 and other components running in the development or build environment of application projects.
It is important to carefully evaluate components before making applications depend on them, which can be done using various quality and activity metrics. If ever there is a vulnerability, for instance, you would want that there’s a lively developer community able to develop a patch.
Once a dependency has been chosen, make sure to specify the correct scope in the dependency declaration. This will be obvious in most cases, however, if no scope is provided at all, the default compile scope applies. Such dependencies will therefore be included in the classpath of all phases, including application runtime.
Finally, make sure to revisit dependencies on a regular basis. On one hand to see whether the quality and activity metrics are still as they were when the dependency has been included in the first place, thus, still meet your requirements. But also to check whether the dependency is still needed at all.