Software supply chain security: SBOM, ký artifact và chống package độc hại

1. Tổng quan

Một ứng dụng Spring Boot thông thường chỉ có vài nghìn dòng code do bạn viết. Phần còn lại đến từ hàng trăm thư viện bên thứ ba, từ build tool, CI runner và container base image. Mỗi mắt xích đó đều có thể bị tấn công. Đây là bài toán software supply chain security.

Bài viết đi qua ba trụ cột thực tế: sinh SBOM để biết mình đang dùng gì, ký artifact để chứng minh artifact không bị sửa, và chặn package độc hại ngay từ lúc resolve dependency. Đọc xong, bạn có thể bổ sung các bước này vào một dự án Maven và pipeline CI có sẵn.

2. Vì sao supply chain đang được quan tâm

Kẻ tấn công nhận ra rằng tấn công một thư viện phổ biến hiệu quả hơn nhiều so với tấn công từng ứng dụng. Chỉ cần chèn mã độc vào một package, họ có thể chạm tới mọi dự án dùng package đó.

Các kiểu tấn công thường gặp:

  • Typosquatting: đăng package có tên gần giống package thật, ví dụ sai một ký tự, rồi chờ lập trình viên gõ nhầm.
  • Dependency confusion: đăng lên registry công khai một package trùng tên với package nội bộ, nhưng có version cao hơn. Build tool cấu hình sai sẽ chọn bản công khai.
  • Chiếm quyền maintainer: tài khoản của người duy trì thư viện bị lộ, rồi một bản phát hành “hợp lệ” chứa mã độc được đẩy lên.
  • Tấn công hệ thống build: mã nguồn sạch, nhưng CI runner hoặc build script bị can thiệp, nên artifact đầu ra bị nhiễm.
  • Lỗ hổng trong dependency bắc cầu (transitive): bạn không khai báo trực tiếp thư viện đó, nhưng nó vẫn nằm trong classpath.

Song song với đó, nhiều tổ chức và khách hàng doanh nghiệp bắt đầu yêu cầu nhà cung cấp phần mềm nộp SBOM và chứng minh nguồn gốc build. Vì vậy chủ đề này không còn là “việc của đội bảo mật” mà đã thành yêu cầu kỹ thuật thường ngày.

3. SBOM: biết mình đang chạy gì

SBOM (Software Bill of Materials) là danh sách đầy đủ các thành phần trong một phần mềm, gồm tên, version, license, hash và quan hệ phụ thuộc. Hãy hình dung nó như bảng thành phần in trên bao bì thực phẩm.

Hai định dạng phổ biến là CycloneDX và SPDX. Hệ sinh thái Java hỗ trợ CycloneDX khá tốt, nên bài này dùng định dạng đó.

3.1. Sinh SBOM bằng Maven

Thêm plugin CycloneDX vào pom.xml:

<properties>
    <!-- Thay bằng phiên bản mới nhất bạn kiểm tra được trên 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>

Giải thích:

  • makeAggregateBom gom dependency của mọi module trong dự án multi-module vào một file.
  • Gắn vào phase package để SBOM luôn được sinh cùng file JAR, không ai phải nhớ chạy riêng.
  • includeTestScope=false loại JUnit, Mockito… khỏi SBOM, vì chúng không có mặt lúc runtime.

Chạy build:

mvn clean package

Plugin tạo file target/bom.json. Một phần nội dung trông như sau:

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

Trường quan trọng nhất là purl (Package URL). Đây là định danh chuẩn mà các công cụ quét lỗ hổng dùng để đối chiếu với cơ sở dữ liệu CVE.

3.2. Dùng SBOM để làm gì

Bản thân SBOM không bảo vệ gì cả. Giá trị của nó nằm ở việc bạn dùng nó:

  • Quét lỗ hổng: đưa bom.json vào một công cụ quét SBOM trong CI. Khi có CVE mới, bạn quét lại SBOM cũ mà không cần build lại.
  • Trả lời câu hỏi “chúng ta có bị ảnh hưởng không?”: khi một lỗ hổng lớn xuất hiện, bạn tìm theo purl trong kho SBOM của tất cả service, thay vì đi hỏi từng team.
  • Kiểm tra license: phát hiện thư viện có license không phù hợp với chính sách công ty.

Spring Boot 3.x ở các phiên bản gần đây còn có actuator endpoint sbom. Khi file SBOM được đóng gói vào ứng dụng (dùng spring-boot-starter-parent kèm plugin CycloneDX), bạn có thể bật endpoint này:

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

Gọi GET /actuator/sbom để xem SBOM của chính instance đang chạy. Cách này hữu ích khi bạn cần biết chính xác bản nào đang chạy trên production.

Lưu ý: SBOM liệt kê đầy đủ thư viện và version, nên kẻ tấn công có thể dùng nó để dò lỗ hổng. Đừng public endpoint sbom ra Internet. Hãy giới hạn nó trong mạng nội bộ hoặc đặt sau xác thực.

4. Ký artifact: chứng minh artifact không bị sửa

SBOM cho biết artifact chứa gì. Chữ ký số cho biết artifact đến từ đâu và chưa bị chỉnh sửa kể từ lúc ký.

4.1. Checksum: cần nhưng chưa đủ

Mức đơn giản nhất là kiểm tra hash. Lớp sau tính SHA-256 của một file và so với giá trị kỳ vọng:

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

Điểm đáng chú ý:

  • File được đọc theo từng block 8 KB, nên artifact lớn cũng không làm đầy bộ nhớ.
  • HexFormat (có từ Java 17) chuyển byte sang chuỗi hex mà không cần thư viện ngoài.
  • MessageDigest.isEqual so sánh trong thời gian không đổi. Ở đây chuyện này không quá quan trọng, nhưng đó là thói quen tốt khi so sánh giá trị liên quan đến bảo mật.

Test cho hành vi chính, dùng JUnit 5 và 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");
    }
}

Chỉ cần thêm một ký tự ! là hash đã khác hoàn toàn, và verify ném SecurityException.

Vấn đề là: nếu kẻ tấn công thay được artifact, họ thường cũng thay được file .sha256 nằm cạnh nó. Checksum chỉ đảm bảo tính toàn vẹn, không chứng minh nguồn gốc. Để chứng minh nguồn gốc, bạn cần chữ ký số.

4.2. Ký bằng Sigstore cosign

Cách truyền thống là ký bằng GPG. Cách này chạy được, nhưng quản lý khóa dài hạn rất mệt: phải lưu trữ, xoay vòng, thu hồi khi lộ. Sigstore đưa ra hướng khác gọi là keyless signing. Khi ký, công cụ cosign lấy danh tính OIDC (ví dụ danh tính của workflow CI), xin một chứng chỉ ngắn hạn, ký, rồi ghi bản ghi vào một transparency log công khai.

Ký một file JAR trong CI:

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

File .sigstore.json chứa chữ ký, chứng chỉ và bằng chứng đã ghi vào transparency log. Bên nhận xác minh như sau:

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"

Kết quả khi hợp lệ:

Verified OK

Điều quan trọng là hai tham số --certificate-identity* và --certificate-oidc-issuer. Nếu bỏ chúng, bạn chỉ biết “ai đó đã ký”, chứ không biết người ký có phải pipeline của bạn hay không. Với container image, quy trình tương tự dùng cosign sign và cosign verify trên image digest.

4.3. Đưa vào pipeline

Một job GitHub Actions tối giản, ký JAR kèm SBOM:

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write   # cần để cosign lấy 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

Quyền id-token: write cho phép workflow lấy OIDC token. Bạn không lưu private key nào trong secrets. Ký cả bom.json giúp bên nhận tin rằng SBOM thật sự đi kèm với artifact đó, chứ không phải bị ghép từ chỗ khác.

5. Chặn package độc hại từ đầu

Ký artifact bảo vệ đầu ra của bạn. Phần này bảo vệ đầu vào, tức các dependency bạn kéo về.

5.1. Chỉ resolve qua một repository có kiểm soát

Cấu hình ~/.m2/settings.xml (hoặc settings dùng trong CI) để mọi request đi qua repository nội bộ, đồng thời fail ngay khi checksum không khớp:

<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=* bắt mọi repository, kể cả repository mà một pom.xml bên thứ ba tự khai báo, phải đi qua proxy nội bộ.
  • checksumPolicy=fail khiến build dừng thay vì chỉ cảnh báo khi checksum sai.
  • Tắt snapshot từ nguồn ngoài, vì snapshot có thể thay đổi nội dung mà không đổi version.

Để chống dependency confusion, hãy cấu hình repository proxy sao cho groupId nội bộ (ví dụ com.acme.*) chỉ được lấy từ repository nội bộ, không bao giờ lấy từ nguồn công khai. Maven Central yêu cầu xác minh quyền sở hữu namespace khi publish, nên rủi ro thấp hơn một số registry khác, nhưng bạn vẫn không nên dựa hoàn toàn vào điều đó.

5.2. Ép chính sách bằng Maven Enforcer

Plugin Enforcer biến chính sách thành lỗi 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>
  • bannedDependencies chặn thư viện đã ngừng hỗ trợ hoặc các dải version có lỗ hổng đã biết. Quy tắc này áp dụng cả cho dependency bắc cầu.
  • requireUpperBoundDeps báo lỗi khi Maven âm thầm chọn một version thấp hơn version mà thư viện khác yêu cầu.
  • requireReleaseDeps cấm SNAPSHOT trong dependency.

Khi vi phạm, build dừng với thông báo kiểu:

[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. Thói quen nhỏ, hiệu quả lớn

  • Pin version cụ thể. Không dùng version range như [1.0,) hay LATEST, vì build hôm nay và ngày mai có thể khác nhau.
  • Review khi thêm dependency mới: kiểm tra tên chính xác, groupId, số người dùng, lịch sử phát hành. Một thư viện vô danh có tên gần giống thư viện nổi tiếng là dấu hiệu typosquatting.
  • Tự động hoá việc cập nhật bằng bot tạo pull request, nhưng vẫn để CI chạy đầy đủ test và quét SBOM trước khi merge.
  • Giảm dependency: thứ không có trong classpath thì không thể tấn công bạn.

6. Lỗi hay gặp và khi nào không nên làm quá

Lưu ý: Sinh SBOM nhưng không ai đọc thì gần như vô ích. Hãy gắn SBOM với một bước quét tự động và một người hoặc team chịu trách nhiệm xử lý kết quả.

Một số lỗi thường gặp:

  • Ký artifact nhưng không ai xác minh. Chữ ký chỉ có giá trị khi bước deploy (hoặc admission controller trên Kubernetes) từ chối artifact không có chữ ký hợp lệ.
  • Xác minh chữ ký mà không ràng buộc danh tính. Như đã nói ở mục 4.2, “có chữ ký” khác hẳn “có chữ ký từ pipeline của tôi”.
  • SBOM sinh từ mã nguồn thay vì từ artifact cuối cùng. Nếu bước đóng gói thêm hoặc loại bớt thư viện, SBOM sẽ sai lệch. Hãy sinh SBOM ở cùng bước build tạo ra artifact deploy.
  • Danh sách cấm quá dài và không được cập nhật. Lúc đó team sẽ tìm cách bỏ qua Enforcer, và chính sách mất tác dụng.

Khi nào không nên đầu tư nặng:

  • Prototype, script nội bộ chạy một lần, dự án học tập. Pin version và giữ một repository mirror là đủ. Thiết lập keyless signing cùng chính sách admission sẽ tốn nhiều công hơn giá trị mang lại.
  • Team chưa có quy trình xử lý lỗ hổng. Hàng trăm cảnh báo CVE đổ về cùng lúc mà không có người phân loại chỉ tạo ra “alert fatigue”. Hãy bắt đầu bằng SBOM và quét ở mức nghiêm trọng cao, rồi mở rộng dần.
  • Môi trường air-gapped hoàn toàn. Keyless signing phụ thuộc vào dịch vụ OIDC và transparency log bên ngoài. Trong môi trường này, ký bằng khóa tự quản lý (GPG hoặc cosign với key pair) phù hợp hơn.

7. Kết luận

Software supply chain security xoay quanh ba câu hỏi: tôi đang dùng gì, artifact này có đúng là của tôi không, và thứ tôi kéo về có đáng tin không. SBOM trả lời câu hỏi thứ nhất, chữ ký số với Sigstore trả lời câu hỏi thứ hai, còn repository có kiểm soát cùng Maven Enforcer giúp trả lời câu hỏi thứ ba.

Bạn không cần áp dụng tất cả trong một ngày. Một lộ trình hợp lý là: pin version và dùng mirror nội bộ, sau đó sinh SBOM trong mỗi build, rồi thêm Enforcer, và cuối cùng là ký artifact kèm xác minh bắt buộc ở bước deploy. Mỗi bước đều giảm rủi ro rõ rệt, và đều có thể tự động hoá trong pipeline bạn đang có.

securitysupply-chainsbommavendevsecops