소프트웨어 공급망 보안: SBOM, artifact 서명, 악성 패키지 차단

1. 개요

일반적인 Spring Boot 애플리케이션은 보통 몇 천 줄의 자체 코드만 포함합니다. 나머지는 수백 개의 서드파티 라이브러리, 빌드 도구, CI 러너, 그리고 컨테이너 베이스 이미지에서 나옵니다. 이 모든 체인의 각 고리가 공격받을 수 있습니다. 이것이 바로 소프트웨어 공급망 보안 문제입니다.

이 글은 세 가지 실무적 핵심 요소를 다룹니다: 무엇을 사용 중인지 알기 위해 SBOM을 생성하고, artifact가 수정되지 않았음을 증명하기 위해 artifact에 서명하고, 의존성을 해결할 때부터 악성 패키지를 차단합니다. 이 글을 다 읽고 나면 기존 Maven 프로젝트와 CI 파이프라인에 이러한 단계들을 추가할 수 있습니다.

2. 공급망이 주목받는 이유

공격자들은 각각의 애플리케이션을 공격하는 것보다 대중적인 라이브러리를 공격하는 것이 훨씬 효율적임을 깨달았습니다. 단 하나의 패키지에 악성 코드를 삽입하면 그 패키지를 사용하는 모든 프로젝트에 도달할 수 있습니다.

흔히 발생하는 공격 유형들:

  • 타이포스쿼팅: 실제 패키지와 비슷한 이름(한 글자만 다른)으로 패키지를 등록하고 프로그래머의 오타를 기다립니다.
  • 의존성 혼동: 공개 저장소에 내부 패키지와 같은 이름으로 패키지를 등록하되, 더 높은 버전으로 올립니다. 잘못 설정된 빌드 도구는 공개 버전을 선택합니다.
  • 유지보수자 권한 탈취: 라이브러리 유지보수자의 계정이 노출되면 악성 코드가 포함된 “정상적인” 릴리스가 업로드됩니다.
  • 빌드 시스템 공격: 소스 코드는 깨끗하지만 CI 러너나 빌드 스크립트가 조작되어 최종 artifact가 감염됩니다.
  • 전이 의존성의 취약점: 직접 선언하지 않은 라이브러리이지만 classpath에 포함됩니다.

동시에 많은 조직과 엔터프라이즈 고객들이 소프트웨어 공급업체에 SBOM 제출과 빌드 출처 증명을 요청하기 시작했습니다. 따라서 이 주제는 더 이상 “보안팀의 일”이 아니라 일상적인 엔지니어링 요구사항이 되었습니다.

3. SBOM: 무엇을 실행 중인지 알기

**SBOM(Software Bill of Materials)**은 소프트웨어에 포함된 모든 구성 요소의 완전한 목록으로, 이름, 버전, 라이선스, 해시 및 의존성 관계를 포함합니다. 식품 포장재에 인쇄된 성분 표시처럼 생각하면 됩니다.

가장 일반적인 두 가지 형식은 CycloneDX와 SPDX입니다. Java 생태계는 CycloneDX를 꽤 잘 지원하므로 이 글에서는 이 형식을 사용합니다.

3.1. Maven으로 SBOM 생성하기

pom.xml에 CycloneDX 플러그인을 추가합니다:

<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은 멀티모듈 프로젝트의 모든 모듈의 의존성을 하나의 파일로 통합합니다.
  • 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 자체는 아무것도 보호하지 않습니다. 가치는 SBOM을 어떻게 사용하는지에 있습니다:

  • 취약점 스캔: bom.json을 CI의 SBOM 스캔 도구에 전달합니다. 새 CVE가 나타나면 빌드 없이 이전 SBOM을 다시 스캔할 수 있습니다.
  • “우리가 영향받나요?” 질문에 답하기: 큰 취약점이 발견되면 purl을 사용하여 모든 서비스의 SBOM 저장소를 검색합니다.
  • 라이선스 검토: 회사 정책과 맞지 않는 라이선스의 라이브러리를 발견합니다.

Spring Boot 3.x의 최근 버전에는 actuator 엔드포인트 sbom이 있습니다. SBOM 파일이 애플리케이션에 패키지되면(Spring Boot 플러그인 기본 설정과 CycloneDX 플러그인 사용), 이 엔드포인트를 활성화할 수 있습니다:

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

GET /actuator/sbom을 호출하면 실행 중인 인스턴스의 SBOM을 볼 수 있습니다. 프로덕션에서 정확히 어떤 버전이 실행 중인지 알아야 할 때 유용합니다.

주의: SBOM은 모든 라이브러리와 버전을 나열하므로 공격자가 이를 통해 취약점을 탐사할 수 있습니다. sbom 엔드포인트를 인터넷에 공개하지 마세요. 내부 네트워크로 제한하거나 인증 뒤에 두세요.

4. Artifact 서명: artifact가 수정되지 않았음을 증명하기

SBOM은 artifact가 무엇을 포함하는지 알려줍니다. 디지털 서명은 artifact가 어디에서 왔는지와 수정되지 않았는지를 증명합니다.

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(
                    "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);
        }
    }
}

주목할 점:

  • 파일은 8KB씩 블록 단위로 읽히므로 큰 artifact도 메모리를 채우지 않습니다.
  • 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");
    }
}

! 한 글자만 추가되어도 해시가 완전히 달라지고, verify는 SecurityException을 던집니다.

문제는 공격자가 artifact를 바꾸면 대개 옆에 있는 .sha256 파일도 바꿀 수 있다는 것입니다. 체크섬은 무결성만 보장하고 출처를 증명하지는 않습니다. 출처를 증명하려면 디지털 서명이 필요합니다.

4.2. Sigstore cosign으로 서명하기

기존 방식은 GPG로 서명하는 것입니다. 이것도 작동하지만 장기 키 관리가 매우 번거롭습니다: 저장, 순환, 노출 시 취소. Sigstore는 키리스 서명이라는 다른 접근 방식을 제시합니다. 서명할 때 cosign 도구는 OIDC 신원(예: CI 워크플로의 신원)을 사용하여 단기 인증서를 요청하고, 서명한 다음 기록을 공개 투명성 로그에 기록합니다.

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

중요한 것은 --certificate-identity*와 --certificate-oidc-issuer 두 매개변수입니다. 이 둘을 빼면 “누군가가 서명했다”는 것만 알지만 서명자가 당신의 파이프라인인지는 알 수 없습니다. 컨테이너 이미지의 경우 이미지 digest에 대해 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 토큰을 가져올 수 있게 합니다. secrets에 저장된 private 키가 없습니다. bom.json에도 서명하면 수신자는 SBOM이 정말 그 artifact와 함께 제공된 것이고 다른 곳에서 섞인 것이 아님을 신뢰할 수 있습니다.

5. 처음부터 악성 패키지 차단하기

Artifact 서명은 당신의 산출물을 보호합니다. 이 섹션은 입력을 보호합니다. 즉, 가져오는 의존성입니다.

5.1. 하나의 제어된 저장소를 통해서만 resolve하기

~/.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은 체크섬 오류 시 경고가 아닌 빌드 중단을 일으킵니다.
  • 외부 소스에서 snapshot을 비활성화합니다. snapshot은 버전을 유지한 채 내용이 변할 수 있습니다.

의존성 혼동을 방지하려면 저장소 프록시를 구성하여 내부 groupId(예: com.acme.*)는 내부 저장소에서만 가져오도록 합니다. Maven Central은 publish 시 네임스페이스 소유권을 확인하므로 위험이 낮지만, 그 외 레지스트리는 그렇지 않을 수 있습니다.

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, 사용자 수, 릴리스 이력을 확인합니다. 유명 라이브러리와 비슷한 이름의 무명 라이브러리는 타이포스쿼팅의 신호입니다.
  • 업데이트를 자동화하되 bot으로 pull request를 생성하되, merge 전 CI가 전체 테스트와 SBOM 스캔을 실행하도록 하세요.
  • 의존성을 줄이세요: classpath에 없는 것은 공격받을 수 없습니다.

6. 흔한 오류와 과하면 안 되는 경우

주의: SBOM을 생성하지만 아무도 읽지 않으면 거의 쓸모없습니다. SBOM을 자동 스캔 단계와 연결하고 결과를 처리할 책임 있는 사람이나 팀을 지정하세요.

흔한 오류들:

  • Artifact에 서명하지만 아무도 검증하지 않음. 서명은 배포 단계(또는 Kubernetes admission controller)가 유효한 서명 없는 artifact를 거부할 때만 가치가 있습니다.
  • 서명은 검증하되 신원 확인은 안 함. 섹션 4.2에서 말했듯이 “서명이 있음”은 “내 파이프라인의 서명이 있음”과 완전히 다릅니다.
  • 소스 코드로부터 생성한 SBOM을 최종 artifact로 사용. 패키징 단계에서 라이브러리를 추가하거나 제거하면 SBOM이 부정확해집니다. SBOM을 artifact를 배포하는 것과 동일한 빌드 단계에서 생성하세요.
  • 차단 목록이 너무 길고 업데이트 안 함. 그러면 팀이 Enforcer를 우회하려고 할 것이고 정책이 무효화됩니다.

투자할 필요가 없는 경우:

  • 프로토타입, 일회용 내부 스크립트, 학습 프로젝트. 버전 고정과 repository 미러는 충분합니다. 키리스 서명 설정과 admission 정책은 제공하는 가치보다 훨씬 더 많은 작업이 필요합니다.
  • 팀이 취약점 처리 절차가 없을 때. 수백 개의 CVE 경고가 한꺼번에 쏟아지는데 분류할 사람이 없으면 “경보 피로”만 생깁니다. 심각도 높은 수준의 SBOM과 스캔으로 시작한 후 점진적으로 확대하세요.
  • 완전히 격리된 환경. 키리스 서명은 외부의 OIDC 서비스와 투명성 로그에 의존합니다. 이 환경에서는 자체 관리 키(GPG 또는 cosign with key pair)로 서명하는 것이 더 적합합니다.

7. 결론

소프트웨어 공급망 보안은 세 가지 질문을 중심으로 합니다: 내가 무엇을 사용 중인가, 이 artifact가 정말 내 것인가, 그리고 가져오는 것을 신뢰할 수 있는가. SBOM은 첫 번째 질문에 답하고, Sigstore를 사용한 디지털 서명은 두 번째 질문에 답하고, 제어된 저장소와 Maven Enforcer는 세 번째 질문에 답하도록 도와줍니다.

하루 만에 모든 것을 적용할 필요는 없습니다. 합리적인 로드맵은: 버전 고정 및 내부 미러 사용, 모든 빌드에서 SBOM 생성, Enforcer 추가, 마지막으로 artifact 서명과 배포 단계에서 필수 검증 적용입니다. 각 단계는 명확한 위험 감소를 가져오며 기존 파이프라인에서 자동화할 수 있습니다.

securitysupply-chainsbommavendevsecops