我们团队的敏捷节奏是两周一个迭代,CI/CD流水线每天要跑上百次。最近一个季度的复盘会上,一个潜在风险被摆上了桌面:我们如何确保部署到生产环境的JAR包,就是CI服务器上经过完整测试、最终构建出来的那个?长久以来,我们依赖的是Artifactory的内置校验和以及看似隔离的网络环境,这在内部威胁和复杂的供应链攻击面前显得非常脆弱。有人提出在部署前手动比对SHA-256哈希,但这立刻被否决了——任何需要手动介入的卡点都是对敏捷交付的直接破坏。
这个痛点促使我们立项,目标是在不牺牲交付速度的前提下,为我们的构建产物建立一套可追溯、防篡改的信任链。我们的构想是,在CI流水线中自动地为每个构建成功的Java构件(JAR/WAR)生成一个不可否认的“身份证明”——也就是数字签名。
初步构想与技术选型决策
最初的构想很简单:用GPG签名。但很快我们发现了问题。在自动化的CI环境中管理GPG私钥是个大麻烦。将私钥以环境变量或Secret文件的形式注入到临时的CI Runner中,本身就带来了新的攻击面。一个常见的错误是,为了图方便,团队共享一个长期有效的GPG密钥,一旦泄露,后果不堪设想。
受社区Sigstore项目“keyless signing”理念的启发,我们决定探索一种更适合CI/CD场景的方案。完全自研一套基于OIDC和透明日志的系统过于复杂,不符合敏捷“小步快跑”的原则。因此,我们做了一个折中且务实的决策:利用云服务商提供的密钥管理服务(KMS)或IAM的短期凭证来执行签名操作。签名的密钥由云服务商管理,CI Runner通过扮演特定角色(Role)获得临时的签名权限。这样,静态的、长期的私钥就从我们的工作流中消失了。
我们的技术栈是Java,所以这套工具链也必须是Java原生的,以便无缝集成到现有的Maven/Gradle构建流程中。
最终的技术选型如下:
- 核心语言: Java 17。
- 密码学库: Bouncy Castle。虽然Java标准库提供了JCA/JCE,但Bouncy Castle在算法支持和灵活性上更胜一筹。
- 签名算法:
SHA256withECDSA。相比RSA,ECDSA有更好的性能和更短的密钥长度。 - “身份证明”结构: 我们称之为“构件证明”(Artifact Attestation)。它是一个JSON对象,不仅包含构件本身的哈希,还包含了构建上下文信息,如Git Commit ID、构建环境标识、时间戳,以及最重要的——软件物料清单(SBOM)的哈希。这个证明文件本身将被签名。
- SBOM格式: CycloneDX XML。它是业界标准,生态工具支持良好。
步骤化实现:构建签名与验证的核心逻辑
整个项目被拆分成几个核心Java模块,可以打包成一个命令行工具,方便在CI脚本中调用。
1. 定义构件证明(Attestation)的数据模型
这是信任链的基石。一个清晰、全面的数据模型至关重要。我们使用Jackson库来处理JSON序列化。
// File: io/devsecops/attestation/ArtifactAttestation.java
package io.devsecops.attestation;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import java.time.Instant;
import java.util.Objects;
/**
* 构件证明的数据模型.
* 它包含了验证一个构件来源和完整性所需的所有元数据.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ArtifactAttestation {
private static final ObjectMapper MAPPER = new ObjectMapper()
.enable(SerializationFeature.INDENT_OUTPUT);
@JsonProperty("schema_version")
private final String schemaVersion = "1.0";
@JsonProperty("timestamp")
private final long timestamp;
@JsonProperty("build_context")
private final BuildContext buildContext;
@JsonProperty("artifact_identity")
private final ArtifactIdentity artifactIdentity;
public ArtifactAttestation(BuildContext buildContext, ArtifactIdentity artifactIdentity) {
this.timestamp = Instant.now().getEpochSecond();
this.buildContext = Objects.requireNonNull(buildContext, "buildContext cannot be null");
this.artifactIdentity = Objects.requireNonNull(artifactIdentity, "artifactIdentity cannot be null");
}
// Getters...
public String toJson() throws JsonProcessingException {
return MAPPER.writeValueAsString(this);
}
public static class BuildContext {
@JsonProperty("builder_id")
private final String builderId; // e.g., "jenkins-runner-abc-123"
@JsonProperty("git_commit_hash")
private final String gitCommitHash;
public BuildContext(String builderId, String gitCommitHash) {
this.builderId = builderId;
this.gitCommitHash = gitCommitHash;
}
// Getters...
}
public static class ArtifactIdentity {
@JsonProperty("artifact_sha256")
private final String artifactSha256;
@JsonProperty("sbom_sha256")
private final String sbomSha256; // CycloneDX SBOM hash
public ArtifactIdentity(String artifactSha256, String sbomSha256) {
this.artifactSha256 = artifactSha256;
this.sbomSha256 = sbomSha256;
}
// Getters...
}
}
这个模型非常清晰。BuildContext 描述了“在哪”和“从哪”构建,而 ArtifactIdentity 则描述了构建“是什么”。
2. 实现核心签名服务 ArtifactSigner
这是流水线中“盖章”的环节。真实项目中,sign 方法内部会调用云KMS的API。为了让代码可独立运行和测试,我们先用本地生成的密钥对来模拟。
// File: io/devsecops/signer/ArtifactSigner.java
package io.devsecops.signer;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.*;
import java.util.Base64;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import io.devsecops.attestation.ArtifactAttestation;
/**
* 负责对构件证明进行签名.
* 真实项目中,PrivateKey应由KMS或Hardware Security Module管理.
*/
public class ArtifactSigner {
private static final String SIGNATURE_ALGORITHM = "SHA256withECDSA";
static {
// 注册Bouncy Castle作为安全提供者
Security.addProvider(new BouncyCastleProvider());
}
private final PrivateKey privateKey;
public ArtifactSigner(PrivateKey privateKey) {
this.privateKey = Objects.requireNonNull(privateKey, "Private key cannot be null");
}
/**
* 对给定的证明对象进行签名.
* @param attestation 构件证明
* @return Base64编码的签名字符串
* @throws GeneralSecurityException 如果签名过程中发生密码学错误
* @throws IOException 如果无法将证明序列化为JSON
*/
public String sign(ArtifactAttestation attestation) throws GeneralSecurityException, IOException {
Signature ecdsaSign = Signature.getInstance(SIGNATURE_ALGORITHM, "BC");
ecdsaSign.initSign(privateKey);
byte[] attestationBytes = attestation.toJson().getBytes(StandardCharsets.UTF_8);
ecdsaSign.update(attestationBytes);
byte[] signature = ecdsaSign.sign();
return Base64.getEncoder().encodeToString(signature);
}
/**
* 辅助方法,用于计算文件的SHA-256哈希.
* @param filePath 文件路径
* @return 文件的SHA-256哈希值(小写十六进制字符串)
* @throws IOException 如果读取文件失败
* @throws NoSuchAlgorithmException 如果SHA-256算法不可用
*/
public static String calculateSha256(Path filePath) throws IOException, NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream fis = Files.newInputStream(filePath)) {
byte[] byteArray = new byte[8192];
int bytesCount;
while ((bytesCount = fis.read(byteArray)) != -1) {
digest.update(byteArray, 0, bytesCount);
}
}
byte[] bytes = digest.digest();
StringBuilder sb = new StringBuilder();
for (byte aByte : bytes) {
sb.append(Integer.toString((aByte & 0xff) + 0x100, 16).substring(1));
}
return sb.toString();
}
/**
* 用于本地测试的辅助方法,生成一个EC密钥对.
*/
public static KeyPair generateKeyPair() throws GeneralSecurityException {
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("EC", "BC");
keyGen.initialize(256, new SecureRandom());
return keyGen.generateKeyPair();
}
}
这里的坑在于,对attestation对象签名时,必须确保其序列化后的JSON字节流是稳定且确定的。任何微小的格式变化(如字段顺序、空格)都会导致签名验证失败。这就是为什么在生产代码中,我们通常会对JSON进行规范化(Canonicalization)处理,但对于这个场景,只要保证序列化和验证时使用相同的逻辑即可。
3. 实现核心验证服务 ArtifactVerifier
这是部署流程中的“验票”环节,是安全保障的最后一道防线。
// File: io/devsecops/verifier/ArtifactVerifier.java
package io.devsecops.verifier;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.devsecops.attestation.ArtifactAttestation;
import io.devsecops.signer.ArtifactSigner;
import java.io.IOException;
import java.nio.file.Path;
import java.security.*;
import java.util.Base64;
import java.util.Objects;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
/**
* 负责验证构件的签名和证明.
*/
public class ArtifactVerifier {
private static final String SIGNATURE_ALGORITHM = "SHA256withECDSA";
private static final ObjectMapper MAPPER = new ObjectMapper();
static {
Security.addProvider(new BouncyCastleProvider());
}
private final PublicKey publicKey;
public ArtifactVerifier(PublicKey publicKey) {
this.publicKey = Objects.requireNonNull(publicKey, "Public key cannot be null");
}
/**
* 核心验证方法.
* @param attestationJson 证明文件的JSON内容
* @param signatureBase64 Base64编码的签名
* @param artifactPath 待验证的构件文件路径
* @param sbomPath 待验证的SBOM文件路径
* @return 如果验证通过,返回true
* @throws GeneralSecurityException 密码学相关错误
* @throws IOException 文件IO或JSON解析错误
*/
public boolean verify(String attestationJson, String signatureBase64, Path artifactPath, Path sbomPath)
throws GeneralSecurityException, IOException {
// 1. 验证签名本身是否有效
byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64);
Signature ecdsaVerify = Signature.getInstance(SIGNATURE_ALGORITHM, "BC");
ecdsaVerify.initVerify(publicKey);
ecdsaVerify.update(attestationJson.getBytes(StandardCharsets.UTF_8));
if (!ecdsaVerify.verify(signatureBytes)) {
// 日志记录:签名校验失败
System.err.println("Verification failed: Signature is invalid for the given attestation.");
return false;
}
// 2. 签名有效,现在验证证明内容是否与实际文件匹配
ArtifactAttestation attestation = MAPPER.readValue(attestationJson, ArtifactAttestation.class);
String expectedArtifactHash = attestation.getArtifactIdentity().getArtifactSha256();
String actualArtifactHash = ArtifactSigner.calculateSha256(artifactPath);
if (!expectedArtifactHash.equals(actualArtifactHash)) {
// 日志记录:构件哈希不匹配
System.err.printf("Verification failed: Artifact hash mismatch. Expected: %s, Actual: %s%n",
expectedArtifactHash, actualArtifactHash);
return false;
}
String expectedSbomHash = attestation.getArtifactIdentity().getSbomSha256();
String actualSbomHash = ArtifactSigner.calculateSha256(sbomPath);
if (!expectedSbomHash.equals(actualSbomHash)) {
// 日志记录:SBOM哈希不匹配
System.err.printf("Verification failed: SBOM hash mismatch. Expected: %s, Actual: %s%n",
expectedSbomHash, actualSbomHash);
return false;
}
// 3. (可选) 可以在此添加策略检查,例如检查构建时间是否在合理范围内,构建者ID是否可信等
return true;
}
}
验证逻辑必须严密,分为两步:首先,确认签名对于证明文件是有效的,这保证了证明文件本身未被篡改;其次,确认证明文件中的哈希值与实际文件的哈希值一致,这保证了构件和SBOM也未被篡改。
4. 集成到CI/CD流水线
这是将技术落地到敏捷流程的关键。我们将上述Java代码打包成一个可执行的JAR (devsecops-tool.jar)。下面是一个简化的GitHub Actions工作流示例:
# .github/workflows/build-and-sign.yml
name: Build, Sign, and Verify
on:
push:
branches: [ "main" ]
jobs:
build-and-sign:
runs-on: ubuntu-latest
# 假设配置了云服务商的OIDC,可以获取临时凭证来调用KMS
permissions:
id-token: write
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Build with Maven and Generate SBOM
run: |
mvn package
# 使用CycloneDX Maven插件生成SBOM
mvn org.cyclonedx:cyclonedx-maven-plugin:makeAggregateBom
- name: Download DevSecOps Tool
run: |
# 从一个安全的位置下载我们的签名工具
wget https://internal-repo/devsecops-tool.jar
- name: Sign the Artifact
id: sign
run: |
# 实际场景中,这里的密钥管理会通过IAM角色和KMS API完成
# 为演示,我们生成临时密钥并导出公钥
java -jar devsecops-tool.jar generate-key-pair --private-key-out private.key --public-key-out public.pem
# 调用签名命令
java -jar devsecops-tool.jar sign \
--artifact-path target/my-app-1.0.jar \
--sbom-path target/bom.xml \
--private-key private.key \
--attestation-out target/attestation.json \
--signature-out target/signature.sig \
--builder-id "${{ github.run_id }}" \
--git-commit "${{ github.sha }}"
- name: Upload Artifacts
uses: actions/upload-artifact@v3
with:
name: signed-release
path: |
target/my-app-1.0.jar
target/bom.xml
target/attestation.json
target/signature.sig
public.pem # 上传公钥用于后续验证
deploy:
needs: build-and-sign
runs-on: ubuntu-latest
steps:
- name: Download Signed Artifacts
uses: actions/download-artifact@v2
with:
name: signed-release
- name: Download DevSecOps Tool
run: wget https://internal-repo/devsecops-tool.jar
- name: Pre-Deployment Verification
run: |
java -jar devsecops-tool.jar verify \
--artifact-path my-app-1.0.jar \
--sbom-path bom.xml \
--attestation-path attestation.json \
--signature-path signature.sig \
--public-key public.pem
- name: Deploy to Staging
run: |
echo "Verification successful! Deploying..."
# ... 部署脚本 ...
这个流水线清晰地展示了“构建->签名->发布->验证->部署”的流程,实现了安全检查的自动化左移,完全融入了敏捷开发的高速迭代中。
最终成果:一条自动化的信任链
经过一个迭代的开发和测试,我们成功地将这套机制集成到了核心产品的CI/CD流水线中。现在,每次构建都会在Artifactory中产生五个文件:my-app.jar, bom.xml, attestation.json, signature.sig, 以及一个指向本次构建所用公钥的引用。
graph TD
subgraph "CI/CD Pipeline"
A[Git Push on main] --> B{Maven Build & Test};
B --> C[Generate JAR & CycloneDX SBOM];
C --> D[Execute devsecops-tool.jar sign];
D --> E{Generate attestation.json & signature.sig};
E --> F[Publish All Artifacts to Artifactory];
end
subgraph "Deployment Workflow (e.g., ArgoCD Pre-Sync Hook)"
G[Deployment Trigger] --> H{Fetch Artifacts from Artifactory};
H --> I[Execute devsecops-tool.jar verify];
I --> J{Verification Check};
J -- Success --> K[Deploy Container to Kubernetes];
J -- Failure --> L[Abort Deployment & Alert Security Team];
end
F --> H
这个流程的建立,让安全团队和开发团队达成了共识。开发人员无需关心签名的复杂细节,只需确保流水线正常运行。安全和运维团队则获得了一个强有力的控制门禁,任何未经流水线正式签名的构件都无法进入生产环境。
遗留问题与未来迭代
尽管目前的方案解决了我们最紧迫的问题,但在务实的工程师眼中,它远非完美。
首先,公钥的分发和管理依然是一个薄弱环节。目前我们将公钥与构件一起存储,但这依赖于对Artifactory本身的信任。更健壮的方案是建立一个独立的、高可信的公钥基础设施(PKI),或者利用服务网格(如Istio)配合SPIFFE/SPIRE来做身份分发和验证。
其次,我们的ArtifactAttestation是自定义格式。为了更好地与外部工具生态兼容,我们应该迁移到更标准的格式,比如in-toto Attestation Framework。这能让我们利用更多开源工具进行策略执行,例如Open Policy Agent (OPA),实现更复杂的验证规则(如“禁止部署包含高危漏洞的构件”)。
最后,当前的机制保证了“构建->部署”环节的安全,但并未完全覆盖“源码->构建”环节。一个高级的攻击者可能会在构建服务器本身被攻陷的情况下,污染构建过程。解决这个问题,需要引入可复现构建(Reproducible Builds)和更严格的构建环境隔离,这是我们下个季度的探索方向。