Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/release-drafter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ name: Release Drafter
on:
push:
branches: [main]
pull_request:
types: [opened, reopened, synchronize, edited]

permissions:
contents: write
pull-requests: write # for autolabeler to apply labels on PRs

jobs:
update-draft:
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:

permissions:
contents: write
id-token: write # for cosign keyless signing via Sigstore OIDC

jobs:
release:
Expand All @@ -26,3 +27,13 @@ jobs:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2

- name: Sign release checksums
run: cosign sign-blob --yes --bundle dist/checksums.txt.bundle dist/checksums.txt

- name: Upload signature to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ github.ref_name }}" dist/checksums.txt.bundle
19 changes: 15 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,23 @@ jobs:
fi
echo "OK: $count schema files extracted"

# Verify SBOM exists
if [ ! -f e2e-schemas/sbom.cdx.json ]; then
echo "FAIL: sbom.cdx.json not found"
# Verify per-group SBOMs exist
sbom_count=$(find e2e-schemas -name 'sbom.cdx.json' | wc -l)
if [ "$sbom_count" -lt 1 ]; then
echo "FAIL: no per-group sbom.cdx.json files found"
exit 1
fi
echo "OK: sbom.cdx.json exists"
echo "OK: $sbom_count per-group SBOM files found"

# Verify each SBOM has a serialNumber
for sbom in $(find e2e-schemas -name 'sbom.cdx.json'); do
sn=$(jq -r '.serialNumber // empty' "$sbom")
if [ -z "$sn" ]; then
echo "FAIL: $sbom missing serialNumber"
exit 1
fi
echo "OK: $sbom has serialNumber $sn"
done

# Verify extracted schemas are valid JSON Schemas
pip install --quiet check-jsonschema
Expand Down
48 changes: 38 additions & 10 deletions cmd/crd-schema-extractor/extract.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,18 +206,36 @@ func runExtract(sourcesPath, outputDir string, parallel int) error {
}
}

// Generate SBOM
sbomJSON, err := sbom.Generate(allSrcList, timestamp)
if err != nil {
return err
// Generate per-API-group SBOMs
groupSources := make(map[string][]source.Source)
for _, e := range dedupedEntries {
group := e.schema.Group
if !containsSource(groupSources[group], e.src) {
groupSources[group] = append(groupSources[group], e.src)
}
}

if err := os.MkdirAll(outputDir, 0755); err != nil {
return err
}
sbomPath := filepath.Join(outputDir, "sbom.cdx.json")
if err := os.WriteFile(sbomPath, sbomJSON, 0644); err != nil {
return err
for group, sources := range groupSources {
sbomPath := filepath.Join(outputDir, group, "sbom.cdx.json")
existing, err := sbom.LoadExisting(sbomPath)
if err != nil {
log.Warn().Err(err).Str("group", group).Msg("loading existing SBOM")
}
if !sbom.HasChanged(sources, existing) {
log.Debug().Str("group", group).Msg("SBOM unchanged, skipping")
continue
}
sbomJSON, err := sbom.Generate(sources, timestamp, existing)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Join(outputDir, group), 0755); err != nil {
return err
}
if err := os.WriteFile(sbomPath, sbomJSON, 0644); err != nil {
return err
}
log.Info().Str("group", group).Int("sources", len(sources)).Msg("wrote SBOM")
}

log.Info().
Expand Down Expand Up @@ -335,6 +353,16 @@ func runFetchOnly(sourcesPath, outputDir string, parallel int) error {
return nil
}

// containsSource reports whether the slice already contains a source with the given name.
func containsSource(sources []source.Source, src source.Source) bool {
for _, s := range sources {
if s.Name == src.Name {
return true
}
}
return false
}

// fileContentEqual returns true if the file at path exists and its content
// has the same SHA-256 hash as data.
func fileContentEqual(path string, data []byte) bool {
Expand Down
127 changes: 122 additions & 5 deletions internal/sbom/sbom.go
Original file line number Diff line number Diff line change
@@ -1,20 +1,120 @@
// Package sbom generates a CycloneDX SBOM from the extracted sources.
// Package sbom generates per-API-group CycloneDX SBOMs from extracted sources.
package sbom

import (
"bytes"
"crypto/rand"
"fmt"
"os"
"sort"

cdx "github.com/CycloneDX/cyclonedx-go"
"github.com/rs/zerolog/log"

"github.com/blacksd/crd-schema-extractor/internal/provenance"
"github.com/blacksd/crd-schema-extractor/internal/source"
)

// ComponentState captures the identity of a component for change detection.
type ComponentState struct {
Name string
Version string
}

// State holds the relevant fields from an existing SBOM needed for
// serial number preservation and version bumping.
type State struct {
SerialNumber string
Version int
Components []ComponentState
}

// LoadExisting reads an existing SBOM file and extracts its state.
// Returns nil, nil if the file does not exist.
// Returns nil, nil (with a warning log) if the file is corrupt or unparseable.
func LoadExisting(path string) (*State, error) {
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer f.Close()

bom := cdx.NewBOM()
decoder := cdx.NewBOMDecoder(f, cdx.BOMFileFormatJSON)
if err := decoder.Decode(bom); err != nil {
log.Warn().Err(err).Str("path", path).Msg("corrupt SBOM, treating as fresh")
return nil, nil
}

state := &State{
SerialNumber: bom.SerialNumber,
Version: bom.Version,
}

if bom.Components != nil {
for _, c := range *bom.Components {
state.Components = append(state.Components, ComponentState{
Name: c.Name,
Version: c.Version,
})
}
}

return state, nil
}

// HasChanged reports whether the source list differs from the existing SBOM state.
// Returns true if existing is nil (no previous SBOM).
func HasChanged(sources []source.Source, existing *State) bool {
if existing == nil {
return true
}

current := make([]ComponentState, len(sources))
for i, src := range sources {
current[i] = ComponentState{Name: src.Name, Version: src.Version}
}
sort.Slice(current, func(i, j int) bool { return current[i].Name < current[j].Name })

if len(current) != len(existing.Components) {
return true
}
for i := range current {
if current[i] != existing.Components[i] {
return true
}
}
return false
}

// newSerialNumber generates a URN UUID v4 using crypto/rand.
func newSerialNumber() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(fmt.Sprintf("crypto/rand failed: %v", err))
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("urn:uuid:%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}

// Generate creates a CycloneDX 1.5 SBOM from a list of sources.
func Generate(sources []source.Source, timestamp string) ([]byte, error) {
// If existing is non-nil, the serial number is preserved and version is bumped.
// If existing is nil, a fresh serial number and version 1 are used.
func Generate(sources []source.Source, timestamp string, existing *State) ([]byte, error) {
bom := cdx.NewBOM()

if existing != nil {
bom.SerialNumber = existing.SerialNumber
bom.Version = existing.Version + 1
} else {
bom.SerialNumber = newSerialNumber()
bom.Version = 1
}

bom.Metadata = &cdx.Metadata{
Timestamp: timestamp,
Tools: &cdx.ToolsChoice{
Expand Down Expand Up @@ -42,11 +142,28 @@ func Generate(sources []source.Source, timestamp string) ([]byte, error) {
},
}
}

var refs []cdx.ExternalReference
if src.Homepage != "" {
c.ExternalReferences = &[]cdx.ExternalReference{
{Type: cdx.ERTypeWebsite, URL: src.Homepage},
}
refs = append(refs, cdx.ExternalReference{Type: cdx.ERTypeWebsite, URL: src.Homepage})
}
if len(refs) > 0 {
c.ExternalReferences = &refs
}

var props []cdx.Property
props = append(props, cdx.Property{Name: "crd-schemas:source:type", Value: src.Type})
if src.Repo != "" {
props = append(props, cdx.Property{Name: "crd-schemas:source:repo", Value: src.Repo})
}
if src.URL != "" {
props = append(props, cdx.Property{Name: "crd-schemas:source:url", Value: src.URL})
}
if src.Chart != "" {
props = append(props, cdx.Property{Name: "crd-schemas:source:chart", Value: src.Chart})
}
c.Properties = &props

components = append(components, c)
}

Expand Down
Loading
Loading