added signed artifacts and SBOM generation capabilities

This commit is contained in:
2026-05-12 21:31:43 +02:00
parent ab94775162
commit 822f723ff1
16 changed files with 1615 additions and 12 deletions
+99
View File
@@ -0,0 +1,99 @@
// Package sbom generates Software Bills of Materials in CycloneDX 1.4 JSON format.
// https://cyclonedx.org/specification/overview/
package sbom
import (
"fmt"
"time"
)
const (
FormatCycloneDX = "cyclonedx-json-1.4"
SpecVersion = "1.4"
BOMFormat = "CycloneDX"
)
// Document is the top-level CycloneDX 1.4 BOM.
type Document struct {
BOMFormat string `json:"bomFormat"`
SpecVersion string `json:"specVersion"`
SerialNumber string `json:"serialNumber"`
Version int `json:"version"`
Metadata Metadata `json:"metadata"`
Components []Component `json:"components"`
}
type Metadata struct {
Timestamp string `json:"timestamp"`
Tools []Tool `json:"tools"`
Component *Component `json:"component,omitempty"`
}
type Tool struct {
Vendor string `json:"vendor"`
Name string `json:"name"`
Version string `json:"version"`
}
// Component represents a software dependency in the BOM.
type Component struct {
Type string `json:"type"` // "library", "application", "framework"
Name string `json:"name"`
Version string `json:"version,omitempty"`
PURL string `json:"purl,omitempty"`
Description string `json:"description,omitempty"`
Scope string `json:"scope,omitempty"` // "required", "optional"
ExternalRefs []ExternalRef `json:"externalReferences,omitempty"`
}
type ExternalRef struct {
Type string `json:"type"` // "website", "vcs", "distribution"
URL string `json:"url"`
}
// NewDocument creates a blank CycloneDX 1.4 document with metadata populated.
func NewDocument(repoName, sha string) *Document {
return &Document{
BOMFormat: BOMFormat,
SpecVersion: SpecVersion,
SerialNumber: fmt.Sprintf("urn:uuid:forgebucket:%s:%s", repoName, sha[:7]),
Version: 1,
Metadata: Metadata{
Timestamp: time.Now().UTC().Format(time.RFC3339),
Tools: []Tool{
{Vendor: "ForgeBucket", Name: "sbom-generator", Version: "1.0.0"},
},
Component: &Component{
Type: "application",
Name: repoName,
},
},
Components: []Component{},
}
}
// PURL helpers — produce Package URL strings per ecosystem.
func golangPURL(module, version string) string {
return fmt.Sprintf("pkg:golang/%s@%s", module, version)
}
func npmPURL(name, version string) string {
return fmt.Sprintf("pkg:npm/%s@%s", name, version)
}
func pypiPURL(name, version string) string {
return fmt.Sprintf("pkg:pypi/%s@%s", name, version)
}
func cargoPURL(name, version string) string {
return fmt.Sprintf("pkg:cargo/%s@%s", name, version)
}
func gemPURL(name, version string) string {
return fmt.Sprintf("pkg:gem/%s@%s", name, version)
}
func mavenPURL(group, artifact, version string) string {
return fmt.Sprintf("pkg:maven/%s/%s@%s", group, artifact, version)
}
+190
View File
@@ -0,0 +1,190 @@
package sbom
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/events"
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
"github.com/forgeo/forgebucket/internal/models"
)
// manifestEntry maps a known manifest file path to its parser function.
type manifestEntry struct {
path string
parser func([]byte) []Component
}
// knownManifests is the ordered list of manifest files the generator probes.
// Files are tried in order; all that exist at the given SHA are parsed.
var knownManifests = []manifestEntry{
{"go.mod", ParseGoMod},
{"package.json", ParsePackageJSON},
{"requirements.txt", ParseRequirementsTxt},
{"Cargo.toml", ParseCargoToml},
{"Gemfile.lock", ParseGemfileLock},
{"pom.xml", ParsePomXML},
}
// Generator subscribes to pipeline.completed events and produces SBOM reports.
type Generator struct {
db *xorm.Engine
bus events.EventBus
}
func NewGenerator(db *xorm.Engine, bus events.EventBus) *Generator {
return &Generator{db: db, bus: bus}
}
// Start subscribes to pipeline.completed and blocks until ctx is cancelled.
func (g *Generator) Start(ctx context.Context) {
unsub, err := g.bus.Subscribe(events.SubjectPipelineCompleted, func(_ string, data []byte) {
var evt events.PipelineEvent
if err := json.Unmarshal(data, &evt); err != nil {
log.Printf("sbom: bad pipeline.completed event: %v", err)
return
}
if evt.Status != "succeeded" {
return
}
go g.generateForRun(evt.RunID, evt.RepoID)
})
if err != nil {
log.Printf("sbom: subscribe pipeline.completed: %v", err)
} else {
defer unsub()
}
<-ctx.Done()
}
// generateForRun generates an SBOM for the pipeline run identified by runID.
func (g *Generator) generateForRun(runID, repoID int64) {
var run models.PipelineRun
if found, _ := g.db.ID(runID).Get(&run); !found {
return
}
var repo models.Repository
if found, _ := g.db.ID(repoID).Get(&repo); !found {
return
}
doc, err := Generate(repo.DiskPath, repo.Name, run.TriggerSHA)
if err != nil {
log.Printf("sbom: generate for run %d: %v", runID, err)
return
}
if err := g.persist(repoID, runID, run.TriggerSHA, doc); err != nil {
log.Printf("sbom: persist for run %d: %v", runID, err)
}
}
// GenerateOnDemand generates an SBOM for a specific repo + SHA and stores it
// (or returns the cached one if the SHA was already processed).
func (g *Generator) GenerateOnDemand(repoID int64, sha string) (*models.SBOMReport, error) {
// Return cached report for this exact SHA if one already exists.
var existing models.SBOMReport
if found, _ := g.db.Where("repo_id = ? AND sha = ?", repoID, sha).Get(&existing); found {
return &existing, nil
}
var repo models.Repository
if found, _ := g.db.ID(repoID).Get(&repo); !found {
return nil, fmt.Errorf("repo %d not found", repoID)
}
doc, err := Generate(repo.DiskPath, repo.Name, sha)
if err != nil {
return nil, err
}
report, err := g.persistAndReturn(repoID, 0, sha, doc)
if err != nil {
return nil, err
}
return report, nil
}
// GetLatest returns the most recent SBOM report for a repo.
func (g *Generator) GetLatest(repoID int64) (*models.SBOMReport, error) {
var report models.SBOMReport
found, err := g.db.Where("repo_id = ?", repoID).
OrderBy("generated_at DESC").
Get(&report)
if err != nil {
return nil, err
}
if !found {
return nil, nil
}
return &report, nil
}
// GetForRun returns the SBOM report associated with a pipeline run.
func (g *Generator) GetForRun(runID int64) (*models.SBOMReport, error) {
var report models.SBOMReport
found, err := g.db.Where("run_id = ?", runID).Get(&report)
if err != nil {
return nil, err
}
if !found {
return nil, nil
}
return &report, nil
}
// ─── core generation logic ────────────────────────────────────────────────────
// Generate reads known manifest files from the git repo at sha and builds
// a CycloneDX 1.4 document. It is safe to call even if no manifests exist
// (the document will have an empty components list).
func Generate(repoPath, repoName, sha string) (*Document, error) {
doc := NewDocument(repoName, sha)
for _, m := range knownManifests {
content, err := gitdomain.BlobCat(repoPath, sha, m.path)
if err != nil {
// File simply doesn't exist at this SHA — skip silently.
continue
}
comps := m.parser(content)
doc.Components = append(doc.Components, comps...)
}
return doc, nil
}
// ─── persistence helpers ──────────────────────────────────────────────────────
func (g *Generator) persist(repoID, runID int64, sha string, doc *Document) error {
_, err := g.persistAndReturn(repoID, runID, sha, doc)
return err
}
func (g *Generator) persistAndReturn(repoID, runID int64, sha string, doc *Document) (*models.SBOMReport, error) {
bomJSON, err := json.Marshal(doc)
if err != nil {
return nil, fmt.Errorf("marshal BOM: %w", err)
}
report := &models.SBOMReport{
RepoID: repoID,
RunID: runID,
SHA: sha,
Format: FormatCycloneDX,
ComponentCount: len(doc.Components),
BOMDocument: string(bomJSON),
GeneratedAt: time.Now().UTC(),
}
if _, err := g.db.Insert(report); err != nil {
return nil, fmt.Errorf("insert sbom_report: %w", err)
}
log.Printf("sbom: generated report %d for repo %d @ %s (%d components)",
report.ID, repoID, sha[:7], report.ComponentCount)
return report, nil
}
+353
View File
@@ -0,0 +1,353 @@
package sbom
import (
"bufio"
"bytes"
"encoding/json"
"strings"
)
// ParseResult holds components extracted from a single manifest file.
type ParseResult struct {
Ecosystem string
Components []Component
}
// ─── go.mod ──────────────────────────────────────────────────────────────────
// ParseGoMod parses a go.mod file and returns Go module components.
// Handles both single-line `require x v1` and block `require ( ... )` forms.
func ParseGoMod(content []byte) []Component {
var components []Component
scanner := bufio.NewScanner(bytes.NewReader(content))
inBlock := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Strip inline comments.
if idx := strings.Index(line, "//"); idx >= 0 {
line = strings.TrimSpace(line[:idx])
}
if line == "" {
continue
}
if line == "require (" {
inBlock = true
continue
}
if inBlock && line == ")" {
inBlock = false
continue
}
var modulePath, version string
if inBlock {
parts := strings.Fields(line)
if len(parts) >= 2 {
modulePath, version = parts[0], parts[1]
}
} else if strings.HasPrefix(line, "require ") {
parts := strings.Fields(strings.TrimPrefix(line, "require "))
if len(parts) >= 2 {
modulePath, version = parts[0], parts[1]
}
}
if modulePath == "" {
continue
}
// Indirect deps are still included — they are part of the supply chain.
components = append(components, Component{
Type: "library",
Name: modulePath,
Version: version,
PURL: golangPURL(modulePath, version),
})
}
return components
}
// ─── package.json ─────────────────────────────────────────────────────────────
type packageJSON struct {
Dependencies map[string]string `json:"dependencies"`
DevDependencies map[string]string `json:"devDependencies"`
PeerDependencies map[string]string `json:"peerDependencies"`
}
// ParsePackageJSON parses a package.json and returns npm components.
func ParsePackageJSON(content []byte) []Component {
var pkg packageJSON
if err := json.Unmarshal(content, &pkg); err != nil {
return nil
}
seen := make(map[string]bool)
var components []Component
add := func(name, version, scope string) {
if seen[name] {
return
}
seen[name] = true
// Strip semver range prefixes: ^, ~, >=, >, <=, <, =
clean := strings.TrimLeft(version, "^~>=<")
components = append(components, Component{
Type: "library",
Name: name,
Version: clean,
PURL: npmPURL(name, clean),
Scope: scope,
})
}
for name, ver := range pkg.Dependencies {
add(name, ver, "required")
}
for name, ver := range pkg.DevDependencies {
add(name, ver, "optional")
}
for name, ver := range pkg.PeerDependencies {
add(name, ver, "optional")
}
return components
}
// ─── requirements.txt ────────────────────────────────────────────────────────
// ParseRequirementsTxt parses a pip requirements.txt.
// Handles: pkg==1.0, pkg>=1.0, pkg~=1.0, pkg (no version), comments, extras.
func ParseRequirementsTxt(content []byte) []Component {
var components []Component
scanner := bufio.NewScanner(bytes.NewReader(content))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "-") {
continue
}
// Strip inline comments.
if idx := strings.Index(line, " #"); idx >= 0 {
line = strings.TrimSpace(line[:idx])
}
// Strip extras: package[extra]==1.0 → package, ==1.0
name := line
version := ""
for _, op := range []string{"==", ">=", "<=", "~=", "!=", ">", "<"} {
if idx := strings.Index(line, op); idx >= 0 {
name = strings.TrimSpace(line[:idx])
version = strings.TrimSpace(line[idx+len(op):])
// Take only the first version specifier.
if commaIdx := strings.Index(version, ","); commaIdx >= 0 {
version = version[:commaIdx]
}
break
}
}
// Strip extras [extra1,extra2] from name.
if bIdx := strings.Index(name, "["); bIdx >= 0 {
name = name[:bIdx]
}
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
continue
}
components = append(components, Component{
Type: "library",
Name: name,
Version: version,
PURL: pypiPURL(name, version),
})
}
return components
}
// ─── Cargo.toml ──────────────────────────────────────────────────────────────
// ParseCargoToml parses a Cargo.toml [dependencies] section.
// Handles: name = "version" and name = { version = "x", ... }.
func ParseCargoToml(content []byte) []Component {
var components []Component
scanner := bufio.NewScanner(bytes.NewReader(content))
inDeps := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "#") {
continue
}
// Section headers.
if strings.HasPrefix(line, "[") {
inDeps = line == "[dependencies]" ||
line == "[dev-dependencies]" ||
line == "[build-dependencies]"
continue
}
if !inDeps || line == "" {
continue
}
eqIdx := strings.Index(line, "=")
if eqIdx < 0 {
continue
}
name := strings.TrimSpace(line[:eqIdx])
rest := strings.TrimSpace(line[eqIdx+1:])
var version string
if strings.HasPrefix(rest, `"`) {
// name = "version"
version = strings.Trim(rest, `"`)
} else if strings.HasPrefix(rest, "{") {
// name = { version = "x", features = [...] }
if vIdx := strings.Index(rest, `version = "`); vIdx >= 0 {
vIdx += len(`version = "`)
endIdx := strings.Index(rest[vIdx:], `"`)
if endIdx >= 0 {
version = rest[vIdx : vIdx+endIdx]
}
}
}
if name == "" {
continue
}
components = append(components, Component{
Type: "library",
Name: name,
Version: version,
PURL: cargoPURL(name, version),
})
}
return components
}
// ─── Gemfile.lock ─────────────────────────────────────────────────────────────
// ParseGemfileLock parses a Gemfile.lock and extracts gem components.
// The GEM section format is:
//
// GEM
// remote: https://rubygems.org/
// specs:
// activesupport (7.1.0)
// ...
func ParseGemfileLock(content []byte) []Component {
var components []Component
scanner := bufio.NewScanner(bytes.NewReader(content))
inSpecs := false
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if trimmed == "GEM" {
continue
}
if trimmed == "specs:" {
inSpecs = true
continue
}
// Any non-indented non-empty line ends the specs block.
if inSpecs && !strings.HasPrefix(line, " ") && trimmed != "" {
inSpecs = false
}
if !inSpecs {
continue
}
// Specs entries are indented exactly 4 spaces: " name (version)"
// Sub-dependencies are indented 6+ spaces — skip them.
if !strings.HasPrefix(line, " ") || strings.HasPrefix(line, " ") {
continue
}
// Parse: " gemname (version)"
entry := strings.TrimSpace(line)
oIdx := strings.Index(entry, " (")
if oIdx < 0 {
continue
}
name := entry[:oIdx]
versionFull := strings.TrimSuffix(entry[oIdx+2:], ")")
version := strings.Fields(versionFull)[0]
components = append(components, Component{
Type: "library",
Name: name,
Version: version,
PURL: gemPURL(name, version),
})
}
return components
}
// ─── pom.xml (minimal) ───────────────────────────────────────────────────────
// ParsePomXML does a lightweight line-scan extraction of Maven dependencies.
// It avoids pulling in an XML parser — it looks for <dependency> blocks and
// extracts groupId, artifactId, version tags.
func ParsePomXML(content []byte) []Component {
var components []Component
scanner := bufio.NewScanner(bytes.NewReader(content))
var groupID, artifactID, version string
inDep := false
extract := func(line, tag string) string {
open := "<" + tag + ">"
close := "</" + tag + ">"
sIdx := strings.Index(line, open)
eIdx := strings.Index(line, close)
if sIdx >= 0 && eIdx > sIdx {
return line[sIdx+len(open) : eIdx]
}
return ""
}
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.Contains(line, "<dependency>") {
inDep = true
groupID, artifactID, version = "", "", ""
continue
}
if strings.Contains(line, "</dependency>") {
if inDep && groupID != "" && artifactID != "" {
name := groupID + ":" + artifactID
components = append(components, Component{
Type: "library",
Name: name,
Version: version,
PURL: mavenPURL(groupID, artifactID, version),
})
}
inDep = false
continue
}
if !inDep {
continue
}
if v := extract(line, "groupId"); v != "" {
groupID = v
}
if v := extract(line, "artifactId"); v != "" {
artifactID = v
}
if v := extract(line, "version"); v != "" {
version = v
}
}
return components
}
+293
View File
@@ -0,0 +1,293 @@
package sbom_test
import (
"testing"
"github.com/forgeo/forgebucket/internal/domain/sbom"
)
// ─── go.mod ──────────────────────────────────────────────────────────────────
func TestParseGoMod_Block(t *testing.T) {
content := []byte(`module github.com/example/app
go 1.21
require (
github.com/go-chi/chi/v5 v5.2.5
golang.org/x/crypto v0.50.0 // indirect
)
require github.com/lib/pq v1.12.3
`)
comps := sbom.ParseGoMod(content)
if len(comps) != 3 {
t.Fatalf("expected 3 components, got %d", len(comps))
}
byName := make(map[string]sbom.Component)
for _, c := range comps {
byName[c.Name] = c
}
if c, ok := byName["github.com/go-chi/chi/v5"]; !ok {
t.Error("missing github.com/go-chi/chi/v5")
} else {
if c.Version != "v5.2.5" {
t.Errorf("wrong version: %s", c.Version)
}
if c.PURL != "pkg:golang/github.com/go-chi/chi/v5@v5.2.5" {
t.Errorf("wrong PURL: %s", c.PURL)
}
}
if _, ok := byName["golang.org/x/crypto"]; !ok {
t.Error("missing golang.org/x/crypto (indirect deps must be included)")
}
if _, ok := byName["github.com/lib/pq"]; !ok {
t.Error("missing github.com/lib/pq (single-line require)")
}
}
func TestParseGoMod_Empty(t *testing.T) {
comps := sbom.ParseGoMod([]byte("module foo\n\ngo 1.21\n"))
if len(comps) != 0 {
t.Errorf("expected 0 components, got %d", len(comps))
}
}
// ─── package.json ─────────────────────────────────────────────────────────────
func TestParsePackageJSON(t *testing.T) {
content := []byte(`{
"name": "my-app",
"dependencies": {
"react": "^18.2.0",
"axios": "1.4.0"
},
"devDependencies": {
"vitest": "~1.0.0"
}
}`)
comps := sbom.ParsePackageJSON(content)
if len(comps) != 3 {
t.Fatalf("expected 3 components, got %d", len(comps))
}
byName := make(map[string]sbom.Component)
for _, c := range comps {
byName[c.Name] = c
}
if c, ok := byName["react"]; !ok {
t.Error("missing react")
} else {
if c.Version != "18.2.0" {
t.Errorf("expected version stripped of ^, got %s", c.Version)
}
if c.PURL != "pkg:npm/react@18.2.0" {
t.Errorf("wrong PURL: %s", c.PURL)
}
if c.Scope != "required" {
t.Errorf("expected scope required, got %s", c.Scope)
}
}
if c, ok := byName["vitest"]; !ok {
t.Error("missing vitest")
} else if c.Scope != "optional" {
t.Errorf("devDependency should be optional, got %s", c.Scope)
}
}
func TestParsePackageJSON_Invalid(t *testing.T) {
comps := sbom.ParsePackageJSON([]byte("not json"))
if len(comps) != 0 {
t.Errorf("expected 0 on invalid JSON, got %d", len(comps))
}
}
// ─── requirements.txt ────────────────────────────────────────────────────────
func TestParseRequirementsTxt(t *testing.T) {
content := []byte(`# comment
requests==2.31.0
flask>=2.3.0
numpy~=1.24.0
boto3[s3]==1.28.0 # with extras
no-version-package
-r other-requirements.txt
`)
comps := sbom.ParseRequirementsTxt(content)
byName := make(map[string]sbom.Component)
for _, c := range comps {
byName[c.Name] = c
}
if c, ok := byName["requests"]; !ok {
t.Error("missing requests")
} else if c.Version != "2.31.0" {
t.Errorf("requests version: %s", c.Version)
}
if c, ok := byName["flask"]; !ok {
t.Error("missing flask")
} else if c.Version != "2.3.0" {
t.Errorf("flask version: %s", c.Version)
}
if _, ok := byName["boto3"]; !ok {
t.Error("missing boto3 (extras should be stripped from name)")
}
if _, ok := byName["no-version-package"]; !ok {
t.Error("missing no-version-package")
}
}
// ─── Cargo.toml ──────────────────────────────────────────────────────────────
func TestParseCargoToml(t *testing.T) {
content := []byte(`[package]
name = "my-crate"
version = "0.1.0"
[dependencies]
serde = "1.0"
tokio = { version = "1.28", features = ["full"] }
clap = "4.3"
[dev-dependencies]
criterion = "0.5"
`)
comps := sbom.ParseCargoToml(content)
if len(comps) != 4 {
t.Fatalf("expected 4 components, got %d: %v", len(comps), comps)
}
byName := make(map[string]sbom.Component)
for _, c := range comps {
byName[c.Name] = c
}
if c, ok := byName["serde"]; !ok {
t.Error("missing serde")
} else if c.Version != "1.0" {
t.Errorf("serde version: %s", c.Version)
}
if c, ok := byName["tokio"]; !ok {
t.Error("missing tokio")
} else if c.Version != "1.28" {
t.Errorf("tokio version: %s", c.Version)
}
if _, ok := byName["criterion"]; !ok {
t.Error("missing criterion (dev-dependency)")
}
}
// ─── Gemfile.lock ─────────────────────────────────────────────────────────────
func TestParseGemfileLock(t *testing.T) {
content := []byte(`GEM
remote: https://rubygems.org/
specs:
rails (7.1.0)
actionpack (= 7.1.0)
railties (= 7.1.0)
actionpack (7.1.0)
rake (13.0.6)
PLATFORMS
ruby
DEPENDENCIES
rails (~> 7.1.0)
`)
comps := sbom.ParseGemfileLock(content)
if len(comps) != 3 {
t.Fatalf("expected 3 components, got %d: %v", len(comps), comps)
}
byName := make(map[string]sbom.Component)
for _, c := range comps {
byName[c.Name] = c
}
if c, ok := byName["rails"]; !ok {
t.Error("missing rails")
} else if c.Version != "7.1.0" {
t.Errorf("rails version: %s", c.Version)
}
if c, ok := byName["rake"]; !ok {
t.Error("missing rake")
} else if c.Version != "13.0.6" {
t.Errorf("rake version: %s", c.Version)
}
}
// ─── pom.xml ─────────────────────────────────────────────────────────────────
func TestParsePomXML(t *testing.T) {
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<project>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.1.0</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.6.0</version>
</dependency>
</dependencies>
</project>`)
comps := sbom.ParsePomXML(content)
if len(comps) != 2 {
t.Fatalf("expected 2 components, got %d", len(comps))
}
byName := make(map[string]sbom.Component)
for _, c := range comps {
byName[c.Name] = c
}
if c, ok := byName["org.springframework.boot:spring-boot-starter-web"]; !ok {
t.Error("missing spring-boot-starter-web")
} else {
if c.Version != "3.1.0" {
t.Errorf("spring-boot version: %s", c.Version)
}
if c.PURL != "pkg:maven/org.springframework.boot/spring-boot-starter-web@3.1.0" {
t.Errorf("wrong PURL: %s", c.PURL)
}
}
}
// ─── Document builder ─────────────────────────────────────────────────────────
func TestNewDocument(t *testing.T) {
doc := sbom.NewDocument("my-repo", "abc1234567890")
if doc.BOMFormat != "CycloneDX" {
t.Errorf("BOMFormat: %s", doc.BOMFormat)
}
if doc.SpecVersion != "1.4" {
t.Errorf("SpecVersion: %s", doc.SpecVersion)
}
if doc.Metadata.Component.Name != "my-repo" {
t.Errorf("metadata component name: %s", doc.Metadata.Component.Name)
}
if len(doc.Metadata.Tools) == 0 {
t.Error("expected at least one tool in metadata")
}
if doc.Components == nil {
t.Error("expected non-nil Components slice")
}
}