소프트웨어 공급망 보안: 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 서명과 배포 단계에서 필수 검증 적용입니다. 각 단계는 명확한 위험 감소를 가져오며 기존 파이프라인에서 자동화할 수 있습니다.