234 lines
10 KiB
Go
234 lines
10 KiB
Go
package cisupplychainconformance
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/hex"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net/url"
|
||
|
|
"regexp"
|
||
|
|
"slices"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
CaptureSchema = "remote-ci-supply-chain-capture-v1"
|
||
|
|
ReportSchema = "remote-ci-supply-chain-conformance-report-v1"
|
||
|
|
)
|
||
|
|
|
||
|
|
var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
||
|
|
var commitPattern = regexp.MustCompile(`^[0-9a-f]{40}([0-9a-f]{24})?$`)
|
||
|
|
|
||
|
|
var RequiredChecks = []string{
|
||
|
|
"GO_FORMAT", "GO_VET_TEST", "OPENAPI_GENERATION", "WEB_TYPECHECK_BUILD", "COMPOSE_VALIDATION",
|
||
|
|
"BACKEND_IMAGE_BUILD", "WEB_IMAGE_BUILD", "GO_VULNERABILITY_LICENSE", "FRONTEND_VULNERABILITY_LICENSE",
|
||
|
|
"REPOSITORY_SECRET_SCAN", "INFRASTRUCTURE_CONFIG_SCAN", "BACKEND_IMAGE_SCAN", "WEB_IMAGE_SCAN", "CYCLONEDX_SBOM",
|
||
|
|
}
|
||
|
|
var RequiredImages = []string{"BACKEND_IMAGE", "WEB_IMAGE"}
|
||
|
|
var RequiredSBOMs = []string{"SOURCE", "BACKEND_IMAGE", "WEB_IMAGE"}
|
||
|
|
|
||
|
|
type Capture struct {
|
||
|
|
SchemaVersion string `json:"schema_version"`
|
||
|
|
EvidenceSource string `json:"evidence_source"`
|
||
|
|
CandidateVersion string `json:"candidate_version"`
|
||
|
|
BuildDigest string `json:"build_digest"`
|
||
|
|
SourceCommit string `json:"source_commit"`
|
||
|
|
Provider string `json:"provider"`
|
||
|
|
RepositoryRef string `json:"repository_ref"`
|
||
|
|
WorkflowRef string `json:"workflow_ref"`
|
||
|
|
RunID string `json:"run_id"`
|
||
|
|
RunURL string `json:"run_url"`
|
||
|
|
Trigger string `json:"trigger"`
|
||
|
|
Ref string `json:"ref"`
|
||
|
|
Attempt int `json:"attempt"`
|
||
|
|
Conclusion string `json:"conclusion"`
|
||
|
|
StartedAt time.Time `json:"started_at"`
|
||
|
|
CompletedAt time.Time `json:"completed_at"`
|
||
|
|
CollectedAt time.Time `json:"collected_at"`
|
||
|
|
ExecutedByRef string `json:"executed_by_ref"`
|
||
|
|
ReviewedByRef string `json:"reviewed_by_ref"`
|
||
|
|
ReviewRef string `json:"review_ref"`
|
||
|
|
Checks []CheckResult `json:"checks"`
|
||
|
|
Images []ImageArtifact `json:"images"`
|
||
|
|
SBOMs []SBOMArtifact `json:"sboms"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type CheckResult struct {
|
||
|
|
Code string `json:"code"`
|
||
|
|
Status string `json:"status"`
|
||
|
|
EvidenceRef string `json:"evidence_ref"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type ImageArtifact struct {
|
||
|
|
Subject string `json:"subject"`
|
||
|
|
Reference string `json:"reference"`
|
||
|
|
Digest string `json:"digest"`
|
||
|
|
EvidenceRef string `json:"evidence_ref"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type SBOMArtifact struct {
|
||
|
|
Subject string `json:"subject"`
|
||
|
|
Format string `json:"format"`
|
||
|
|
Digest string `json:"digest"`
|
||
|
|
EvidenceRef string `json:"evidence_ref"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type Check struct {
|
||
|
|
Code string `json:"code"`
|
||
|
|
Status string `json:"status"`
|
||
|
|
Detail string `json:"detail"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type Report struct {
|
||
|
|
SchemaVersion string `json:"schema_version"`
|
||
|
|
Result string `json:"result"`
|
||
|
|
GeneratedAt time.Time `json:"generated_at"`
|
||
|
|
CandidateVersion string `json:"candidate_version"`
|
||
|
|
BuildDigest string `json:"build_digest"`
|
||
|
|
SourceCommit string `json:"source_commit"`
|
||
|
|
EvidenceScope string `json:"evidence_scope"`
|
||
|
|
ExternalValidationRequired bool `json:"external_validation_required"`
|
||
|
|
Provider string `json:"provider"`
|
||
|
|
RunID string `json:"run_id"`
|
||
|
|
RunURL string `json:"run_url"`
|
||
|
|
CompletedChecks []string `json:"completed_checks"`
|
||
|
|
ImageDigests map[string]string `json:"image_digests"`
|
||
|
|
SBOMDigests map[string]string `json:"sbom_digests"`
|
||
|
|
InputSHA256 map[string]string `json:"input_sha256"`
|
||
|
|
Checks []Check `json:"checks"`
|
||
|
|
Disclaimer string `json:"disclaimer"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func ParseCapture(content []byte) (Capture, error) {
|
||
|
|
var capture Capture
|
||
|
|
if err := decodeStrict(content, &capture); err != nil {
|
||
|
|
return Capture{}, fmt.Errorf("decode remote CI supply-chain capture: %w", err)
|
||
|
|
}
|
||
|
|
return capture, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func ParseReport(content []byte) (Report, error) {
|
||
|
|
var report Report
|
||
|
|
if err := decodeStrict(content, &report); err != nil {
|
||
|
|
return Report{}, fmt.Errorf("decode remote CI supply-chain report: %w", err)
|
||
|
|
}
|
||
|
|
return report, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func Evaluate(capture Capture, candidateVersion, buildDigest, sourceCommit string, generatedAt time.Time) Report {
|
||
|
|
candidateVersion = strings.TrimSpace(candidateVersion)
|
||
|
|
buildDigest = strings.ToLower(strings.TrimSpace(buildDigest))
|
||
|
|
sourceCommit = strings.ToLower(strings.TrimSpace(sourceCommit))
|
||
|
|
report := Report{
|
||
|
|
SchemaVersion: ReportSchema, Result: "PASS", GeneratedAt: generatedAt.UTC(), CandidateVersion: candidateVersion,
|
||
|
|
BuildDigest: buildDigest, SourceCommit: sourceCommit, EvidenceScope: "LOCAL_CI_TOOLCHAIN", ExternalValidationRequired: true,
|
||
|
|
Provider: capture.Provider, RunID: capture.RunID, RunURL: capture.RunURL, CompletedChecks: []string{},
|
||
|
|
ImageDigests: map[string]string{}, SBOMDigests: map[string]string{}, InputSHA256: map[string]string{}, Checks: []Check{},
|
||
|
|
Disclaimer: "该报告只核对固定提交、远程CI检查、镜像摘要和SBOM证据的一致性;本地执行不能替代远程CI,也不等于业务发布批准。",
|
||
|
|
}
|
||
|
|
if capture.EvidenceSource == "REMOTE_MANAGED_CI" {
|
||
|
|
report.EvidenceScope = "REMOTE_CI_SUPPLY_CHAIN"
|
||
|
|
}
|
||
|
|
add := func(code string, ok bool, detail string) {
|
||
|
|
status := "PASS"
|
||
|
|
if !ok {
|
||
|
|
status, report.Result = "FAIL", "FAIL"
|
||
|
|
}
|
||
|
|
report.Checks = append(report.Checks, Check{Code: code, Status: status, Detail: detail})
|
||
|
|
}
|
||
|
|
add("CAPTURE_SCHEMA", capture.SchemaVersion == CaptureSchema, "采集必须使用"+CaptureSchema)
|
||
|
|
add("EVIDENCE_SOURCE", capture.EvidenceSource == "REMOTE_MANAGED_CI" || capture.EvidenceSource == "LOCAL_TOOLCHAIN", "证据来源必须明确为远程托管CI或本地工具链")
|
||
|
|
identityOK := candidateVersion != "" && digestPattern.MatchString(buildDigest) && commitPattern.MatchString(sourceCommit)
|
||
|
|
add("CANDIDATE_IDENTITY", identityOK, "候选版本、sha256构建摘要和完整源提交必须有效")
|
||
|
|
add("CAPTURE_CANDIDATE_BINDING", capture.CandidateVersion == candidateVersion && strings.ToLower(capture.BuildDigest) == buildDigest && strings.ToLower(capture.SourceCommit) == sourceCommit, "采集必须绑定同一候选、构建摘要和源提交")
|
||
|
|
|
||
|
|
remote := capture.EvidenceSource == "REMOTE_MANAGED_CI"
|
||
|
|
providerOK := capture.Provider != "" && capture.RepositoryRef != "" && capture.WorkflowRef != "" && capture.RunID != "" && validHTTPS(capture.RunURL)
|
||
|
|
if remote {
|
||
|
|
providerOK = providerOK && !strings.Contains(strings.ToLower(capture.Provider), "local")
|
||
|
|
}
|
||
|
|
add("REMOTE_RUN_IDENTITY", providerOK, "远程运行必须绑定提供器、仓库、工作流、运行编号和HTTPS运行地址")
|
||
|
|
triggerOK := slices.Contains([]string{"PUSH", "WORKFLOW_DISPATCH", "RELEASE"}, capture.Trigger) && capture.Ref != "" && capture.Attempt >= 1 && capture.Conclusion == "SUCCESS"
|
||
|
|
add("REMOTE_RUN_OUTCOME", triggerOK, "候选运行必须由受控触发、成功结束并记录ref和尝试次数")
|
||
|
|
timelineOK := !capture.StartedAt.IsZero() && !capture.CompletedAt.Before(capture.StartedAt) && !capture.CollectedAt.Before(capture.CompletedAt)
|
||
|
|
add("RUN_TIMELINE", timelineOK, "开始、完成和采集时间必须单调")
|
||
|
|
reviewOK := capture.ExecutedByRef != "" && capture.ReviewedByRef != "" && capture.ReviewRef != ""
|
||
|
|
if remote {
|
||
|
|
reviewOK = reviewOK && capture.ExecutedByRef != capture.ReviewedByRef
|
||
|
|
}
|
||
|
|
add("INDEPENDENT_REVIEW", reviewOK, "真实远程证据必须由不同人员执行和复核并附复核引用")
|
||
|
|
|
||
|
|
seenChecks, checksOK := map[string]bool{}, true
|
||
|
|
for _, result := range capture.Checks {
|
||
|
|
if !slices.Contains(RequiredChecks, result.Code) || seenChecks[result.Code] || result.Status != "PASS" || strings.TrimSpace(result.EvidenceRef) == "" {
|
||
|
|
checksOK = false
|
||
|
|
}
|
||
|
|
seenChecks[result.Code] = true
|
||
|
|
}
|
||
|
|
for _, code := range RequiredChecks {
|
||
|
|
if !seenChecks[code] {
|
||
|
|
checksOK = false
|
||
|
|
} else {
|
||
|
|
report.CompletedChecks = append(report.CompletedChecks, code)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
add("REQUIRED_CHECKS", checksOK, "14项构建、测试、许可证、漏洞、密钥、配置、镜像和SBOM检查必须唯一通过")
|
||
|
|
|
||
|
|
seenImages, imagesOK := map[string]bool{}, true
|
||
|
|
for _, artifact := range capture.Images {
|
||
|
|
if !slices.Contains(RequiredImages, artifact.Subject) || seenImages[artifact.Subject] || artifact.Reference == "" || !digestPattern.MatchString(strings.ToLower(artifact.Digest)) || artifact.EvidenceRef == "" {
|
||
|
|
imagesOK = false
|
||
|
|
}
|
||
|
|
seenImages[artifact.Subject] = true
|
||
|
|
report.ImageDigests[artifact.Subject] = strings.ToLower(artifact.Digest)
|
||
|
|
}
|
||
|
|
for _, subject := range RequiredImages {
|
||
|
|
imagesOK = imagesOK && seenImages[subject]
|
||
|
|
}
|
||
|
|
add("IMAGE_DIGESTS", imagesOK, "后端和Web镜像都必须有不可变sha256摘要及制品引用")
|
||
|
|
|
||
|
|
seenSBOMs, sbomsOK := map[string]bool{}, true
|
||
|
|
for _, artifact := range capture.SBOMs {
|
||
|
|
if !slices.Contains(RequiredSBOMs, artifact.Subject) || seenSBOMs[artifact.Subject] || artifact.Format != "CYCLONEDX_JSON" || !digestPattern.MatchString(strings.ToLower(artifact.Digest)) || artifact.EvidenceRef == "" {
|
||
|
|
sbomsOK = false
|
||
|
|
}
|
||
|
|
seenSBOMs[artifact.Subject] = true
|
||
|
|
report.SBOMDigests[artifact.Subject] = strings.ToLower(artifact.Digest)
|
||
|
|
}
|
||
|
|
for _, subject := range RequiredSBOMs {
|
||
|
|
sbomsOK = sbomsOK && seenSBOMs[subject]
|
||
|
|
}
|
||
|
|
add("CYCLONEDX_SBOMS", sbomsOK, "源码、后端镜像和Web镜像必须各有CycloneDX JSON及文件摘要")
|
||
|
|
if remote && providerOK && triggerOK && timelineOK && reviewOK && checksOK && imagesOK && sbomsOK && report.Result == "PASS" {
|
||
|
|
report.ExternalValidationRequired = false
|
||
|
|
}
|
||
|
|
return report
|
||
|
|
}
|
||
|
|
|
||
|
|
func SHA256(content []byte) string {
|
||
|
|
digest := sha256.Sum256(content)
|
||
|
|
return hex.EncodeToString(digest[:])
|
||
|
|
}
|
||
|
|
|
||
|
|
func validHTTPS(value string) bool {
|
||
|
|
parsed, err := url.Parse(strings.TrimSpace(value))
|
||
|
|
return err == nil && parsed.Scheme == "https" && parsed.Host != ""
|
||
|
|
}
|
||
|
|
|
||
|
|
func decodeStrict(content []byte, target any) error {
|
||
|
|
decoder := json.NewDecoder(bytes.NewReader(content))
|
||
|
|
decoder.DisallowUnknownFields()
|
||
|
|
if err := decoder.Decode(target); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
var trailing any
|
||
|
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||
|
|
return errors.New("trailing JSON data")
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|