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
+1
View File
@@ -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 }}
+11 -1
View File
@@ -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
+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)
}
}
}
@@ -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