diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml index 5322f75..a82d159 100644 --- a/.gitea/workflows/verify.yml +++ b/.gitea/workflows/verify.yml @@ -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 }} diff --git a/Makefile b/Makefile index a625d13..c079728 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/cmd/verse-gateway-sbom/main.go b/cmd/verse-gateway-sbom/main.go new file mode 100644 index 0000000..db7d197 --- /dev/null +++ b/cmd/verse-gateway-sbom/main.go @@ -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]) +} diff --git a/cmd/verse-gateway-sbom/main_test.go b/cmd/verse-gateway-sbom/main_test.go new file mode 100644 index 0000000..5a65919 --- /dev/null +++ b/cmd/verse-gateway-sbom/main_test.go @@ -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) + } + } +} diff --git a/openspec/changes/deterministic-gateway-sbom/.openspec.yaml b/openspec/changes/deterministic-gateway-sbom/.openspec.yaml new file mode 100644 index 0000000..ab39675 --- /dev/null +++ b/openspec/changes/deterministic-gateway-sbom/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-30 diff --git a/openspec/changes/deterministic-gateway-sbom/design.md b/openspec/changes/deterministic-gateway-sbom/design.md new file mode 100644 index 0000000..f06612b --- /dev/null +++ b/openspec/changes/deterministic-gateway-sbom/design.md @@ -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. diff --git a/openspec/changes/deterministic-gateway-sbom/proposal.md b/openspec/changes/deterministic-gateway-sbom/proposal.md new file mode 100644 index 0000000..79b50f9 --- /dev/null +++ b/openspec/changes/deterministic-gateway-sbom/proposal.md @@ -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. diff --git a/openspec/changes/deterministic-gateway-sbom/specs/gateway-deployment-artifact/spec.md b/openspec/changes/deterministic-gateway-sbom/specs/gateway-deployment-artifact/spec.md new file mode 100644 index 0000000..ff6d567 --- /dev/null +++ b/openspec/changes/deterministic-gateway-sbom/specs/gateway-deployment-artifact/spec.md @@ -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 diff --git a/openspec/changes/deterministic-gateway-sbom/tasks.md b/openspec/changes/deterministic-gateway-sbom/tasks.md new file mode 100644 index 0000000..5816315 --- /dev/null +++ b/openspec/changes/deterministic-gateway-sbom/tasks.md @@ -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 diff --git a/openspec/specs/gateway-deployment-artifact/spec.md b/openspec/specs/gateway-deployment-artifact/spec.md index 22e3ddc..bcf8765 100644 --- a/openspec/specs/gateway-deployment-artifact/spec.md +++ b/openspec/specs/gateway-deployment-artifact/spec.md @@ -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