build(gateway): generate deterministic SPDX SBOM

This commit is contained in:
sechmachine
2026-07-30 22:47:55 +07:00
parent 786c96b110
commit 08eb6dc36c
10 changed files with 570 additions and 3 deletions
+376
View File
@@ -0,0 +1,376 @@
package main
import (
"bufio"
"crypto/sha256"
"debug/buildinfo"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"reflect"
"regexp"
"sort"
"strings"
"time"
)
const (
dataPlaneModulePath = "git.sechmachine.io.vn/sechmachine/VerseVDI-Data-Plane"
protocolModulePath = "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol"
)
type artifactFlags []string
func (values *artifactFlags) String() string { return strings.Join(*values, ",") }
func (values *artifactFlags) Set(value string) error {
*values = append(*values, value)
return nil
}
type goModule struct {
Path string
Version string
Sum string
}
type gatewayArtifact struct {
Architecture string
Filename string
SHA256 string
Size int64
}
type sbomInput struct {
SourceRevision string
Created time.Time
ProtocolGoModSum string
Modules []goModule
Artifacts []gatewayArtifact
}
type spdxDocument struct {
SPDXVersion string `json:"spdxVersion"`
DataLicense string `json:"dataLicense"`
SPDXID string `json:"SPDXID"`
Name string `json:"name"`
DocumentNamespace string `json:"documentNamespace"`
CreationInfo spdxCreationInfo `json:"creationInfo"`
DocumentComment string `json:"documentComment"`
Packages []spdxPackage `json:"packages"`
Relationships []spdxRelationship `json:"relationships"`
}
type spdxCreationInfo struct {
Created string `json:"created"`
Creators []string `json:"creators"`
}
type spdxPackage struct {
Name string `json:"name"`
SPDXID string `json:"SPDXID"`
VersionInfo string `json:"versionInfo"`
DownloadLocation string `json:"downloadLocation"`
FilesAnalyzed bool `json:"filesAnalyzed"`
PackagePurpose string `json:"primaryPackagePurpose"`
Checksums []spdxChecksum `json:"checksums,omitempty"`
LicenseConcluded string `json:"licenseConcluded"`
LicenseDeclared string `json:"licenseDeclared"`
CopyrightText string `json:"copyrightText"`
Comment string `json:"comment,omitempty"`
ExternalRefs []spdxRef `json:"externalRefs,omitempty"`
}
type spdxChecksum struct {
Algorithm string `json:"algorithm"`
ChecksumValue string `json:"checksumValue"`
}
type spdxRef struct {
Category string `json:"referenceCategory"`
Type string `json:"referenceType"`
Locator string `json:"referenceLocator"`
Comment string `json:"comment,omitempty"`
}
type spdxRelationship struct {
Element string `json:"spdxElementId"`
Type string `json:"relationshipType"`
Related string `json:"relatedSpdxElement"`
}
func main() {
var artifacts artifactFlags
sourceRevision := flag.String("source-revision", "", "full clean source commit")
sourceDate := flag.String("source-date", "", "UTC RFC3339 source date")
goSumPath := flag.String("go-sum", "go.sum", "Go checksum file")
output := flag.String("output", "", "SPDX JSON output")
flag.Var(&artifacts, "artifact", "linux architecture and binary path, for example amd64=dist/verse-gateway-linux-amd64")
flag.Parse()
if *output == "" {
fatal(errors.New("output is required"))
}
created, err := time.Parse(time.RFC3339, *sourceDate)
if err != nil {
fatal(errors.New("source-date must be RFC3339"))
}
created = created.UTC()
if err := verifyCleanRevision(*sourceRevision); err != nil {
fatal(err)
}
inspected, modules, err := inspectArtifacts(artifacts)
if err != nil {
fatal(err)
}
protocolVersion := ""
for _, module := range modules {
if module.Path == protocolModulePath {
protocolVersion = module.Version
}
}
goModSum, err := readGoModSum(*goSumPath, protocolModulePath, protocolVersion)
if err != nil {
fatal(err)
}
encoded, err := buildSPDX(sbomInput{
SourceRevision: *sourceRevision, Created: created, ProtocolGoModSum: goModSum,
Modules: modules, Artifacts: inspected,
})
if err != nil {
fatal(err)
}
if err := os.WriteFile(*output, encoded, 0o644); err != nil {
fatal(err)
}
}
func fatal(err error) {
_, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
func verifyCleanRevision(revision string) error {
if !regexp.MustCompile(`^[0-9a-f]{40}$`).MatchString(revision) {
return errors.New("source-revision must be a full lowercase commit")
}
head, err := exec.Command("git", "rev-parse", "HEAD").Output()
if err != nil || strings.TrimSpace(string(head)) != revision {
return errors.New("source-revision does not match HEAD")
}
status, err := exec.Command("git", "status", "--porcelain", "--untracked-files=no").Output()
if err != nil {
return fmt.Errorf("inspect source dirt: %w", err)
}
if len(status) != 0 {
return errors.New("tracked source tree is dirty")
}
return nil
}
func inspectArtifacts(specifications []string) ([]gatewayArtifact, []goModule, error) {
if len(specifications) != 2 {
return nil, nil, errors.New("exactly amd64 and arm64 artifacts are required")
}
var artifacts []gatewayArtifact
var common []goModule
seen := make(map[string]bool)
for _, specification := range specifications {
architecture, path, ok := strings.Cut(specification, "=")
if !ok || (architecture != "amd64" && architecture != "arm64") || seen[architecture] {
return nil, nil, errors.New("artifact must uniquely name amd64 or arm64")
}
seen[architecture] = true
artifact, modules, err := inspectArtifact(architecture, path)
if err != nil {
return nil, nil, err
}
if common == nil {
common = modules
} else if !reflect.DeepEqual(common, modules) {
return nil, nil, errors.New("artifact module inventories differ")
}
artifacts = append(artifacts, artifact)
}
sort.Slice(artifacts, func(first, second int) bool {
return artifacts[first].Architecture < artifacts[second].Architecture
})
return artifacts, common, nil
}
func inspectArtifact(architecture, path string) (gatewayArtifact, []goModule, error) {
info, err := buildinfo.ReadFile(path)
if err != nil {
return gatewayArtifact{}, nil, fmt.Errorf("read %s build metadata: %w", architecture, err)
}
settings := make(map[string]string, len(info.Settings))
for _, setting := range info.Settings {
settings[setting.Key] = setting.Value
}
if info.Path != dataPlaneModulePath+"/cmd/verse-gateway" || settings["GOOS"] != "linux" ||
settings["GOARCH"] != architecture || settings["CGO_ENABLED"] != "0" {
return gatewayArtifact{}, nil, fmt.Errorf("%s artifact build identity is invalid", architecture)
}
modules := []goModule{{Path: info.Main.Path, Version: info.Main.Version, Sum: info.Main.Sum}}
for _, dependency := range info.Deps {
if dependency.Replace != nil {
return gatewayArtifact{}, nil, fmt.Errorf("%s contains replaced module %s", architecture, dependency.Path)
}
modules = append(modules, goModule{Path: dependency.Path, Version: dependency.Version, Sum: dependency.Sum})
}
sort.Slice(modules, func(first, second int) bool {
if modules[first].Path == modules[second].Path {
return modules[first].Version < modules[second].Version
}
return modules[first].Path < modules[second].Path
})
file, err := os.Open(path)
if err != nil {
return gatewayArtifact{}, nil, err
}
defer file.Close()
digest := sha256.New()
size, err := io.Copy(digest, file)
if err != nil {
return gatewayArtifact{}, nil, err
}
return gatewayArtifact{
Architecture: architecture, Filename: filepath.Base(path),
SHA256: hex.EncodeToString(digest.Sum(nil)), Size: size,
}, modules, nil
}
func readGoModSum(path, modulePath, version string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
prefix := modulePath + " " + version + "/go.mod "
scanner := bufio.NewScanner(file)
for scanner.Scan() {
if strings.HasPrefix(scanner.Text(), prefix) {
return strings.TrimPrefix(scanner.Text(), prefix), nil
}
}
if err := scanner.Err(); err != nil {
return "", err
}
return "", errors.New("protocol go.mod checksum is absent")
}
func buildSPDX(input sbomInput) ([]byte, error) {
if !regexp.MustCompile(`^[0-9a-f]{40}$`).MatchString(input.SourceRevision) || input.Created.Location() != time.UTC ||
input.ProtocolGoModSum == "" || len(input.Artifacts) != 2 {
return nil, errors.New("incomplete SPDX inputs")
}
modules := append([]goModule(nil), input.Modules...)
sort.Slice(modules, func(first, second int) bool {
if modules[first].Path == modules[second].Path {
return modules[first].Version < modules[second].Version
}
return modules[first].Path < modules[second].Path
})
artifacts := append([]gatewayArtifact(nil), input.Artifacts...)
sort.Slice(artifacts, func(first, second int) bool {
return artifacts[first].Architecture < artifacts[second].Architecture
})
if artifacts[0].Architecture != "amd64" || artifacts[1].Architecture != "arm64" {
return nil, errors.New("SPDX requires amd64 and arm64 artifacts")
}
sourceID := "SPDXRef-Source"
packages := []spdxPackage{{
Name: dataPlaneModulePath, SPDXID: sourceID, VersionInfo: input.SourceRevision,
DownloadLocation: "NOASSERTION", FilesAnalyzed: false, PackagePurpose: "SOURCE",
LicenseConcluded: "NOASSERTION", LicenseDeclared: "GPL-3.0-only", CopyrightText: "NOASSERTION",
Comment: "license_provenance=LICENSE",
}}
moduleIDs := make(map[string]string)
protocolFound := false
for _, module := range modules {
if module.Path == dataPlaneModulePath {
continue
}
if module.Path == "" || module.Version == "" || moduleIDs[module.Path] != "" {
return nil, errors.New("ambiguous module inventory")
}
id := "SPDXRef-Module-" + shortDigest(module.Path+"@"+module.Version)
moduleIDs[module.Path] = id
comment := "go_module_sum=" + module.Sum
if module.Path == protocolModulePath {
if module.Sum == "" {
return nil, errors.New("immutable Protocol checksum is absent")
}
protocolFound = true
comment += "; go_mod_sum=" + input.ProtocolGoModSum
}
packages = append(packages, spdxPackage{
Name: module.Path, SPDXID: id, VersionInfo: module.Version,
DownloadLocation: "NOASSERTION", FilesAnalyzed: false, PackagePurpose: "LIBRARY",
LicenseConcluded: "NOASSERTION", LicenseDeclared: "NOASSERTION", CopyrightText: "NOASSERTION",
Comment: comment,
ExternalRefs: []spdxRef{{
Category: "PACKAGE-MANAGER", Type: "purl",
Locator: "pkg:golang/" + module.Path + "@" + module.Version,
}},
})
}
if !protocolFound {
return nil, errors.New("Protocol module is absent")
}
var relationships []spdxRelationship
for _, artifact := range artifacts {
if artifact.Filename == "" || len(artifact.SHA256) != 64 || artifact.Size < 1 {
return nil, errors.New("artifact metadata is invalid")
}
id := "SPDXRef-Artifact-" + artifact.Architecture
packages = append(packages, spdxPackage{
Name: artifact.Filename, SPDXID: id, VersionInfo: input.SourceRevision,
DownloadLocation: "NOASSERTION", FilesAnalyzed: false, PackagePurpose: "APPLICATION",
Checksums: []spdxChecksum{{Algorithm: "SHA256", ChecksumValue: artifact.SHA256}},
LicenseConcluded: "NOASSERTION", LicenseDeclared: "GPL-3.0-only", CopyrightText: "NOASSERTION",
Comment: fmt.Sprintf("GOOS=linux; GOARCH=%s; CGO_ENABLED=0; size=%d", artifact.Architecture, artifact.Size),
})
relationships = append(relationships,
spdxRelationship{Element: "SPDXRef-DOCUMENT", Type: "DESCRIBES", Related: id},
spdxRelationship{Element: id, Type: "GENERATED_FROM", Related: sourceID},
)
for _, moduleID := range moduleIDs {
relationships = append(relationships, spdxRelationship{Element: id, Type: "DEPENDS_ON", Related: moduleID})
}
}
sort.Slice(packages, func(first, second int) bool { return packages[first].SPDXID < packages[second].SPDXID })
sort.Slice(relationships, func(first, second int) bool {
left := relationships[first].Element + relationships[first].Type + relationships[first].Related
right := relationships[second].Element + relationships[second].Type + relationships[second].Related
return left < right
})
document := spdxDocument{
SPDXVersion: "SPDX-2.3", DataLicense: "CC0-1.0", SPDXID: "SPDXRef-DOCUMENT",
Name: "verse-gateway-" + input.SourceRevision,
DocumentNamespace: "https://git.sechmachine.io.vn/sechmachine/VerseVDI-Data-Plane/spdx/" + input.SourceRevision,
CreationInfo: spdxCreationInfo{
Created: input.Created.Format(time.RFC3339),
Creators: []string{"Tool: verse-gateway-sbom"},
},
DocumentComment: "notices=LICENSE; provenance=Go build metadata and artifact SHA-256; vulnerability_status=unscanned; signing_status=unsigned; phase3c_c_image_remediation=separate",
Packages: packages, Relationships: relationships,
}
encoded, err := json.MarshalIndent(document, "", " ")
if err != nil {
return nil, err
}
return append(encoded, '\n'), nil
}
func shortDigest(value string) string {
digest := sha256.Sum256([]byte(value))
return hex.EncodeToString(digest[:8])
}
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"bytes"
"strings"
"testing"
"time"
)
func TestBuildSPDXIsDeterministicAndTruthful(t *testing.T) {
input := sbomInput{
SourceRevision: strings.Repeat("a", 40),
Created: time.Date(2026, time.July, 30, 1, 2, 3, 0, time.UTC),
ProtocolGoModSum: "h1:protocol-go-mod",
Modules: []goModule{
{Path: "git.sechmachine.io.vn/sechmachine/VerseVDI-Data-Plane", Version: "(devel)"},
{Path: protocolModulePath, Version: "v1.0.0-phase3c-gateway-rc.9", Sum: "h1:protocol"},
{Path: "github.com/quic-go/quic-go", Version: "v0.61.0", Sum: "h1:quic"},
},
Artifacts: []gatewayArtifact{
{Architecture: "amd64", Filename: "verse-gateway-linux-amd64", SHA256: strings.Repeat("1", 64), Size: 100},
{Architecture: "arm64", Filename: "verse-gateway-linux-arm64", SHA256: strings.Repeat("2", 64), Size: 101},
},
}
first, err := buildSPDX(input)
if err != nil {
t.Fatal(err)
}
second, err := buildSPDX(input)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(first, second) {
t.Fatal("SPDX generation was not byte-stable")
}
text := string(first)
for _, required := range []string{
`"spdxVersion": "SPDX-2.3"`,
input.SourceRevision,
"v1.0.0-phase3c-gateway-rc.9",
input.ProtocolGoModSum,
"verse-gateway-linux-amd64",
"verse-gateway-linux-arm64",
strings.Repeat("1", 64),
strings.Repeat("2", 64),
`"licenseConcluded": "NOASSERTION"`,
"vulnerability_status=unscanned",
"signing_status=unsigned",
} {
if !strings.Contains(text, required) {
t.Fatalf("SPDX is missing %q", required)
}
}
}
func TestBuildSPDXRejectsAmbiguousInputs(t *testing.T) {
valid := sbomInput{
SourceRevision: strings.Repeat("a", 40), Created: time.Unix(0, 0).UTC(),
ProtocolGoModSum: "h1:protocol-go-mod",
Modules: []goModule{
{Path: protocolModulePath, Version: "v1.0.0-rc.9", Sum: "h1:protocol"},
},
Artifacts: []gatewayArtifact{
{Architecture: "amd64", Filename: "amd64", SHA256: strings.Repeat("1", 64), Size: 1},
{Architecture: "arm64", Filename: "arm64", SHA256: strings.Repeat("2", 64), Size: 1},
},
}
mutations := []func(*sbomInput){
func(input *sbomInput) { input.SourceRevision = "short" },
func(input *sbomInput) { input.Modules = nil },
func(input *sbomInput) { input.Artifacts[1].Architecture = "amd64" },
}
for index, mutate := range mutations {
input := valid
input.Modules = append([]goModule(nil), valid.Modules...)
input.Artifacts = append([]gatewayArtifact(nil), valid.Artifacts...)
mutate(&input)
if _, err := buildSPDX(input); err == nil {
t.Fatalf("invalid SPDX input %d was accepted", index)
}
}
}