Software Supply Chain Security: SBOM, Signing, and Blocking Malicious Packages
1. Overview
A typical Spring Boot application has only a few thousand lines of code written by you. The rest comes from hundreds of third-party libraries, from the build tool, CI runner, and container base image. Each link in that chain can be attacked. This is the problem of software supply chain security.
This article covers three practical pillars: generating SBOM to know what you’re using, signing artifacts to prove an artifact hasn’t been tampered with, and blocking malicious packages right at the dependency resolution stage. After reading, you’ll be able to add these steps to an existing Maven project and CI pipeline.
2. Why Supply Chain Security Matters
Attackers have realized that attacking a popular library is far more effective than attacking individual applications. By injecting malicious code into a single package, they can reach every project that uses that package.
Common attack types:
- Typosquatting: publishing a package with a name similar to the real one (one character off, for example), then waiting for a programmer to mistype.
- Dependency confusion: publishing a package to the public registry with the same name as an internal package but with a higher version. A misconfigured build tool will pick the public version.
- Compromising a maintainer account: the credentials of the library maintainer leak, and a “legitimate” release containing malicious code is pushed.
- Attacking the build system: the source code is clean, but the CI runner or build script is tampered with, so the output artifact is corrupted.
- Vulnerabilities in transitive dependencies: you don’t declare the library directly, but it still ends up in your classpath.
At the same time, many organizations and enterprise customers are starting to require software vendors to submit an SBOM and prove the build’s provenance. So this topic is no longer just “the security team’s job” but has become an everyday technical requirement.
3. SBOM: Know What You’re Running
SBOM (Software Bill of Materials) is a complete list of components in a piece of software, including name, version, license, hash, and dependency relationships. Think of it like the ingredient list printed on food packaging.
Two popular formats are CycloneDX and SPDX. The Java ecosystem has good support for CycloneDX, so this article uses that format.
3.1. Generating SBOM with Maven
Add the CycloneDX plugin to pom.xml:
<properties>
<!-- Replace with the latest version you can check on Maven Central -->
<cyclonedx.version>REPLACE_WITH_LATEST</cyclonedx.version>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.cyclonedx</groupId>
<artifactId>cyclonedx-maven-plugin</artifactId>
<version>${cyclonedx.version}</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>makeAggregateBom</goal>
</goals>
</execution>
</executions>
<configuration>
<outputFormat>json</outputFormat>
<includeTestScope>false</includeTestScope>
</configuration>
</plugin>
</plugins>
</build>
Explanation:
makeAggregateBomcombines the dependencies of all modules in a multi-module project into one file.- Attached to the
packagephase so the SBOM is always generated along with the JAR file; nobody has to remember to run it separately. includeTestScope=falseexcludes JUnit, Mockito, etc. from the SBOM, since they’re not present at runtime.
Run the build:
mvn clean package
The plugin creates the file target/bom.json. A sample of the contents looks like:
{
"bomFormat": "CycloneDX",
"components": [
{
"group": "com.fasterxml.jackson.core",
"name": "jackson-databind",
"version": "...",
"purl": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@...",
"hashes": [ { "alg": "SHA-256", "content": "..." } ],
"licenses": [ ... ]
},
...
]
}
The most important field is purl (Package URL). This is a standard identifier that vulnerability scanning tools use to match against CVE databases.
3.2. What to Do with an SBOM
The SBOM itself doesn’t protect anything. Its value lies in how you use it:
- Scan for vulnerabilities: feed
bom.jsonto an SBOM scanning tool in your CI. When a new CVE appears, you can scan old SBOMs without rebuilding. - Answer “are we affected?”: when a major vulnerability is disclosed, you search by
purlacross your SBOM repository for all services, rather than asking each team. - Check licenses: detect libraries with licenses incompatible with company policy.
Recent versions of Spring Boot 3.x also have an sbom actuator endpoint. When the SBOM file is packaged into the application (using spring-boot-starter-parent with the CycloneDX plugin), you can enable this endpoint:
management.endpoints.web.exposure.include=health,sbom
Call GET /actuator/sbom to see the SBOM of the running instance. This is useful when you need to know exactly which version is running in production.
Note: SBOM lists all libraries and versions, so an attacker could use it to probe for vulnerabilities. Don’t expose the
sbomendpoint to the internet. Restrict it to your internal network or place it behind authentication.
4. Signing Artifacts: Proving Artifacts Haven’t Been Tampered With
SBOM tells you what an artifact contains. A digital signature tells you where an artifact comes from and that it hasn’t been modified since signing.
4.1. Checksums: Necessary but Not Sufficient
The simplest level is checking a hash. The class below computes SHA-256 of a file and compares it against an expected value:
package dev.nonla.supplychain;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
public final class ArtifactVerifier {
private ArtifactVerifier() {
}
public static String sha256(Path file) throws IOException {
MessageDigest digest = newDigest();
try (InputStream in = Files.newInputStream(file)) {
byte[] buffer = new byte[8192];
int read;
while ((read = in.read(buffer)) != -1) {
digest.update(buffer, 0, read);
}
}
return HexFormat.of().formatHex(digest.digest());
}
public static void verify(Path file, String expectedSha256) throws IOException {
String actual = sha256(file);
boolean matches = MessageDigest.isEqual(
actual.getBytes(StandardCharsets.US_ASCII),
expectedSha256.toLowerCase().getBytes(StandardCharsets.US_ASCII));
if (!matches) {
throw new SecurityException(
"Checksum mismatch for " + file.getFileName() + ": " + actual);
}
}
private static MessageDigest newDigest() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
}
Notable points:
- The file is read in 8 KB blocks, so even large artifacts don’t exhaust memory.
HexFormat(available since Java 17) converts bytes to a hex string without external libraries.MessageDigest.isEqualcompares in constant time. While not critical here, it’s a good security habit when comparing values related to security.
Test for the main behavior, using JUnit 5 and AssertJ:
package dev.nonla.supplychain;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ArtifactVerifierTest {
private static final String HELLO_SHA256 =
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
@TempDir
Path tempDir;
@Test
void givenUntouchedArtifact_whenVerify_thenPasses() throws Exception {
Path jar = Files.writeString(tempDir.resolve("order-service.jar"), "hello");
assertThat(ArtifactVerifier.sha256(jar)).isEqualTo(HELLO_SHA256);
assertThatCode(() -> ArtifactVerifier.verify(jar, HELLO_SHA256))
.doesNotThrowAnyException();
}
@Test
void givenTamperedArtifact_whenVerify_thenThrows() throws Exception {
Path jar = Files.writeString(tempDir.resolve("order-service.jar"), "hello!");
assertThatThrownBy(() -> ArtifactVerifier.verify(jar, HELLO_SHA256))
.isInstanceOf(SecurityException.class)
.hasMessageContaining("order-service.jar");
}
}
Just adding a single ! character changes the hash completely, and verify throws SecurityException.
The problem is: if an attacker can replace the artifact, they can usually replace the .sha256 file next to it too. A checksum ensures integrity, but doesn’t prove origin. To prove origin, you need a digital signature.
4.2. Signing with Sigstore cosign
The traditional approach is signing with GPG. This works, but long-term key management is tedious: storage, rotation, revocation when compromised. Sigstore offers an alternative called keyless signing. When signing, the cosign tool takes your OIDC identity (e.g., your CI workflow identity), requests a short-lived certificate, signs with it, and records the fact in a public transparency log.
Sign a JAR file in CI:
cosign sign-blob target/order-service.jar \
--bundle order-service.jar.sigstore.json \
--yes
The .sigstore.json file contains the signature, certificate, and proof of entry in the transparency log. The recipient verifies like this:
cosign verify-blob target/order-service.jar \
--bundle order-service.jar.sigstore.json \
--certificate-identity-regexp "^https://github.com/acme/order-service/" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"
Output when valid:
Verified OK
The important part is the two --certificate-identity* and --certificate-oidc-issuer flags. Without them, you only know “someone signed it”, not “my pipeline signed it”. For container images, the process is similar using cosign sign and cosign verify on the image digest.
4.3. Adding to Your Pipeline
A minimal GitHub Actions job that signs the JAR and SBOM:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # needed for cosign to get an OIDC token
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- uses: sigstore/cosign-installer@v3
- run: mvn -B clean package
- run: |
cosign sign-blob target/order-service.jar --bundle order-service.jar.sigstore.json --yes
cosign sign-blob target/bom.json --bundle bom.json.sigstore.json --yes
The id-token: write permission allows the workflow to get an OIDC token. You don’t store any private key in secrets. Signing the bom.json too helps the recipient trust that the SBOM truly came with that artifact, not spliced in from elsewhere.
5. Blocking Malicious Packages From the Start
Signing artifacts protects your output. This section protects your input: the dependencies you pull in.
5.1. Resolve Only Through a Controlled Repository
Configure ~/.m2/settings.xml (or the settings used in CI) so all requests go through an internal repository, and fail immediately if checksums don’t match:
<settings>
<mirrors>
<mirror>
<id>internal</id>
<mirrorOf>*</mirrorOf>
<url>https://repo.acme.internal/maven-all/</url>
</mirror>
</mirrors>
<profiles>
<profile>
<id>strict</id>
<repositories>
<repository>
<id>internal</id>
<url>https://repo.acme.internal/maven-all/</url>
<releases>
<checksumPolicy>fail</checksumPolicy>
</releases>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
</profile>
</profiles>
<activeProfiles>
<activeProfile>strict</activeProfile>
</activeProfiles>
</settings>
mirrorOf=*intercepts all repositories, including repositories declared by third-party POMs, forcing them through your internal proxy.checksumPolicy=failstops the build instead of just warning when a checksum is wrong.- Disable snapshots from external sources, since snapshots can change content without changing the version.
To defend against dependency confusion, configure your repository proxy so internal GroupIds (e.g., com.acme.*) are only fetched from your internal repository, never from public sources. Maven Central requires ownership verification when publishing, so the risk is lower than some other registries, but you still shouldn’t rely entirely on that.
5.2. Enforce Policy with Maven Enforcer
The Enforcer plugin turns policies into build failures:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>${enforcer.version}</version>
<executions>
<execution>
<id>supply-chain-rules</id>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<bannedDependencies>
<excludes>
<exclude>log4j:log4j</exclude>
<exclude>commons-collections:commons-collections:[,3.2.2)</exclude>
</excludes>
</bannedDependencies>
<requireUpperBoundDeps/>
<requireReleaseDeps>
<onlyWhenRelease>false</onlyWhenRelease>
</requireReleaseDeps>
</rules>
</configuration>
</execution>
</executions>
</plugin>
bannedDependenciesblocks obsolete libraries or version ranges with known vulnerabilities. This rule applies even to transitive dependencies.requireUpperBoundDepsreports an error when Maven silently picks a version lower than what another library requires.requireReleaseDepsforbids SNAPSHOTs in dependencies.
When violated, the build stops with a message like:
[ERROR] Rule 0: org.apache.maven.enforcer.rules.dependency.BannedDependencies failed with message:
com.acme:order-service:jar:1.0.0
log4j:log4j:jar:1.2.17 <--- banned via the exclude/include list
5.3. Small Habits, Big Impact
- Pin specific versions. Don’t use version ranges like
[1.0,)orLATEST, since today’s and tomorrow’s builds might differ. - Review when adding a dependency: check the exact name, GroupId, number of users, and release history. A library with an obscure name very similar to a popular one is a typosquatting red flag.
- Automate updates with a bot that creates pull requests, but still run full tests and SBOM scanning in CI before merging.
- Reduce dependencies: what’s not in your classpath can’t attack you.
6. Common Pitfalls and When Not to Overdo It
Note: Generating an SBOM that nobody reads is nearly useless. Tie SBOM generation to an automated scanning step and assign a person or team to handle the results.
Some common mistakes:
- Signing artifacts but nobody verifies them. A signature is only valuable if your deployment step (or Kubernetes admission controller) rejects artifacts without a valid signature.
- Verifying signatures without binding identity. As mentioned in section 4.2, “has a signature” is different from “has a signature from my pipeline”.
- Generating SBOM from source instead of from the final artifact. If the packaging step adds or removes libraries, the SBOM will be inaccurate. Generate SBOM at the same build step that creates the deployed artifact.
- A banning list that’s too long and never updated. Teams will then find ways to bypass Enforcer, and the policy loses its teeth.
When not to invest heavily:
- Prototypes, one-off internal scripts, learning projects. Pinning versions and maintaining a mirror repository is enough. Setting up keyless signing and admission policies costs more effort than the value it provides.
- Your team doesn’t have a vulnerability response process yet. Hundreds of CVE warnings pouring in at once with nobody to triage just causes “alert fatigue”. Start with SBOM and high-severity scanning, then expand gradually.
- Completely air-gapped environments. Keyless signing depends on external OIDC services and transparency logs. In such environments, signing with self-managed keys (GPG or cosign with a key pair) is more appropriate.
7. Conclusion
Software supply chain security revolves around three questions: what am I using, is this artifact really mine, and can I trust what I’m pulling in? SBOM answers the first question, digital signatures with Sigstore answer the second, and a controlled repository plus Maven Enforcer help answer the third.
You don’t need to implement everything in a day. A reasonable roadmap is: pin versions and use an internal repository mirror, then generate SBOM in every build, add Enforcer, and finally sign artifacts with mandatory verification at deployment. Each step significantly reduces risk and can be automated in the pipeline you already have.