added signed artifacts and SBOM generation capabilities
This commit is contained in:
+21
-1
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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=<sha-or-branch>
|
||||
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)
|
||||
}
|
||||
+12
-2
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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:<hex>"
|
||||
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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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:<hex>"
|
||||
BundleJSON string `xorm:"'bundle_json' text" json:"-"` // full bundle, not surfaced directly
|
||||
SignedAt time.Time `xorm:"'signed_at'" json:"signedAt"`
|
||||
}
|
||||
Reference in New Issue
Block a user