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