From 822f723ff1d493206fc86649644defd7d48cc4c0 Mon Sep 17 00:00:00 2001 From: erangel1 Date: Tue, 12 May 2026 21:31:43 +0200 Subject: [PATCH] added signed artifacts and SBOM generation capabilities --- cmd/forgebucket/main.go | 22 +- internal/api/handlers/artifacts.go | 109 ++++++- internal/api/handlers/sbom.go | 137 +++++++++ internal/api/router.go | 14 +- internal/config/config.go | 7 + internal/domain/sbom/cyclonedx.go | 99 ++++++ internal/domain/sbom/generator.go | 190 ++++++++++++ internal/domain/sbom/parsers.go | 353 ++++++++++++++++++++++ internal/domain/sbom/parsers_test.go | 293 ++++++++++++++++++ internal/domain/signing/keystore.go | 189 ++++++++++++ internal/domain/signing/keystore_test.go | 153 ++++++++++ internal/models/migrations/001_init.go | 8 +- internal/models/migrations/015_signing.go | 10 + internal/models/migrations/016_sbom.go | 10 + internal/models/sbom.go | 17 ++ internal/models/signing.go | 16 + 16 files changed, 1615 insertions(+), 12 deletions(-) create mode 100644 internal/api/handlers/sbom.go create mode 100644 internal/domain/sbom/cyclonedx.go create mode 100644 internal/domain/sbom/generator.go create mode 100644 internal/domain/sbom/parsers.go create mode 100644 internal/domain/sbom/parsers_test.go create mode 100644 internal/domain/signing/keystore.go create mode 100644 internal/domain/signing/keystore_test.go create mode 100644 internal/models/migrations/015_signing.go create mode 100644 internal/models/migrations/016_sbom.go create mode 100644 internal/models/sbom.go create mode 100644 internal/models/signing.go diff --git a/cmd/forgebucket/main.go b/cmd/forgebucket/main.go index 6c0ab75..f9a11b0 100644 --- a/cmd/forgebucket/main.go +++ b/cmd/forgebucket/main.go @@ -20,6 +20,8 @@ import ( "github.com/forgeo/forgebucket/internal/domain/ci" gitdomain "github.com/forgeo/forgebucket/internal/domain/git" "github.com/forgeo/forgebucket/internal/domain/gitops" + "github.com/forgeo/forgebucket/internal/domain/sbom" + "github.com/forgeo/forgebucket/internal/domain/signing" "github.com/forgeo/forgebucket/internal/events" "github.com/forgeo/forgebucket/internal/observability" "github.com/forgeo/forgebucket/internal/models/migrations" @@ -78,9 +80,27 @@ func main() { gitopsCtrl := gitops.NewController(engine, bus, cfg) go gitopsCtrl.Start(ciCtx) + sbomGen := sbom.NewGenerator(engine, bus) + go sbomGen.Start(ciCtx) + go observability.StartNATSWatcher(ciCtx, bus) - handler := api.New(cfg, engine, store, bus, cfg.ArtifactRoot, web.FS()) + // Initialise artifact signing key store. + var keyStore *signing.KeyStore + if cfg.ArtifactSigningKey != "" { + keyStore, err = signing.New(cfg.ArtifactSigningKey) + if err != nil { + log.Fatalf("signing: %v", err) + } + } else { + keyStore, err = signing.Generate() + if err != nil { + log.Fatalf("signing: %v", err) + } + } + log.Printf("signing: key store initialised (keyId=%s)", keyStore.KeyID()) + + handler := api.New(cfg, engine, store, bus, cfg.ArtifactRoot, web.FS(), *keyStore, sbomGen) srv := &http.Server{ Addr: fmt.Sprintf(":%s", cfg.Port), diff --git a/internal/api/handlers/artifacts.go b/internal/api/handlers/artifacts.go index 2d023dd..d3ea721 100644 --- a/internal/api/handlers/artifacts.go +++ b/internal/api/handlers/artifacts.go @@ -1,29 +1,33 @@ package handlers import ( + "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strconv" + "time" "github.com/go-chi/chi/v5" "xorm.io/xorm" + "github.com/forgeo/forgebucket/internal/domain/signing" "github.com/forgeo/forgebucket/internal/models" ) type ArtifactHandler struct { db *xorm.Engine artifactRoot string + keys *signing.KeyStore } -func NewArtifactHandler(db *xorm.Engine, artifactRoot string) *ArtifactHandler { - return &ArtifactHandler{db: db, artifactRoot: artifactRoot} +func NewArtifactHandler(db *xorm.Engine, artifactRoot string, keys *signing.KeyStore) *ArtifactHandler { + return &ArtifactHandler{db: db, artifactRoot: artifactRoot, keys: keys} } -// ListArtifacts returns all artifacts for a pipeline run. +// List returns all artifacts for a pipeline run. func (h *ArtifactHandler) List(w http.ResponseWriter, r *http.Request) { repoID, runID, ok := h.resolveRunIDs(w, r) if !ok { @@ -40,8 +44,8 @@ func (h *ArtifactHandler) List(w http.ResponseWriter, r *http.Request) { jsonOK(w, artifacts) } -// Upload accepts a multipart file upload and stores it as an artifact. -// Callers must provide a valid Bearer access token with write scope (runner auth). +// Upload accepts a multipart file upload, stores it as an artifact, and +// immediately signs it using the server's signing key. func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) { repoID, runID, ok := h.resolveRunIDs(w, r) if !ok { @@ -87,8 +91,13 @@ func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) { } defer dst.Close() - size, err := io.Copy(dst, file) + // Read into memory so we can both write to disk and sign. + content, err := io.ReadAll(file) if err != nil { + jsonError(w, "could not read upload", http.StatusInternalServerError) + return + } + if _, err := dst.Write(content); err != nil { jsonError(w, "could not write file", http.StatusInternalServerError) return } @@ -106,7 +115,7 @@ func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) { RepoID: repoID, Name: name, StoragePath: relPath, - Size: size, + Size: int64(len(content)), ContentType: ct, } if _, err := h.db.Insert(artifact); err != nil { @@ -114,10 +123,38 @@ func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) { return } + // Sign the artifact and persist the bundle. + go h.signArtifact(artifact.ID, name, content) + w.WriteHeader(http.StatusCreated) jsonOK(w, artifact) } +// signArtifact is called in a goroutine after a successful upload. +func (h *ArtifactHandler) signArtifact(artifactID int64, name string, content []byte) { + bundle, err := h.keys.Sign(artifactID, name, content) + if err != nil { + fmt.Printf("signing: failed to sign artifact %d: %v\n", artifactID, err) + return + } + bundleJSON, err := json.Marshal(bundle) + if err != nil { + fmt.Printf("signing: failed to marshal bundle for artifact %d: %v\n", artifactID, err) + return + } + sig := &models.ArtifactSignature{ + ArtifactID: artifactID, + KeyID: bundle.KeyID, + Algorithm: "ecdsa-p256-sha256", + Digest: bundle.Payload.Digest, + BundleJSON: string(bundleJSON), + SignedAt: time.Now().UTC(), + } + if _, err := h.db.Insert(sig); err != nil { + fmt.Printf("signing: failed to store signature for artifact %d: %v\n", artifactID, err) + } +} + // Download streams the artifact file to the client. func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) { artifactID, err := strconv.ParseInt(chi.URLParam(r, "artifactID"), 10, 64) @@ -132,7 +169,6 @@ func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) { } fullPath := filepath.Join(h.artifactRoot, filepath.FromSlash(artifact.StoragePath)) - // Ensure the resolved path stays within artifactRoot (traversal guard). if !isUnder(h.artifactRoot, fullPath) { jsonError(w, "forbidden", http.StatusForbidden) return @@ -155,6 +191,63 @@ func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) { io.Copy(w, f) //nolint:errcheck } +// GetSignature returns the full signature bundle JSON for an artifact. +// GET /api/v1/repos/{owner}/{repo}/artifacts/{artifactID}/signature +func (h *ArtifactHandler) GetSignature(w http.ResponseWriter, r *http.Request) { + artifactID, err := strconv.ParseInt(chi.URLParam(r, "artifactID"), 10, 64) + if err != nil { + jsonError(w, "invalid artifact ID", http.StatusBadRequest) + return + } + + var sig models.ArtifactSignature + found, err := h.db.Where("artifact_id = ?", artifactID).Get(&sig) + if err != nil { + jsonError(w, "database error", http.StatusInternalServerError) + return + } + if !found { + jsonError(w, "signature not found — artifact may still be pending signing", http.StatusNotFound) + return + } + + // Return the raw bundle JSON so clients can verify independently. + w.Header().Set("Content-Type", "application/vnd.forgebucket.signature.bundle+json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(sig.BundleJSON)) //nolint:errcheck +} + +// VerifySignature verifies the stored signature bundle for an artifact. +// GET /api/v1/repos/{owner}/{repo}/artifacts/{artifactID}/verify +func (h *ArtifactHandler) VerifySignature(w http.ResponseWriter, r *http.Request) { + artifactID, err := strconv.ParseInt(chi.URLParam(r, "artifactID"), 10, 64) + if err != nil { + jsonError(w, "invalid artifact ID", http.StatusBadRequest) + return + } + + var sig models.ArtifactSignature + found, err := h.db.Where("artifact_id = ?", artifactID).Get(&sig) + if err != nil { + jsonError(w, "database error", http.StatusInternalServerError) + return + } + if !found { + jsonError(w, "signature not found", http.StatusNotFound) + return + } + + result, err := h.keys.Verify([]byte(sig.BundleJSON)) + if err != nil { + jsonError(w, fmt.Sprintf("verification error: %v", err), http.StatusUnprocessableEntity) + return + } + + jsonOK(w, result) +} + +// ─── helpers ───────────────────────────────────────────────────────────────── + func (h *ArtifactHandler) resolveRunIDs(w http.ResponseWriter, r *http.Request) (repoID, runID int64, ok bool) { rID, ok := resolveRepoID(h.db, w, r) if !ok { diff --git a/internal/api/handlers/sbom.go b/internal/api/handlers/sbom.go new file mode 100644 index 0000000..77f9eab --- /dev/null +++ b/internal/api/handlers/sbom.go @@ -0,0 +1,137 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "xorm.io/xorm" + + "github.com/forgeo/forgebucket/internal/domain/sbom" +) + +type SBOMHandler struct { + db *xorm.Engine + generator *sbom.Generator +} + +func NewSBOMHandler(db *xorm.Engine, gen *sbom.Generator) *SBOMHandler { + return &SBOMHandler{db: db, generator: gen} +} + +// GetForRun returns the SBOM report metadata for a pipeline run. +// GET /api/v1/repos/{owner}/{repo}/runs/{runID}/sbom +func (h *SBOMHandler) GetForRun(w http.ResponseWriter, r *http.Request) { + runID, err := strconv.ParseInt(chi.URLParam(r, "runID"), 10, 64) + if err != nil { + jsonError(w, "invalid run ID", http.StatusBadRequest) + return + } + + report, err := h.generator.GetForRun(runID) + if err != nil { + jsonError(w, "database error", http.StatusInternalServerError) + return + } + if report == nil { + jsonError(w, "SBOM not yet generated for this run", http.StatusNotFound) + return + } + jsonOK(w, report) +} + +// GetDocumentForRun streams the full CycloneDX JSON document for a run. +// GET /api/v1/repos/{owner}/{repo}/runs/{runID}/sbom/document +func (h *SBOMHandler) GetDocumentForRun(w http.ResponseWriter, r *http.Request) { + runID, err := strconv.ParseInt(chi.URLParam(r, "runID"), 10, 64) + if err != nil { + jsonError(w, "invalid run ID", http.StatusBadRequest) + return + } + + report, err := h.generator.GetForRun(runID) + if err != nil { + jsonError(w, "database error", http.StatusInternalServerError) + return + } + if report == nil { + jsonError(w, "SBOM not yet generated for this run", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/vnd.cyclonedx+json") + w.Header().Set("Content-Disposition", `attachment; filename="bom.json"`) + w.WriteHeader(http.StatusOK) + w.Write([]byte(report.BOMDocument)) //nolint:errcheck +} + +// GetLatest returns the most recent SBOM report metadata for a repo. +// GET /api/v1/repos/{owner}/{repo}/sbom +func (h *SBOMHandler) GetLatest(w http.ResponseWriter, r *http.Request) { + repoID, ok := resolveRepoID(h.db, w, r) + if !ok { + return + } + + report, err := h.generator.GetLatest(repoID) + if err != nil { + jsonError(w, "database error", http.StatusInternalServerError) + return + } + if report == nil { + jsonError(w, "no SBOM generated yet — push a commit or trigger a pipeline run", http.StatusNotFound) + return + } + jsonOK(w, report) +} + +// GetLatestDocument streams the latest CycloneDX JSON for a repo. +// GET /api/v1/repos/{owner}/{repo}/sbom/document +func (h *SBOMHandler) GetLatestDocument(w http.ResponseWriter, r *http.Request) { + repoID, ok := resolveRepoID(h.db, w, r) + if !ok { + return + } + + report, err := h.generator.GetLatest(repoID) + if err != nil { + jsonError(w, "database error", http.StatusInternalServerError) + return + } + if report == nil { + jsonError(w, "no SBOM generated yet", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/vnd.cyclonedx+json") + w.Header().Set("Content-Disposition", `attachment; filename="bom.json"`) + w.WriteHeader(http.StatusOK) + w.Write([]byte(report.BOMDocument)) //nolint:errcheck +} + +// Generate triggers on-demand SBOM generation for a repo at a given ref/SHA. +// POST /api/v1/repos/{owner}/{repo}/sbom/generate?ref= +func (h *SBOMHandler) Generate(w http.ResponseWriter, r *http.Request) { + repoID, ok := resolveRepoID(h.db, w, r) + if !ok { + return + } + + sha := r.URL.Query().Get("ref") + if sha == "" { + sha = r.URL.Query().Get("sha") + } + if sha == "" { + jsonError(w, "ref or sha query param required", http.StatusBadRequest) + return + } + + report, err := h.generator.GenerateOnDemand(repoID, sha) + if err != nil { + jsonError(w, "generation failed: "+err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) + jsonOK(w, report) +} diff --git a/internal/api/router.go b/internal/api/router.go index aa74c52..7c001e5 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -19,11 +19,13 @@ import ( "github.com/forgeo/forgebucket/internal/api/handlers" "github.com/forgeo/forgebucket/internal/api/middleware" "github.com/forgeo/forgebucket/internal/config" + "github.com/forgeo/forgebucket/internal/domain/sbom" + "github.com/forgeo/forgebucket/internal/domain/signing" "github.com/forgeo/forgebucket/internal/events" "github.com/forgeo/forgebucket/internal/observability" ) -func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus events.EventBus, artifactRoot string, staticFiles fs.FS) http.Handler { +func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus events.EventBus, artifactRoot string, staticFiles fs.FS, keys signing.KeyStore, sbomGen *sbom.Generator) http.Handler { r := chi.NewRouter() r.Use(chimiddleware.Logger) @@ -62,7 +64,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even auditH := handlers.NewAuditHandler(engine) healthH := handlers.NewHealthHandler(engine, bus) repoHealthH := handlers.NewRepoHealthHandler(engine) - artifactH := handlers.NewArtifactHandler(engine, artifactRoot) + artifactH := handlers.NewArtifactHandler(engine, artifactRoot, &keys) runnerH := handlers.NewRunnerHandler(engine) gitopsH := handlers.NewGitOpsHandler(engine, bus) fedH := handlers.NewFederationHandler(engine, cfg) @@ -70,6 +72,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even timelineH := handlers.NewTimelineHandler(engine, cfg.RepoRoot) workspaceH := handlers.NewWorkspaceHandler(engine, cfg) secretH := handlers.NewSecretHandler(engine, cfg.SessionSecret) + sbomH := handlers.NewSBOMHandler(engine, sbomGen) // ── Git smart-HTTP transport ─────────────────────────────────────────────── // Regex constraint ensures only *.git paths match, so asset/SPA URLs @@ -198,6 +201,10 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even }) }) r.Get("/artifacts/{artifactID}/download", artifactH.Download) + r.Get("/artifacts/{artifactID}/signature", artifactH.GetSignature) + r.Get("/artifacts/{artifactID}/verify", artifactH.VerifySignature) + r.Get("/sbom", sbomH.GetForRun) + r.Get("/sbom/document", sbomH.GetDocumentForRun) r.Route("/members", func(r chi.Router) { r.Get("/", memberH.List) r.With(csrf).Post("/", memberH.Add) @@ -247,6 +254,9 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even r.Get("/lfs-settings", lfsH.Get) r.With(csrf).Put("/lfs-settings", lfsH.Update) r.Get("/health", repoHealthH.Get) + r.Get("/sbom", sbomH.GetLatest) + r.Get("/sbom/document", sbomH.GetLatestDocument) + r.With(csrf).Post("/sbom/generate", sbomH.Generate) r.Route("/environments", func(r chi.Router) { r.Get("/", envH.ListEnvironments) r.With(csrf).Post("/", envH.CreateEnvironment) diff --git a/internal/config/config.go b/internal/config/config.go index 48fe72b..2d20db6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,6 +37,10 @@ type Config struct { InstanceURL string InstanceName string + // Artifact signing (Phase 4) + // PEM-encoded ECDSA P-256 private key. If empty an ephemeral key is generated. + ArtifactSigningKey string + // Dev Debug bool } @@ -61,6 +65,9 @@ func Load() (*Config, error) { cfg.SessionSecret = requireEnv("SESSION_SECRET", &missing) cfg.CSRFSecret = requireEnv("CSRF_SECRET", &missing) + // Optional signing key + cfg.ArtifactSigningKey = os.Getenv("ARTIFACT_SIGNING_KEY") + // Optional OIDC cfg.OIDCIssuer = os.Getenv("OIDC_ISSUER") cfg.OIDCClientID = os.Getenv("OIDC_CLIENT_ID") diff --git a/internal/domain/sbom/cyclonedx.go b/internal/domain/sbom/cyclonedx.go new file mode 100644 index 0000000..0715854 --- /dev/null +++ b/internal/domain/sbom/cyclonedx.go @@ -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) +} diff --git a/internal/domain/sbom/generator.go b/internal/domain/sbom/generator.go new file mode 100644 index 0000000..43f1bd3 --- /dev/null +++ b/internal/domain/sbom/generator.go @@ -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 +} diff --git a/internal/domain/sbom/parsers.go b/internal/domain/sbom/parsers.go new file mode 100644 index 0000000..0383e00 --- /dev/null +++ b/internal/domain/sbom/parsers.go @@ -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 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 := "" + 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, "") { + inDep = true + groupID, artifactID, version = "", "", "" + continue + } + if strings.Contains(line, "") { + 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 +} diff --git a/internal/domain/sbom/parsers_test.go b/internal/domain/sbom/parsers_test.go new file mode 100644 index 0000000..9a342b9 --- /dev/null +++ b/internal/domain/sbom/parsers_test.go @@ -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(` + + + + org.springframework.boot + spring-boot-starter-web + 3.1.0 + + + org.postgresql + postgresql + 42.6.0 + + +`) + + 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") + } +} diff --git a/internal/domain/signing/keystore.go b/internal/domain/signing/keystore.go new file mode 100644 index 0000000..7e12cd2 --- /dev/null +++ b/internal/domain/signing/keystore.go @@ -0,0 +1,189 @@ +// Package signing provides ECDSA P-256 artifact signing and verification. +// +// Every artifact uploaded through the API is automatically signed by the +// server's signing key. The resulting Bundle is self-contained: it carries +// the payload JSON, the base64-encoded ASN.1 signature, and the signer's +// public key PEM, so any verifier can reconstruct the check without needing +// access to the server's private key. +package signing + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "log" + "time" +) + +// KeyStore holds the server signing key pair. +type KeyStore struct { + privateKey *ecdsa.PrivateKey + publicKeyPEM string + keyID string // 16-char hex fingerprint of the DER public key +} + +// New creates a KeyStore from a PEM-encoded ECDSA private key. +func New(privateKeyPEM string) (*KeyStore, error) { + block, _ := pem.Decode([]byte(privateKeyPEM)) + if block == nil { + return nil, errors.New("signing: invalid PEM block") + } + key, err := x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("signing: parse private key: %w", err) + } + return newFromKey(key) +} + +// Generate creates a fresh ephemeral ECDSA P-256 key pair. +// Logs a warning — not suitable for production; use ARTIFACT_SIGNING_KEY env var. +func Generate() (*KeyStore, error) { + log.Println("signing: ARTIFACT_SIGNING_KEY not set — generating ephemeral key (signatures will not survive restart)") + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("signing: generate key: %w", err) + } + return newFromKey(key) +} + +func newFromKey(key *ecdsa.PrivateKey) (*KeyStore, error) { + pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + return nil, fmt.Errorf("signing: marshal public key: %w", err) + } + pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})) + sum := sha256.Sum256(pubDER) + keyID := fmt.Sprintf("%x", sum[:8]) + return &KeyStore{privateKey: key, publicKeyPEM: pubPEM, keyID: keyID}, nil +} + +// PrivateKeyPEM serialises the private key so callers can persist it. +func (ks *KeyStore) PrivateKeyPEM() (string, error) { + der, err := x509.MarshalECPrivateKey(ks.privateKey) + if err != nil { + return "", err + } + return string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})), nil +} + +// PublicKeyPEM returns the signer's PEM public key (embedded in every bundle). +func (ks *KeyStore) PublicKeyPEM() string { return ks.publicKeyPEM } + +// KeyID returns the short fingerprint of the public key. +func (ks *KeyStore) KeyID() string { return ks.keyID } + +// ─── Bundle types ───────────────────────────────────────────────────────────── + +const bundleMediaType = "application/vnd.forgebucket.signature.bundle+json" + +// Bundle is the self-contained signature artifact stored alongside each upload. +type Bundle struct { + MediaType string `json:"mediaType"` + Payload BundlePayload `json:"payload"` + Signature string `json:"signature"` // base64(ASN.1 DER ECDSA signature) + PublicKey string `json:"publicKey"` // PEM-encoded ECDSA public key + KeyID string `json:"keyId"` +} + +// BundlePayload is the data that was signed (JSON-serialised before hashing). +type BundlePayload struct { + ArtifactID int64 `json:"artifactId"` + Name string `json:"name"` + Digest string `json:"digest"` // "sha256:" + SignedAt string `json:"signedAt"` // RFC 3339 +} + +// ─── Sign ───────────────────────────────────────────────────────────────────── + +// Sign computes SHA-256(rawContent), builds a BundlePayload, signs +// SHA-256(JSON(payload)) with the private key, and returns the Bundle. +func (ks *KeyStore) Sign(artifactID int64, name string, rawContent []byte) (*Bundle, error) { + contentDigest := sha256.Sum256(rawContent) + + payload := BundlePayload{ + ArtifactID: artifactID, + Name: name, + Digest: fmt.Sprintf("sha256:%x", contentDigest), + SignedAt: time.Now().UTC().Format(time.RFC3339), + } + payloadJSON, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("signing: marshal payload: %w", err) + } + + payloadHash := sha256.Sum256(payloadJSON) + sigDER, err := ecdsa.SignASN1(rand.Reader, ks.privateKey, payloadHash[:]) + if err != nil { + return nil, fmt.Errorf("signing: ecdsa sign: %w", err) + } + + return &Bundle{ + MediaType: bundleMediaType, + Payload: payload, + Signature: base64.StdEncoding.EncodeToString(sigDER), + PublicKey: ks.publicKeyPEM, + KeyID: ks.keyID, + }, nil +} + +// ─── Verify ─────────────────────────────────────────────────────────────────── + +// VerifyResult is returned by both verification functions. +type VerifyResult struct { + Valid bool `json:"valid"` + Digest string `json:"digest"` + SignedAt string `json:"signedAt"` + KeyID string `json:"keyId"` + KeyMatches bool `json:"keyMatchesServer"` // true if bundle public key == server public key +} + +// Verify parses bundleJSON, verifies the embedded signature against the +// embedded public key, and returns a VerifyResult. +// The caller should also check KeyMatches to confirm it was signed by this server. +func (ks *KeyStore) Verify(bundleJSON []byte) (*VerifyResult, error) { + var b Bundle + if err := json.Unmarshal(bundleJSON, &b); err != nil { + return nil, fmt.Errorf("signing: parse bundle: %w", err) + } + + block, _ := pem.Decode([]byte(b.PublicKey)) + if block == nil { + return nil, errors.New("signing: invalid public key PEM in bundle") + } + pubInterface, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("signing: parse public key: %w", err) + } + pub, ok := pubInterface.(*ecdsa.PublicKey) + if !ok { + return nil, errors.New("signing: public key is not ECDSA") + } + + payloadJSON, err := json.Marshal(b.Payload) + if err != nil { + return nil, fmt.Errorf("signing: marshal payload: %w", err) + } + payloadHash := sha256.Sum256(payloadJSON) + + sigDER, err := base64.StdEncoding.DecodeString(b.Signature) + if err != nil { + return nil, fmt.Errorf("signing: decode signature base64: %w", err) + } + + valid := ecdsa.VerifyASN1(pub, payloadHash[:], sigDER) + + return &VerifyResult{ + Valid: valid, + Digest: b.Payload.Digest, + SignedAt: b.Payload.SignedAt, + KeyID: b.KeyID, + KeyMatches: b.PublicKey == ks.publicKeyPEM, + }, nil +} diff --git a/internal/domain/signing/keystore_test.go b/internal/domain/signing/keystore_test.go new file mode 100644 index 0000000..09ddd27 --- /dev/null +++ b/internal/domain/signing/keystore_test.go @@ -0,0 +1,153 @@ +package signing_test + +import ( + "encoding/json" + "testing" + + "github.com/forgeo/forgebucket/internal/domain/signing" +) + +func TestGenerateAndSign(t *testing.T) { + ks, err := signing.Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + if ks.KeyID() == "" { + t.Fatal("expected non-empty key ID") + } + if ks.PublicKeyPEM() == "" { + t.Fatal("expected non-empty public key PEM") + } +} + +func TestSignAndVerify(t *testing.T) { + ks, err := signing.Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + + content := []byte("hello, forgebucket artifact") + bundle, err := ks.Sign(42, "binary.tar.gz", content) + if err != nil { + t.Fatalf("Sign: %v", err) + } + + if bundle.MediaType != "application/vnd.forgebucket.signature.bundle+json" { + t.Errorf("unexpected media type: %s", bundle.MediaType) + } + if bundle.Payload.ArtifactID != 42 { + t.Errorf("artifact ID mismatch: got %d", bundle.Payload.ArtifactID) + } + if bundle.Payload.Name != "binary.tar.gz" { + t.Errorf("name mismatch: got %s", bundle.Payload.Name) + } + if bundle.Payload.Digest == "" { + t.Error("expected non-empty digest") + } + if bundle.Signature == "" { + t.Error("expected non-empty signature") + } + + bundleJSON, err := json.Marshal(bundle) + if err != nil { + t.Fatalf("marshal bundle: %v", err) + } + + result, err := ks.Verify(bundleJSON) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if !result.Valid { + t.Error("expected valid=true") + } + if !result.KeyMatches { + t.Error("expected keyMatchesServer=true") + } + if result.Digest != bundle.Payload.Digest { + t.Errorf("digest mismatch: %s vs %s", result.Digest, bundle.Payload.Digest) + } +} + +func TestVerifyTamperedSignature(t *testing.T) { + ks, _ := signing.Generate() + content := []byte("artifact content") + bundle, err := ks.Sign(1, "file.bin", content) + if err != nil { + t.Fatalf("Sign: %v", err) + } + + // Tamper: valid base64 but not a valid ECDSA signature over this payload. + // "Z2FyYmFnZQ==" decodes to "garbage" which is not a valid DER ECDSA sig. + bundle.Signature = "Z2FyYmFnZQ==" + + bundleJSON, _ := json.Marshal(bundle) + result, err := ks.Verify(bundleJSON) + if err != nil { + t.Fatalf("Verify should not error on invalid sig: %v", err) + } + if result.Valid { + t.Error("expected valid=false for tampered signature") + } +} + +func TestVerifyWrongKey(t *testing.T) { + ks1, _ := signing.Generate() + ks2, _ := signing.Generate() + + content := []byte("artifact") + bundle, err := ks1.Sign(10, "tool", content) + if err != nil { + t.Fatalf("Sign: %v", err) + } + + bundleJSON, _ := json.Marshal(bundle) + + // Verify with ks2 — key won't match. + result, err := ks2.Verify(bundleJSON) + if err != nil { + t.Fatalf("Verify: %v", err) + } + // Cryptographic signature is still valid (uses embedded pub key), but key doesn't match server. + if !result.Valid { + t.Error("signature itself should still be cryptographically valid") + } + if result.KeyMatches { + t.Error("expected keyMatchesServer=false when signed by a different key") + } +} + +func TestNewFromPEM(t *testing.T) { + ks1, err := signing.Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + + pemStr, err := ks1.PrivateKeyPEM() + if err != nil { + t.Fatalf("PrivateKeyPEM: %v", err) + } + + ks2, err := signing.New(pemStr) + if err != nil { + t.Fatalf("New from PEM: %v", err) + } + + if ks1.KeyID() != ks2.KeyID() { + t.Errorf("key IDs differ: %s vs %s", ks1.KeyID(), ks2.KeyID()) + } + + // Sign with ks1, verify with ks2 (same underlying key). + bundle, _ := ks1.Sign(5, "bin", []byte("data")) + bundleJSON, _ := json.Marshal(bundle) + + result, err := ks2.Verify(bundleJSON) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if !result.Valid { + t.Error("expected valid=true") + } + if !result.KeyMatches { + t.Error("expected keyMatchesServer=true for same key material") + } +} diff --git a/internal/models/migrations/001_init.go b/internal/models/migrations/001_init.go index 1a5f5d6..6a6f384 100644 --- a/internal/models/migrations/001_init.go +++ b/internal/models/migrations/001_init.go @@ -52,5 +52,11 @@ func Run(engine *xorm.Engine) error { if err := Run013(engine); err != nil { return err } - return Run014(engine) + if err := Run014(engine); err != nil { + return err + } + if err := Run015(engine); err != nil { + return err + } + return Run016(engine) } diff --git a/internal/models/migrations/015_signing.go b/internal/models/migrations/015_signing.go new file mode 100644 index 0000000..6cd26ff --- /dev/null +++ b/internal/models/migrations/015_signing.go @@ -0,0 +1,10 @@ +package migrations + +import ( + "github.com/forgeo/forgebucket/internal/models" + "xorm.io/xorm" +) + +func Run015(engine *xorm.Engine) error { + return engine.Sync2(&models.ArtifactSignature{}) +} diff --git a/internal/models/migrations/016_sbom.go b/internal/models/migrations/016_sbom.go new file mode 100644 index 0000000..c298942 --- /dev/null +++ b/internal/models/migrations/016_sbom.go @@ -0,0 +1,10 @@ +package migrations + +import ( + "github.com/forgeo/forgebucket/internal/models" + "xorm.io/xorm" +) + +func Run016(engine *xorm.Engine) error { + return engine.Sync2(&models.SBOMReport{}) +} diff --git a/internal/models/sbom.go b/internal/models/sbom.go new file mode 100644 index 0000000..8644a5a --- /dev/null +++ b/internal/models/sbom.go @@ -0,0 +1,17 @@ +package models + +import "time" + +// SBOMReport stores the generated CycloneDX BOM for a repo at a specific SHA. +// BOMDocument holds the full JSON but is not returned by list endpoints — +// use the dedicated document endpoint to stream it. +type SBOMReport struct { + ID int64 `xorm:"'id' pk autoincr" json:"id"` + RepoID int64 `xorm:"'repo_id' notnull index" json:"repoId"` + RunID int64 `xorm:"'run_id' index" json:"runId"` // 0 = on-demand + SHA string `xorm:"'sha' varchar(40)" json:"sha"` + Format string `xorm:"'format' varchar(30)" json:"format"` // "cyclonedx-json-1.4" + ComponentCount int `xorm:"'component_count'" json:"componentCount"` + BOMDocument string `xorm:"'bom_document' text" json:"-"` // full JSON, not returned in lists + GeneratedAt time.Time `xorm:"'generated_at'" json:"generatedAt"` +} diff --git a/internal/models/signing.go b/internal/models/signing.go new file mode 100644 index 0000000..c5e2e46 --- /dev/null +++ b/internal/models/signing.go @@ -0,0 +1,16 @@ +package models + +import "time" + +// ArtifactSignature stores the Cosign-compatible signature bundle produced +// when an artifact is uploaded. The BundleJSON field is the full self-contained +// bundle so consumers can verify without hitting the API again. +type ArtifactSignature struct { + ID int64 `xorm:"'id' pk autoincr" json:"id"` + ArtifactID int64 `xorm:"'artifact_id' notnull unique" json:"artifactId"` + KeyID string `xorm:"'key_id' varchar(32)" json:"keyId"` + Algorithm string `xorm:"'algorithm' varchar(50)" json:"algorithm"` // "ecdsa-p256-sha256" + Digest string `xorm:"'digest' varchar(80)" json:"digest"` // "sha256:" + BundleJSON string `xorm:"'bundle_json' text" json:"-"` // full bundle, not surfaced directly + SignedAt time.Time `xorm:"'signed_at'" json:"signedAt"` +}