ソフトウェアサプライチェーンセキュリティ: SBOM、署名、悪質パッケージ対策

1. 概要

通常のSpring Bootアプリケーションは、あなたが書いたコードはわずか数千行です。残りの部分は、数百の第三者ライブラリ、ビルドツール、CIランナー、コンテナベースイメージから来ています。これらの各要素は攻撃される可能性があります。これがソフトウェアサプライチェーンセキュリティの問題です。

この記事は3つの実践的な柱を通じて説明します: 何を使用しているかを把握するためのSBOM生成、アーティファクトが改ざんされていないことを証明するためのアーティファクト署名、および依存関係を解決する時点で悪質なパッケージを阻止すること。読み終わったら、これらのステップを既存のMavenプロジェクトとCIパイプラインに追加できます。

2. なぜサプライチェーンセキュリティが注目されているのか

攻撃者は、個々のアプリケーションを攻撃するよりも、よく使われるライブラリを攻撃する方がはるかに効率的であることに気付きました。1つのパッケージに悪意あるコードを仕込むだけで、そのパッケージを使用するすべてのプロジェクトに到達できます。

よく見かける攻撃の種類:

  • Typosquatting: 本物のパッケージに似た名前(例えば1文字異なる)を持つパッケージを登録し、プログラマーのタイプミスを待つ。
  • Dependency confusion: パブリックレジストリに内部パッケージと同じ名前の高いバージョン番号を持つパッケージを登録する。ビルドツールの設定が誤っていると、パブリックバージョンが選択されます。
  • Maintainer権限の侵害: ライブラリのメンテナーのアカウントが流出し、悪意あるコードを含む「正規の」リリースがプッシュされる。
  • ビルドシステムへの攻撃: ソースコードはクリーンですが、CIランナーまたはビルドスクリプトが改ざんされ、出力アーティファクトが汚染される。
  • 推移的依存関係(Transitive)の脆弱性: あなたが直接宣言していないライブラリですが、それでもクラスパスに存在しています。

同時に、多くの組織と企業顧客が、ソフトウェアベンダーにSBOMの提出とビルド出所の証明を要求し始めています。したがって、このテーマはもはや「セキュリティチームの仕事」ではなく、日常的な技術要件となっています。

3. SBOM: 何を実行しているかを知る

**SBOM(Software Bill of Materials)**は、ソフトウェア内のすべてのコンポーネントの完全なリストで、名前、バージョン、ライセンス、ハッシュ、および依存関係を含みます。食品パッケージに印刷された成分表のようなものだと考えてください。

2つの一般的な形式はCycloneDXとSPDXです。JavaエコシステムはなかなかよくCycloneDXをサポートしているので、この記事ではその形式を使用します。

3.1. Mavenを使用してSBOMを生成する

CycloneDXプラグインをpom.xmlに追加します:

<properties>
    <!-- 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>

説明:

  • makeAggregateBomは、マルチモジュールプロジェクト内のすべてのモジュールの依存関係を1つのファイルに集約します。
  • packageフェーズにバインドして、SBOMがJARファイルと同時に常に生成されるようにします。誰も別途実行することを覚える必要はありません。
  • includeTestScope=falseはJUnit、MockitoなどをSBOMから除外します。これらはランタイムに存在しないためです。

ビルドを実行:

mvn clean package

プラグインはtarget/bom.jsonファイルを作成します。内容の一部は次のようになります:

{
  "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": [ ... ]
    },
    ...
  ]
}

最も重要なフィールドはpurl(Package URL)です。これは、脆弱性スキャンツールがCVEデータベースと照合するために使用する標準識別子です。

3.2. SBOMは何に使うのか

SBOM自体は何も保護しません。その価値は、あなたがそれをどのように使用するかにあります:

  • 脆弱性スキャン: bom.jsonをCI内のSBOMスキャンツールに渡します。新しいCVEが出現したとき、再ビルドすることなく古いSBOMを再スキャンできます。
  • 「私たちは影響を受けていますか?」という質問に答える: 大きな脆弱性が出現したとき、各チームに質問する代わりに、すべてのサービスのSBOMリポジトリでpurlを探すことができます。
  • ライセンス確認: 会社の方針と合わないライセンスを持つライブラリを検出します。

Spring Boot 3.xの最近のバージョンにはactuator endpoint sbomがあります。SBOMファイルがアプリケーションにパッケージ化される場合(spring-boot-starter-parentとCycloneDXプラグインを使用)、このエンドポイントを有効にできます:

management.endpoints.web.exposure.include=health,sbom

GET /actuator/sbomを呼び出して、実行中のインスタンスのSBOMを表示します。本番環境で正確にどのバージョンが実行されているかを知る必要があるときに便利です。

注意: SBOMはライブラリとバージョンを完全にリストしているため、攻撃者がそれを使用して脆弱性を探ることができます。エンドポイントsbomをインターネットに公開しないでください。内部ネットワークに限定するか、認証の背後に置きます。

4. アーティファクト署名: アーティファクトが改ざんされていないことを証明する

SBOMはアーティファクトが何を含むかを示します。デジタル署名はアーティファクトがどこから来たか、そして署名以来改ざんされていないかを示します。

4.1. チェックサム: 必要だが十分ではない

最も簡単なレベルはハッシュチェックです。以下のクラスはファイルのSHA-256を計算し、期待値と比較します:

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(
                    "チェックサム不一致: " + file.getFileName() + ": " + actual);
        }
    }

    private static MessageDigest newDigest() {
        try {
            return MessageDigest.getInstance("SHA-256");
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("SHA-256は利用できません", e);
        }
    }
}

注目すべき点:

  • ファイルは8KBのブロック単位で読み込まれるため、大きなアーティファクトでもメモリがいっぱいになりません。
  • HexFormat(Java 17以降)はバイトを外部ライブラリなしで16進文字列に変換します。
  • MessageDigest.isEqualは一定時間で比較します。ここではそれほど重要ではありませんが、セキュリティに関連する値を比較する際の良い習慣です。

JUnit 5と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");
    }
}

1文字の!を追加するだけでハッシュが完全に異なり、verifyはSecurityExceptionをスローします。

問題は、攻撃者がアーティファクトを置き換えることができれば、通常はその隣にある.sha256ファイルも置き換えることができるということです。**チェックサムは整合性のみを保証し、出所は証明しません。**出所を証明するには、デジタル署名が必要です。

4.2. Sigstore cosignによる署名

従来の方法はGPGで署名することです。これは機能しますが、長期キーの管理は大変です。保存、ローテーション、漏洩時の取り消しが必要です。Sigstoreはkeyless signingと呼ばれる異なるアプローチを提供します。署名する際、cosignツールはOIDC ID(例えばCIワークフローのID)を取得し、短期の証明書をリクエストし、署名してから公開透明性ログに記録を追加します。

CI内でJARファイルに署名:

cosign sign-blob target/order-service.jar \
  --bundle order-service.jar.sigstore.json \
  --yes

.sigstore.jsonファイルには、署名、証明書、および透明性ログに記録された証拠が含まれます。受信側は次のように検証します:

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"

検証が成功したときの結果:

Verified OK

重要なのは2つのパラメータ--certificate-identity*と--certificate-oidc-issuerです。これらを省略すると、「誰かが署名した」ことだけがわかり、署名者があなたのパイプラインであるかどうかはわかりません。コンテナイメージの場合、cosign signとcosign verifyをイメージダイジェストで使用して同様のプロセスを行います。

4.3. パイプラインに統合する

JARとSBOMに署名する最小限のGitHub Actionsジョブ:

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write   # cosignが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

id-token: write権限により、ワークフローはOIDCトークンを取得できます。どのプライベートキーもシークレットに保存しません。bom.jsonに署名することで、受信者はSBOMが本当にそのアーティファクトに付属していることを信頼できます。別の場所から作成されたのではなく。

5. 最初から悪質なパッケージを阻止する

アーティファクト署名はあなたの出力を保護します。このセクションは入力、つまりあなたが取得する依存関係を保護します。

5.1. 制御されたリポジトリを通じてのみ解決する

~/.m2/settings.xml(またはCI内で使用される設定)を構成して、すべてのリクエストが内部リポジトリを通過し、チェックサムが一致しない場合は即座に失敗するようにします:

<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=*はすべてのリポジトリをキャッチします。サードパーティのpom.xmlが自分で宣言したリポジトリであっても、内部プロキシを通過する必要があります。
  • checksumPolicy=failは、チェックサムが異なる場合、警告するのではなくビルドを停止させます。
  • 外部ソースからのスナップショットを無効化します。スナップショットはバージョンを変更せずに内容を変更する可能性があるためです。

dependency confusionに対抗するには、リポジトリプロキシを構成して、内部groupId(例:com.acme.*)が内部リポジトリからのみ取得され、パブリックソースからは取得されないようにします。Maven Centralはpublish時にnamespace所有権の検証を要求するため、他のレジストリよりもリスクは低くなります。しかし、それに完全に依存すべきではありません。

5.2. Maven Enforcerで方針を強制する

Enforcerプラグインはポリシーをビルドエラーに変えます:

<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>
  • bannedDependenciesは、サポートが終了したライブラリまたは既知の脆弱性を持つバージョン範囲をブロックします。このルールは推移的依存関係にも適用されます。
  • requireUpperBoundDepsは、Mavenが別のライブラリが要求するバージョンより低いバージョンを静かに選択した場合にエラーを報告します。
  • requireReleaseDepsは依存関係内のSNAPSHOTを禁止します。

違反すると、ビルドは以下のようなメッセージで停止します:

[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. 小さな習慣、大きな効果

  • 特定のバージョンをピン留めする。[1.0,)やLATESTなどのバージョン範囲は使用しないでください。今日のビルドと明日のビルドが異なる可能性があるため。
  • 新しい依存関係を追加するときはレビューする: 正確な名前、groupId、ユーザー数、リリース履歴を確認します。有名なライブラリに似た名前を持つ無名のライブラリはtyposquattingの兆候です。
  • 更新を自動化する: プルリクエストを作成するボットを使用しますが、マージ前にCIが完全なテストとSBOMスキャンを実行するようにしてください。
  • 依存関係を削減する: クラスパスにないものはあなたを攻撃することはできません。

6. よくある間違いといつやり過ぎないか

注意: SBOMを生成しても誰も読まなければほぼ無駄です。SBOMを自動スキャンステップと、結果を処理する責任を負う担当者またはチームに結び付けてください。

よくある間違い:

  • アーティファクトに署名しても誰も検証しない。署名は、デプロイステップ(またはKubernetesのadmission controller)が有効な署名のないアーティファクトを拒否する場合にのみ価値があります。
  • アイデンティティをバインドせずに署名を検証する。セクション4.2で述べたように、「署名がある」ことは「私のパイプラインからの署名がある」ことと異なります。
  • ソースコードから生成されたSBOM対最終アーティファクト。パッケージステップでライブラリが追加または削除された場合、SBOMはずれます。デプロイするアーティファクトを作成するのと同じビルドステップでSBOMを生成します。
  • 禁止リストが長すぎて更新されない。その場合、チームはEnforcerを回避する方法を見つけ、ポリシーは無効になります。

投資すべきではない時:

  • プロトタイプ、1回だけ実行される内部スクリプト、学習プロジェクト。バージョンのピン留めとリポジトリミラーの保持で十分です。keyless signingと admission policyを設定するには、提供される価値よりも多くの作業が必要です。
  • チームにまだ脆弱性処理プロセスがない。何百ものCVE警告が同時に到着し、分類する人がいない場合、「alert fatigue」が発生するだけです。SBOMと高重度スキャンから始めて、段階的に展開してください。
  • 完全にエアギャップされた環境。Keyless signingは外部のOIDCサービスと透明性ログに依存しています。この環境では、自己管理キー(GPGまたはキーペアを使用したcosign)で署名する方が適切です。

7. 結論

ソフトウェアサプライチェーンセキュリティは3つの質問を中心に展開します: 何を使用しているか、このアーティファクトは本当に私のものか、そして私が取得しているものは信頼できるか。SBOMは最初の質問に答え、Sigstoreのデジタル署名は2番目の質問に答え、制御されたリポジトリとMaven Enforcerは3番目の質問に答えるのに役立ちます。

すべてを一日で実装する必要はありません。合理的なロードマップは、バージョンのピン留めと内部ミラーの使用から始めて、各ビルドでSBOMを生成し、Enforcerを追加し、最後にアーティファクトに署名してデプロイステップで強制検証を行うことです。各ステップはリスクを明らかに減らし、既存のパイプラインで自動化できます。

securitysupply-chainsbommavendevsecops