build(gateway): generate deterministic SPDX SBOM
This commit is contained in:
@@ -35,6 +35,7 @@ jobs:
|
||||
file dist/verse-gateway-linux-* | tee dist/file.txt
|
||||
go version -m dist/verse-gateway-linux-amd64 > dist/go-version-amd64.txt
|
||||
go version -m dist/verse-gateway-linux-arm64 > dist/go-version-arm64.txt
|
||||
make gateway-sbom DIST_DIR=dist
|
||||
- uses: christopherhx/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4
|
||||
with:
|
||||
name: verse-gateway-linux-${{ gitea.sha }}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
.PHONY: format-check module-verify build vet test openspec gateway-linux verify
|
||||
.PHONY: format-check module-verify build vet test openspec gateway-linux gateway-sbom verify
|
||||
|
||||
GO ?= go
|
||||
OPENSPEC ?= openspec
|
||||
DIST_DIR ?= dist
|
||||
SOURCE_REVISION ?= $(shell git rev-parse HEAD)
|
||||
SOURCE_DATE ?= $(shell git show -s --format=%cI HEAD)
|
||||
|
||||
format-check:
|
||||
@test -z "$$(gofmt -l $$(find gateway -type f -name '*.go' -print))"
|
||||
@@ -27,4 +29,12 @@ gateway-linux:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOWORK=off $(GO) build -mod=readonly -trimpath -buildvcs=false -ldflags=-buildid= -o "$(DIST_DIR)/verse-gateway-linux-amd64" ./cmd/verse-gateway
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 GOWORK=off $(GO) build -mod=readonly -trimpath -buildvcs=false -ldflags=-buildid= -o "$(DIST_DIR)/verse-gateway-linux-arm64" ./cmd/verse-gateway
|
||||
|
||||
gateway-sbom:
|
||||
$(GO) run ./cmd/verse-gateway-sbom \
|
||||
-source-revision "$(SOURCE_REVISION)" \
|
||||
-source-date "$(SOURCE_DATE)" \
|
||||
-artifact "amd64=$(DIST_DIR)/verse-gateway-linux-amd64" \
|
||||
-artifact "arm64=$(DIST_DIR)/verse-gateway-linux-arm64" \
|
||||
-output "$(DIST_DIR)/verse-gateway.spdx.json"
|
||||
|
||||
verify: format-check module-verify build vet test openspec
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-30
|
||||
@@ -0,0 +1,28 @@
|
||||
## Context
|
||||
|
||||
The repository already produces reproducible pure-Go Linux binaries and can read exact Go module/build metadata. A standard deterministic document is missing; adding an external SBOM tool is unnecessary for this bounded artifact.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Generate byte-stable SPDX 2.3 JSON with Go standard library encoding.
|
||||
- Describe the repository, Protocol dependency, all resolved modules, both Linux binaries, relationships, hashes, architectures, notices, and truthful licenses.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Vulnerability scanning, signing, image remediation, public release, or inferred license conclusions.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Use a small repository command that reads each binary with
|
||||
`debug/buildinfo`, verifies Linux architecture and cgo settings, and compares
|
||||
embedded module inventories before sorting every package and relationship.
|
||||
- Use fixed SPDX identifiers and a source-date timestamp supplied by the caller; reject dirty/ambiguous inputs rather than embedding current time.
|
||||
- Use `NOASSERTION` for unavailable concluded/declared license evidence and record no vulnerability result.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Go module metadata lacks complete license conclusions] → retain notices and use `NOASSERTION`.
|
||||
- [Artifact paths make output host-dependent] → encode architecture, filename, size, and digest only.
|
||||
- [A hand-built serializer could drift] → validate required SPDX fields and require byte-identical double generation.
|
||||
@@ -0,0 +1,24 @@
|
||||
## Why
|
||||
|
||||
The gateway has reproducible Linux binaries and a dependency inventory but no deterministic standard SBOM, while OPS-009 and the Phase 3C gateway plan require one for engineering exit.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Generate byte-stable SPDX 2.3 JSON using repository, Go module, and artifact metadata.
|
||||
- Record exact source revision, immutable Protocol version/checksum, module relationships, Linux artifact hashes and architectures, and truthful license fields.
|
||||
- Retain notices/provenance and use `NOASSERTION` where license evidence is unavailable.
|
||||
- Keep vulnerability scanning, signing, and Phase 3C-C image remediation explicitly separate.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
None.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `gateway-deployment-artifact`: Require an inspected deterministic standard SBOM for shipped gateway binaries.
|
||||
|
||||
## Impact
|
||||
|
||||
Data Plane packaging tooling, deterministic tests, Makefile targets, canonical deployment-artifact OpenSpec, and evidence records. Uses Go standard library only; no new dependency. Requirements: SYS-019, P3C-002, P3C-035, OPS-009, VER-015, VER-017.
|
||||
@@ -0,0 +1,12 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Artifact evidence is inspected and truthful
|
||||
Candidate evidence SHALL record exact source and immutable Protocol revisions/checksums, artifact hashes, architecture, embedded dependency inventory, container configuration when built, and the actual scanner/signing status. It SHALL include one byte-stable SPDX 2.3 JSON SBOM for the shipped Linux gateway artifacts containing the source package, resolved Go modules, dependency and generated-from relationships, artifact hashes and architectures, retained notices/provenance, and truthful license fields using `NOASSERTION` where evidence is unavailable. It MUST NOT claim a vulnerability result, signature, image architecture, deployment, or license conclusion that was not produced and inspected.
|
||||
|
||||
#### Scenario: Deterministic gateway SBOM
|
||||
- **WHEN** the canonical SBOM command runs twice with the same clean source revision, Protocol module/checksum, module graph, source date, and Linux artifacts
|
||||
- **THEN** both SPDX JSON outputs are byte-identical and every declared artifact/module relationship and hash matches the inspected inputs
|
||||
|
||||
#### Scenario: Supplemental scanner is unavailable
|
||||
- **WHEN** no qualifying vulnerability scanner is available in the frozen environment
|
||||
- **THEN** the artifact remains explicitly unscanned and unsigned, the deterministic SBOM/compiler/dependency/boundary evidence is retained, and no zero-finding security claim is emitted
|
||||
@@ -0,0 +1,14 @@
|
||||
## 1. Red deterministic contract
|
||||
|
||||
- [x] 1.1 Add a focused test requiring SPDX 2.3 fields, source/Protocol/module relationships, two architectures, and exact artifact hashes
|
||||
- [x] 1.2 Prove current packaging cannot produce the required standard SBOM
|
||||
|
||||
## 2. Standard-library generator
|
||||
|
||||
- [x] 2.1 Implement bounded deterministic SPDX JSON generation from explicit build and Go module metadata
|
||||
- [x] 2.2 Record truthful license fields, notices/provenance, unscanned status, and no signing claim
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [ ] 3.1 Prove byte-stable regeneration and rejection of dirty, missing, mismatched, or ambiguous inputs
|
||||
- [x] 3.2 Reconcile the deployment-artifact canonical spec and run strict validation
|
||||
@@ -12,8 +12,26 @@ The candidate SHALL build the gateway with the normal immutable Protocol module
|
||||
- **THEN** both amd64 and arm64 outputs are byte-identical pure-Go ELF executables with matching embedded GOOS, GOARCH, and cgo settings
|
||||
|
||||
### Requirement: Artifact evidence is inspected and truthful
|
||||
Candidate evidence SHALL record exact source and Protocol revisions, artifact hashes, architecture, embedded dependency inventory, container configuration when built, and the actual scanner/signing status. It MUST NOT claim an SBOM, vulnerability result, signature, image architecture, or deployment that was not produced and inspected.
|
||||
Candidate evidence SHALL record exact source and immutable Protocol
|
||||
revisions/checksums, artifact hashes, architecture, embedded dependency
|
||||
inventory, container configuration when built, and the actual scanner/signing
|
||||
status. It SHALL include one byte-stable SPDX 2.3 JSON SBOM for the shipped Linux
|
||||
gateway artifacts containing the source package, resolved Go modules, dependency
|
||||
and generated-from relationships, artifact hashes and architectures, retained
|
||||
notices/provenance, and truthful license fields using `NOASSERTION` where
|
||||
evidence is unavailable. It MUST NOT claim a vulnerability result, signature,
|
||||
image architecture, deployment, or license conclusion that was not produced and
|
||||
inspected.
|
||||
|
||||
#### Scenario: Deterministic gateway SBOM
|
||||
- **WHEN** the canonical SBOM command runs twice with the same clean source
|
||||
revision, Protocol module/checksum, module graph, source date, and Linux
|
||||
artifacts
|
||||
- **THEN** both SPDX JSON outputs are byte-identical and every declared
|
||||
artifact/module relationship and hash matches the inspected inputs
|
||||
|
||||
#### Scenario: Supplemental scanner is unavailable
|
||||
- **WHEN** no qualifying vulnerability scanner is available in the frozen environment
|
||||
- **THEN** the artifact remains explicitly unscanned, deterministic compiler/dependency/boundary evidence is retained, and no zero-finding security claim is emitted
|
||||
- **THEN** the artifact remains explicitly unscanned and unsigned, the
|
||||
deterministic SBOM/compiler/dependency/boundary evidence is retained, and no
|
||||
zero-finding security claim is emitted
|
||||
|
||||
Reference in New Issue
Block a user