added signed artifacts and SBOM generation capabilities
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user