diff --git a/.gitignore b/.gitignore index 744145b..42593e8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Binaries ubuntu-sbom sbom-merge +ubuntu-nix-sbom +ubuntu-sbom-arm64 # SBOM outputs *.spdx.json @@ -15,6 +17,11 @@ go.sum # Nix result result-* +.envrc +.direnv + +# Pre-commit +.pre-commit-config.yaml # IDE .vscode/ diff --git a/README.md b/README.md index 5dd774d..844b3b9 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,36 @@ A comprehensive SBOM (Software Bill of Materials) generator for systems running - **License Detection**: Extracts license information from package metadata - **Package URLs (purl)**: Includes purl references for both deb and nix packages +## Quick Run + +No installation required! Run directly from GitHub: + +### Generate Merged SBOM (Ubuntu + Nix) + +```bash +# Generate combined SBOM for your system +nix run --extra-experimental-features "nix-command flakes" github:supabase/ubuntu-nix-sbom#sbom-generator -- \ + --nix-target /nix/var/nix/profiles/system \ + --output system-sbom.json +``` + +### Generate Ubuntu-Only SBOM + +```bash +# Scan only Ubuntu/Debian packages +nix run --extra-experimental-features "nix-command flakes" github:supabase/ubuntu-nix-sbom#sbom-ubuntu -- \ + --output ubuntu-sbom.json +``` + +### Generate Nix-Only SBOM + +```bash +# Analyze a specific Nix derivation +nix run --extra-experimental-features "nix-command flakes" github:supabase/ubuntu-nix-sbom#sbom-nix -- \ + /nix/store/xxx-your-derivation \ + --output nix-sbom.json +``` + ## Prerequisites - Nix with flakes enabled @@ -22,7 +52,7 @@ A comprehensive SBOM (Software Bill of Materials) generator for systems running Clone the repository and enter the development shell: ```bash -git clone +git clone https://github.com/supabase/ubuntu-nix-sbom cd ubuntu-nix-sbom nix develop ``` @@ -283,6 +313,28 @@ Both the PR check and release workflows automatically validate SPDX output: ## Development +### Project Structure + +The project follows the standard Go project layout: + +``` +. +├── internal/ +│ └── sbom/ +│ └── types.go # Shared types and utilities (package sbom) +├── cmd/ +│ ├── ubuntu-sbom/ +│ │ └── main.go # Ubuntu SBOM generator binary +│ └── sbom-merge/ +│ └── main.go # SBOM merger binary +├── flake.nix # Nix flake configuration +└── go.mod # Go module (github.com/supabase/ubuntu-nix-sbom) +``` + +The `internal/` directory is a Go convention that prevents external packages from importing the code, ensuring the shared types remain internal to this project. + +### Development Environment + Enter the development shell: ```bash @@ -290,17 +342,25 @@ nix develop ``` This provides: -- Go toolchain +- Go toolchain (with Go modules support) - gopls (Go language server) - sbomnix - Formatting tools (nixfmt, shellcheck, shfmt, gofmt) - Pre-commit hooks (automatically installed) +### Building Binaries + Build the Go binaries manually: ```bash -go build -o ubuntu-sbom main.go -go build -o sbom-merge merge.go +# Build Ubuntu SBOM generator +go build -o ubuntu-sbom ./cmd/ubuntu-sbom + +# Build SBOM merger +go build -o sbom-merge ./cmd/sbom-merge + +# Build both +go build ./cmd/... ``` ### Code Formatting diff --git a/merge.go b/cmd/sbom-merge/main.go similarity index 85% rename from merge.go rename to cmd/sbom-merge/main.go index 0895266..ae14fa2 100644 --- a/merge.go +++ b/cmd/sbom-merge/main.go @@ -9,9 +9,11 @@ import ( "regexp" "strings" "time" + + "github.com/supabase/ubuntu-nix-sbom/internal/sbom" ) -func mainMerge() { +func main() { var ( ubuntuSBOM = flag.String("ubuntu", "", "Path to Ubuntu SBOM JSON file") nixSBOM = flag.String("nix", "", "Path to Nix SBOM JSON file") @@ -38,7 +40,7 @@ func mainMerge() { type SBOMMerger struct{} -func (m *SBOMMerger) Merge(ubuntuPath, nixPath string) (*SPDXDocument, error) { +func (m *SBOMMerger) Merge(ubuntuPath, nixPath string) (*sbom.SPDXDocument, error) { // Load Ubuntu SBOM ubuntuDoc, err := m.loadDocument(ubuntuPath) if err != nil { @@ -52,23 +54,23 @@ func (m *SBOMMerger) Merge(ubuntuPath, nixPath string) (*SPDXDocument, error) { } // Create merged document - mergedDoc := &SPDXDocument{ + mergedDoc := &sbom.SPDXDocument{ SPDXVersion: "SPDX-2.3", DataLicense: "CC0-1.0", SPDXID: "SPDXRef-DOCUMENT", Name: fmt.Sprintf("Ubuntu-Nix-System-SBOM-%s", time.Now().Format("2006-01-02")), - DocumentNamespace: fmt.Sprintf("https://sbom.ubuntu-nix.system/%s", generateUUID()), - CreationInfo: CreationInfo{ + DocumentNamespace: fmt.Sprintf("https://sbom.ubuntu-nix.system/%s", sbom.GenerateUUID()), + CreationInfo: sbom.CreationInfo{ Created: time.Now().UTC().Format(time.RFC3339), Creators: m.mergeCreators(ubuntuDoc, nixDoc), LicenseListVersion: "3.20", }, - Packages: []Package{}, - Relationships: []Relationship{}, + Packages: []sbom.Package{}, + Relationships: []sbom.Relationship{}, } // Create the single root System package - systemPkg := Package{ + systemPkg := sbom.Package{ SPDXID: "SPDXRef-System", Name: "Ubuntu-Nix-System", DownloadLocation: "NOASSERTION", @@ -81,7 +83,7 @@ func (m *SBOMMerger) Merge(ubuntuPath, nixPath string) (*SPDXDocument, error) { mergedDoc.Packages = append(mergedDoc.Packages, systemPkg) // Add document describes relationship - mergedDoc.Relationships = append(mergedDoc.Relationships, Relationship{ + mergedDoc.Relationships = append(mergedDoc.Relationships, sbom.Relationship{ SPDXElementID: "SPDXRef-DOCUMENT", RelatedSPDXElement: "SPDXRef-System", RelationshipType: "DESCRIBES", @@ -102,7 +104,7 @@ func (m *SBOMMerger) Merge(ubuntuPath, nixPath string) (*SPDXDocument, error) { mergedDoc.Packages = append(mergedDoc.Packages, pkg) // Add relationship to system root - mergedDoc.Relationships = append(mergedDoc.Relationships, Relationship{ + mergedDoc.Relationships = append(mergedDoc.Relationships, sbom.Relationship{ SPDXElementID: "SPDXRef-System", RelatedSPDXElement: pkg.SPDXID, RelationshipType: "CONTAINS", @@ -127,7 +129,7 @@ func (m *SBOMMerger) Merge(ubuntuPath, nixPath string) (*SPDXDocument, error) { mergedDoc.Packages = append(mergedDoc.Packages, pkg) // Add relationship to system root - mergedDoc.Relationships = append(mergedDoc.Relationships, Relationship{ + mergedDoc.Relationships = append(mergedDoc.Relationships, sbom.Relationship{ SPDXElementID: "SPDXRef-System", RelatedSPDXElement: pkg.SPDXID, RelationshipType: "CONTAINS", @@ -140,13 +142,13 @@ func (m *SBOMMerger) Merge(ubuntuPath, nixPath string) (*SPDXDocument, error) { return mergedDoc, nil } -func (m *SBOMMerger) loadDocument(path string) (*SPDXDocument, error) { +func (m *SBOMMerger) loadDocument(path string) (*sbom.SPDXDocument, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } - var doc SPDXDocument + var doc sbom.SPDXDocument if err := json.Unmarshal(data, &doc); err != nil { return nil, err } @@ -154,7 +156,7 @@ func (m *SBOMMerger) loadDocument(path string) (*SPDXDocument, error) { return &doc, nil } -func (m *SBOMMerger) mergeCreators(ubuntuDoc, nixDoc *SPDXDocument) []string { +func (m *SBOMMerger) mergeCreators(ubuntuDoc, nixDoc *sbom.SPDXDocument) []string { creatorMap := make(map[string]bool) var creators []string @@ -196,7 +198,7 @@ func (m *SBOMMerger) renumberSPDXID(originalID, prefix string) string { return fmt.Sprintf("SPDXRef-%s-%s", prefix, strings.TrimPrefix(originalID, "SPDXRef-")) } -func (m *SBOMMerger) Save(doc *SPDXDocument, outputPath string) error { +func (m *SBOMMerger) Save(doc *sbom.SPDXDocument, outputPath string) error { file, err := os.Create(outputPath) if err != nil { return err diff --git a/main.go b/cmd/ubuntu-sbom/main.go similarity index 73% rename from main.go rename to cmd/ubuntu-sbom/main.go index 988668d..af7097f 100644 --- a/main.go +++ b/cmd/ubuntu-sbom/main.go @@ -6,82 +6,15 @@ import ( "encoding/json" "flag" "fmt" - "io" "log" "os" "os/exec" "regexp" "strings" "time" -) - -// SPDX Document structure -type SPDXDocument struct { - SPDXVersion string `json:"spdxVersion"` - DataLicense string `json:"dataLicense"` - SPDXID string `json:"SPDXID"` - Name string `json:"name"` - DocumentNamespace string `json:"documentNamespace"` - CreationInfo CreationInfo `json:"creationInfo"` - Packages []Package `json:"packages"` - Relationships []Relationship `json:"relationships"` -} - -type CreationInfo struct { - Created string `json:"created"` - Creators []string `json:"creators"` - LicenseListVersion string `json:"licenseListVersion"` -} - -type Package struct { - SPDXID string `json:"SPDXID"` - Name string `json:"name"` - DownloadLocation string `json:"downloadLocation"` - FilesAnalyzed bool `json:"filesAnalyzed"` - VerificationCode *Verification `json:"verificationCode,omitempty"` - Checksums []Checksum `json:"checksums,omitempty"` - HomePage string `json:"homePage,omitempty"` - LicenseConcluded string `json:"licenseConcluded"` - LicenseDeclared string `json:"licenseDeclared"` - CopyrightText string `json:"copyrightText"` - Description string `json:"description,omitempty"` - PackageVersion string `json:"versionInfo,omitempty"` - Supplier string `json:"supplier,omitempty"` - ExternalRefs []ExternalRef `json:"externalRefs,omitempty"` -} - -type Verification struct { - Value string `json:"packageVerificationCodeValue"` -} -type Checksum struct { - Algorithm string `json:"algorithm"` - Value string `json:"checksumValue"` -} - -type Relationship struct { - SPDXElementID string `json:"spdxElementId"` - RelatedSPDXElement string `json:"relatedSpdxElement"` - RelationshipType string `json:"relationshipType"` -} - -type ExternalRef struct { - Category string `json:"referenceCategory"` - Type string `json:"referenceType"` - Locator string `json:"referenceLocator"` -} - -type DpkgPackage struct { - Name string - Version string - Architecture string - Status string - Maintainer string - Homepage string - Description string - License string - Copyright string -} + "github.com/supabase/ubuntu-nix-sbom/internal/sbom" +) func main() { var ( @@ -113,29 +46,29 @@ type SBOMGenerator struct { showProgress bool } -func (g *SBOMGenerator) Generate() (*SPDXDocument, error) { +func (g *SBOMGenerator) Generate() (*sbom.SPDXDocument, error) { packages, err := g.getInstalledPackages() if err != nil { return nil, fmt.Errorf("failed to get packages: %w", err) } - doc := &SPDXDocument{ + doc := &sbom.SPDXDocument{ SPDXVersion: "SPDX-2.3", DataLicense: "CC0-1.0", SPDXID: "SPDXRef-DOCUMENT", Name: fmt.Sprintf("Ubuntu-System-SBOM-%s", time.Now().Format("2006-01-02")), - DocumentNamespace: fmt.Sprintf("https://sbom.ubuntu.system/%s", generateUUID()), - CreationInfo: CreationInfo{ + DocumentNamespace: fmt.Sprintf("https://sbom.ubuntu.system/%s", sbom.GenerateUUID()), + CreationInfo: sbom.CreationInfo{ Created: time.Now().UTC().Format(time.RFC3339), Creators: []string{"Tool: ubuntu-sbom-generator-1.0"}, LicenseListVersion: "3.20", }, - Packages: []Package{}, - Relationships: []Relationship{}, + Packages: []sbom.Package{}, + Relationships: []sbom.Relationship{}, } // Add root package representing the Ubuntu system - rootPkg := Package{ + rootPkg := sbom.Package{ SPDXID: "SPDXRef-Ubuntu-System", Name: "Ubuntu-System", DownloadLocation: "NOASSERTION", @@ -156,7 +89,7 @@ func (g *SBOMGenerator) Generate() (*SPDXDocument, error) { doc.Packages = append(doc.Packages, spdxPkg) // Add relationship - doc.Relationships = append(doc.Relationships, Relationship{ + doc.Relationships = append(doc.Relationships, sbom.Relationship{ SPDXElementID: "SPDXRef-Ubuntu-System", RelatedSPDXElement: spdxPkg.SPDXID, RelationshipType: "CONTAINS", @@ -164,7 +97,7 @@ func (g *SBOMGenerator) Generate() (*SPDXDocument, error) { } // Add document describes relationship - doc.Relationships = append(doc.Relationships, Relationship{ + doc.Relationships = append(doc.Relationships, sbom.Relationship{ SPDXElementID: "SPDXRef-DOCUMENT", RelatedSPDXElement: "SPDXRef-Ubuntu-System", RelationshipType: "DESCRIBES", @@ -173,14 +106,14 @@ func (g *SBOMGenerator) Generate() (*SPDXDocument, error) { return doc, nil } -func (g *SBOMGenerator) getInstalledPackages() ([]DpkgPackage, error) { +func (g *SBOMGenerator) getInstalledPackages() ([]sbom.DpkgPackage, error) { cmd := exec.Command("dpkg-query", "-W", "-f=${Package}\t${Version}\t${Architecture}\t${Status}\t${Maintainer}\t${Homepage}\t${Description}\n") output, err := cmd.Output() if err != nil { return nil, err } - var packages []DpkgPackage + var packages []sbom.DpkgPackage scanner := bufio.NewScanner(strings.NewReader(string(output))) for scanner.Scan() { @@ -188,7 +121,7 @@ func (g *SBOMGenerator) getInstalledPackages() ([]DpkgPackage, error) { parts := strings.Split(line, "\t") if len(parts) >= 7 && strings.Contains(parts[3], "installed") { - pkg := DpkgPackage{ + pkg := sbom.DpkgPackage{ Name: parts[0], Version: parts[1], Architecture: parts[2], @@ -239,8 +172,8 @@ func (g *SBOMGenerator) getPackageLicense(packageName string) (string, string) { return license, copyright } -func (g *SBOMGenerator) packageToSPDX(pkg DpkgPackage, id int) Package { - spdxPkg := Package{ +func (g *SBOMGenerator) packageToSPDX(pkg sbom.DpkgPackage, id int) sbom.Package { + spdxPkg := sbom.Package{ SPDXID: fmt.Sprintf("SPDXRef-Ubuntu-Package-%d-%s", id, sanitizeName(pkg.Name)), Name: pkg.Name, PackageVersion: pkg.Version, @@ -261,7 +194,7 @@ func (g *SBOMGenerator) packageToSPDX(pkg DpkgPackage, id int) Package { } // Add external reference for the package - spdxPkg.ExternalRefs = []ExternalRef{ + spdxPkg.ExternalRefs = []sbom.ExternalRef{ { Category: "PACKAGE-MANAGER", Type: "purl", @@ -272,7 +205,7 @@ func (g *SBOMGenerator) packageToSPDX(pkg DpkgPackage, id int) Package { // If include-files is set, calculate package verification if g.includeFiles { if checksum := g.calculatePackageChecksum(pkg.Name); checksum != "" { - spdxPkg.Checksums = []Checksum{ + spdxPkg.Checksums = []sbom.Checksum{ { Algorithm: "SHA256", Value: checksum, @@ -300,7 +233,7 @@ func (g *SBOMGenerator) calculatePackageChecksum(packageName string) string { continue } - if fileHash := hashFile(filePath); fileHash != "" { + if fileHash := sbom.HashFile(filePath); fileHash != "" { h.Write([]byte(fileHash)) } } @@ -308,22 +241,7 @@ func (g *SBOMGenerator) calculatePackageChecksum(packageName string) string { return fmt.Sprintf("%x", h.Sum(nil)) } -func hashFile(path string) string { - file, err := os.Open(path) - if err != nil { - return "" - } - defer file.Close() - - h := sha256.New() - if _, err := io.Copy(h, file); err != nil { - return "" - } - - return fmt.Sprintf("%x", h.Sum(nil)) -} - -func (g *SBOMGenerator) Save(doc *SPDXDocument, outputPath string) error { +func (g *SBOMGenerator) Save(doc *sbom.SPDXDocument, outputPath string) error { file, err := os.Create(outputPath) if err != nil { return err @@ -442,14 +360,3 @@ func sanitizeName(name string) string { re := regexp.MustCompile(`[^a-zA-Z0-9-.]`) return re.ReplaceAllString(name, "-") } - -func generateUUID() string { - // Simple UUID v4 generation - b := make([]byte, 16) - for i := range b { - b[i] = byte(time.Now().UnixNano() & 0xff) - } - - return fmt.Sprintf("%x-%x-%x-%x-%x", - b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) -} diff --git a/flake.nix b/flake.nix index d362b1e..8949f45 100644 --- a/flake.nix +++ b/flake.nix @@ -48,7 +48,7 @@ vendorHash = null; buildPhase = '' - go build -o ubuntu-sbom main.go + go build -o ubuntu-sbom ./cmd/ubuntu-sbom ''; installPhase = '' @@ -70,7 +70,7 @@ vendorHash = null; buildPhase = '' - go build -o sbom-merge merge.go + go build -o sbom-merge ./cmd/sbom-merge ''; installPhase = '' @@ -189,7 +189,7 @@ # Build static binary with no CGO buildPhase = '' - CGO_ENABLED=0 go build -a -ldflags '-s -w -extldflags "-static"' -o ubuntu-sbom main.go + CGO_ENABLED=0 go build -a -ldflags '-s -w -extldflags "-static"' -o ubuntu-sbom ./cmd/ubuntu-sbom ''; installPhase = '' @@ -220,7 +220,7 @@ # Build static binary with no CGO buildPhase = '' - CGO_ENABLED=0 go build -a -ldflags '-s -w -extldflags "-static"' -o ubuntu-sbom main.go + CGO_ENABLED=0 go build -a -ldflags '-s -w -extldflags "-static"' -o ubuntu-sbom ./cmd/ubuntu-sbom ''; installPhase = '' diff --git a/go.mod b/go.mod index ca29d59..d11ca1d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,3 @@ -module github.com/ubuntu-nix-sbom +module github.com/supabase/ubuntu-nix-sbom go 1.21 - -require ( -) diff --git a/internal/sbom/types.go b/internal/sbom/types.go new file mode 100644 index 0000000..1e0f430 --- /dev/null +++ b/internal/sbom/types.go @@ -0,0 +1,101 @@ +package sbom + +import ( + "crypto/sha256" + "fmt" + "io" + "os" + "time" +) + +type SPDXDocument struct { + SPDXVersion string `json:"spdxVersion"` + DataLicense string `json:"dataLicense"` + SPDXID string `json:"SPDXID"` + Name string `json:"name"` + DocumentNamespace string `json:"documentNamespace"` + CreationInfo CreationInfo `json:"creationInfo"` + Packages []Package `json:"packages"` + Relationships []Relationship `json:"relationships"` +} + +type CreationInfo struct { + Created string `json:"created"` + Creators []string `json:"creators"` + LicenseListVersion string `json:"licenseListVersion"` +} + +type Package struct { + SPDXID string `json:"SPDXID"` + Name string `json:"name"` + DownloadLocation string `json:"downloadLocation"` + FilesAnalyzed bool `json:"filesAnalyzed"` + VerificationCode *Verification `json:"verificationCode,omitempty"` + Checksums []Checksum `json:"checksums,omitempty"` + HomePage string `json:"homePage,omitempty"` + LicenseConcluded string `json:"licenseConcluded"` + LicenseDeclared string `json:"licenseDeclared"` + CopyrightText string `json:"copyrightText"` + Description string `json:"description,omitempty"` + PackageVersion string `json:"versionInfo,omitempty"` + Supplier string `json:"supplier,omitempty"` + ExternalRefs []ExternalRef `json:"externalRefs,omitempty"` +} + +type Verification struct { + Value string `json:"packageVerificationCodeValue"` +} + +type Checksum struct { + Algorithm string `json:"algorithm"` + Value string `json:"checksumValue"` +} + +type Relationship struct { + SPDXElementID string `json:"spdxElementId"` + RelatedSPDXElement string `json:"relatedSpdxElement"` + RelationshipType string `json:"relationshipType"` +} + +type ExternalRef struct { + Category string `json:"referenceCategory"` + Type string `json:"referenceType"` + Locator string `json:"referenceLocator"` +} + +type DpkgPackage struct { + Name string + Version string + Architecture string + Status string + Maintainer string + Homepage string + Description string + License string + Copyright string +} + +func GenerateUUID() string { + b := make([]byte, 16) + for i := range b { + b[i] = byte(time.Now().UnixNano() & 0xff) + } + + return fmt.Sprintf("%x-%x-%x-%x-%x", + b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) +} + +func HashFile(path string) string { + file, err := os.Open(path) + if err != nil { + return "" + } + defer file.Close() + + h := sha256.New() + if _, err := io.Copy(h, file); err != nil { + return "" + } + + return fmt.Sprintf("%x", h.Sum(nil)) +} diff --git a/ubuntu-sbom-arm64 b/ubuntu-sbom-arm64 deleted file mode 100755 index 4cbd563..0000000 Binary files a/ubuntu-sbom-arm64 and /dev/null differ