Sécurité de la chaîne logicielle : SBOM, signature
1. Aperçu
Une application Spring Boot classique ne compte que quelques milliers de lignes de code que vous avez écrit. Le reste provient de centaines de bibliothèques tierces, de l’outil de build, du runner CI et de l’image de conteneur de base. Chacun de ces maillons peut être attaqué. C’est le problème de la sécurité de la chaîne logicielle.
Cet article couvre trois piliers concrets : générer un SBOM pour savoir ce que vous utilisez, signer les artifacts pour prouver qu’un artifact n’a pas été modifié, et bloquer les packages malveillants dès la résolution des dépendances. Après la lecture, vous pourrez ajouter ces étapes à un projet Maven et à un pipeline CI existant.
2. Pourquoi la chaîne logicielle reçoit-elle de l’attention
Les attaquants ont réalisé qu’attaquer une bibliothèque populaire est bien plus efficace que d’attaquer chaque application individuellement. En injectant simplement du code malveillant dans un package, ils peuvent atteindre tous les projets utilisant ce package.
Les types d’attaques couantes incluent :
- Typosquatting : publier un package avec un nom ressemblant au vrai package, par exemple différent d’une lettre, puis attendre qu’un développeur le tape mal.
- Dependency confusion : publier sur un registry public un package ayant le même nom qu’un package interne, mais avec une version supérieure. Si l’outil de build est mal configuré, il choisira la version publique.
- Compromission du mainteneur : le compte de la personne maintenant la bibliothèque est compromis, puis une version « légitime » contenant du code malveillant est poussée.
- Attaque du système de build : le code source est propre, mais le runner CI ou le script de build est compromis, donc l’artifact final est infecté.
- Vulnérabilité dans une dépendance transitoire : vous ne déclarez pas directement cette bibliothèque, mais elle se trouve dans le classpath.
Parallèlement, de nombreuses organisations et clients d’entreprise commencent à exiger que les fournisseurs de logiciels fournissent une SBOM et prouvent l’origine de la construction. Ce sujet n’est donc plus du ressort de l’équipe de sécurité, mais est devenu une exigence technique quotidienne.
3. SBOM : comprendre ce que vous exécutez
SBOM (Software Bill of Materials) est une liste exhaustive des composants d’un logiciel, incluant le nom, la version, la licence, le hash et les relations de dépendance. Imaginez-le comme une liste d’ingrédients imprimée sur un emballage alimentaire.
Les deux formats populaires sont CycloneDX et SPDX. L’écosystème Java supporte assez bien CycloneDX, donc cet article utilise ce format.
3.1. Générer un SBOM avec Maven
Ajoutez le plugin CycloneDX à pom.xml :
<properties>
<!-- Remplacer par la dernière version que vous vérifiez sur 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>
Explication :
makeAggregateBomrassemble les dépendances de tous les modules dans un projet multi-module en un seul fichier.- Attaché à la phase
packagepour que la SBOM soit toujours générée avec le JAR, sans que personne ne doive la générer manuellement. includeTestScope=falseexclut JUnit, Mockito… de la SBOM, puisqu’ils ne sont pas présents au runtime.
Lancez le build :
mvn clean package
Le plugin crée le fichier target/bom.json. Une partie du contenu ressemble à :
{
"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": [ ... ]
},
...
]
}
Le champ le plus important est purl (Package URL). C’est l’identifiant standard que les outils de scan de vulnérabilités utilisent pour correspondre avec la base de données CVE.
3.2. Utiliser la SBOM
La SBOM en elle-même ne protège rien. Sa valeur réside dans la façon dont vous l’utilisez :
- Scanner les vulnérabilités : fournissez
bom.jsonà un outil de scan SBOM dans votre CI. Quand une nouvelle CVE apparaît, vous pouvez rescanner l’ancienne SBOM sans refaire le build. - Répondre à la question “sommes-nous affectés ?” : quand une grosse vulnérabilité paraît, cherchez par
purldans votre dépôt de SBOM de tous les services, au lieu de demander à chaque équipe. - Vérifier les licences : détectez les bibliothèques ayant une licence incompatible avec la politique de votre entreprise.
Spring Boot 3.x dans ses dernières versions dispose d’un endpoint actuator sbom. Quand le fichier SBOM est emballé dans l’application (en utilisant spring-boot-starter-parent avec le plugin CycloneDX), vous pouvez activer cet endpoint :
management.endpoints.web.exposure.include=health,sbom
Appelez GET /actuator/sbom pour voir la SBOM de l’instance en cours d’exécution. C’est utile quand vous devez savoir exactement quelle version s’exécute en production.
Attention : la SBOM énumère complètement les bibliothèques et versions, donc les attaquants peuvent l’utiliser pour chercher des vulnérabilités. N’exposez pas l’endpoint
sbomsur Internet. Limitez-le au réseau interne ou placez-le derrière une authentification.
4. Signer les artifacts : prouver que l’artifact n’a pas été modifié
La SBOM dit ce que l’artifact contient. La signature numérique dit d’où l’artifact provient et qu’il n’a pas été modifié depuis la signature.
4.1. Checksum : nécessaire mais insuffisant
Le niveau le plus simple est de vérifier le hash. Cette classe calcule le SHA-256 d’un fichier et le compare à la valeur attendue :
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);
}
}
}
Points à noter :
- Le fichier est lu par blocs de 8 KB, donc même les artifacts volumineux ne saturent pas la mémoire.
HexFormat(disponible depuis Java 17) convertit les bytes en chaîne hexadécimale sans bibliothèque externe.MessageDigest.isEqualcompare en temps constant. Ici, ce n’est pas critique, mais c’est une bonne pratique quand on compare des valeurs liées à la sécurité.
Test pour le comportement principal, utilisant JUnit 5 et 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");
}
}
Il suffit d’ajouter un caractère ! pour que le hash change complètement, et verify lance une SecurityException.
Le problème est : si un attaquant remplace l’artifact, il peut généralement aussi remplacer le fichier .sha256 qui l’accompagne. Le checksum garantit seulement l’intégrité, pas l’authenticité. Pour prouver l’authenticité, vous avez besoin d’une signature numérique.
4.2. Signer avec Sigstore cosign
La façon traditionnelle est de signer avec GPG. Cela fonctionne, mais gérer des clés long terme est pénible : stockage, rotation, révocation en cas de fuite. Sigstore propose une autre approche appelée keyless signing. Quand vous signez, l’outil cosign récupère votre identité OIDC (par exemple, l’identité de votre workflow CI), demande un certificat à courte durée, signe, puis enregistre la preuve dans un log de transparence public.
Signa un fichier JAR dans votre CI :
cosign sign-blob target/order-service.jar \
--bundle order-service.jar.sigstore.json \
--yes
Le fichier .sigstore.json contient la signature, le certificat et la preuve qu’il a été enregistré dans le log de transparence. Côté récepteur, vérifiez comme suit :
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"
Résultat quand c’est valide :
Verified OK
Les deux paramètres --certificate-identity* et --certificate-oidc-issuer sont essentiels. Sans eux, vous savez seulement « quelqu’un a signé », pas « c’est mon pipeline qui a signé ». Avec les images de conteneur, le processus est similaire en utilisant cosign sign et cosign verify sur le digest de l’image.
4.3. Intégrer dans le pipeline
Un job GitHub Actions minimal qui signe le JAR et la SBOM :
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # nécessaire pour que cosign récupère le token OIDC
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
La permission id-token: write permet au workflow de récupérer un token OIDC. Vous ne stockez aucune clé privée dans les secrets. Signer aussi bom.json garantit au récepteur que la SBOM accompagne vraiment cet artifact, et n’a pas été obtenue d’ailleurs.
5. Bloquer les packages malveillants dès le départ
Signer l’artifact protège votre sortie. Cette section protège votre entrée, c’est-à-dire les dépendances que vous tirez.
5.1. Résoudre uniquement via un repository contrôlé
Configurez ~/.m2/settings.xml (ou le fichier settings utilisé en CI) pour que toute requête passe par un repository interne, et échouez immédiatement si le checksum ne correspond pas :
<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=*intercepte tous les repositories, même ceux qu’unpom.xmltiers déclare lui-même, et force le passage par le proxy interne.checksumPolicy=failarrête le build au lieu de seulement avertir si le checksum est incorrect.- Désactiver les snapshots de sources externes, puisque les snapshots peuvent changer de contenu sans changer de version.
Pour contrer la dependency confusion, configurez votre proxy de repository pour que les groupId internes (par exemple com.acme.*) ne soient jamais cherchés dans les sources publiques. Maven Central impose une vérification de propriété du namespace quand on publie, donc le risque est plus faible que sur d’autres registries, mais vous ne devriez pas vous reposer complètement sur cela.
5.2. Appliquer la politique avec Maven Enforcer
Le plugin Enforcer transforme les politiques en erreurs de build :
<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>
bannedDependenciesbloque les bibliothèques ne recevant plus de support ou les plages de versions avec des vulnérabilités connues. Cette règle s’applique aussi aux dépendances transitoires.requireUpperBoundDepssignale une erreur quand Maven choisit silencieusement une version inférieure à celle qu’une autre bibliothèque demande.requireReleaseDepsinterdit les SNAPSHOT dans les dépendances.
En cas de violation, le build s’arrête avec un message comme :
[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. Bonnes pratiques simples, grand impact
- Pinifier les versions : n’utilisez pas de plages comme
[1.0,)ouLATEST, car le build d’aujourd’hui et celui de demain pourraient différer. - Revoir lors de l’ajout d’une dépendance : vérifiez le nom exact, le groupId, le nombre d’utilisateurs, l’historique des versions. Une bibliothèque inconnue portant un nom ressemblant à une célèbre est un signe typosquatting.
- Automatiser les mises à jour avec un bot créant des pull requests, mais laisser le CI exécuter tous les tests et les scans SBOM avant de fusionner.
- Réduire les dépendances : ce qui ne s’ajoute pas au classpath ne peut pas vous attaquer.
6. Erreurs courantes et quand ne pas en faire trop
Attention : générer une SBOM que personne ne lit est quasi inutile. Liez la SBOM à une étape de scan automatisé et à une personne ou équipe responsable des résultats.
Quelques erreurs fréquentes :
- Signer l’artifact mais personne ne vérifie la signature. La signature n’a de valeur que si votre étape de déploiement (ou un contrôleur d’admission Kubernetes) rejette les artifacts sans signature valide.
- Vérifier la signature sans lier l’identité. Comme expliqué à la section 4.2, « avoir une signature » est bien différent de « avoir une signature de mon pipeline ».
- Générer la SBOM à partir du code source au lieu de l’artifact final. Si l’étape d’empaquetage ajoute ou supprime des bibliothèques, la SBOM sera décalée. Générez-la à la même étape de build qui crée l’artifact à déployer.
- Une liste de bannissement trop longue et jamais mise à jour. Du coup, l’équipe trouvera un moyen de contourner Enforcer, et la politique devient inefficace.
Quand ne pas investir lourd :
- Prototype, script interne à usage unique, projet d’apprentissage. Pinifier les versions et garder un mirror de repository suffit. Configurer le keyless signing avec des politiques d’admission demandera plus d’effort que le bénéfice apporté.
- Équipe sans processus de gestion des vulnérabilités. Des centaines d’alertes CVE débarquant en même temps sans personne pour les trier crée une « alert fatigue ». Commencez avec SBOM et un scan au niveau sévérité haute, puis élargissez graduellement.
- Environnement complètement air-gapped. Le keyless signing dépend d’un service OIDC et d’un log de transparence externes. Dans ce cadre, signer avec vos propres clés (GPG ou cosign avec paire de clés) est plus approprié.
7. Conclusion
La sécurité de la chaîne logicielle tourne autour de trois questions : qu’est-ce que j’utilise, cet artifact vient-il vraiment de moi, et ce que je tire mérite-t-il confiance. La SBOM répond à la première question, la signature numérique avec Sigstore répond à la deuxième, et un repository contrôlé avec Maven Enforcer aide à répondre à la troisième.
Vous n’avez pas besoin d’appliquer tout d’un coup. Une feuille de route raisonnable est : pinifier les versions et utiliser un mirror interne, puis générer une SBOM à chaque build, ajouter ensuite Enforcer, et enfin signer les artifacts avec vérification obligatoire à l’étape de déploiement. Chaque étape réduit les risques de façon notable, et chacune peut être automatisée dans le pipeline existant.