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"
|
"github.com/forgeo/forgebucket/internal/domain/ci"
|
||||||
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
|
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
|
||||||
"github.com/forgeo/forgebucket/internal/domain/gitops"
|
"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/events"
|
||||||
"github.com/forgeo/forgebucket/internal/observability"
|
"github.com/forgeo/forgebucket/internal/observability"
|
||||||
"github.com/forgeo/forgebucket/internal/models/migrations"
|
"github.com/forgeo/forgebucket/internal/models/migrations"
|
||||||
@@ -78,9 +80,27 @@ func main() {
|
|||||||
gitopsCtrl := gitops.NewController(engine, bus, cfg)
|
gitopsCtrl := gitops.NewController(engine, bus, cfg)
|
||||||
go gitopsCtrl.Start(ciCtx)
|
go gitopsCtrl.Start(ciCtx)
|
||||||
|
|
||||||
|
sbomGen := sbom.NewGenerator(engine, bus)
|
||||||
|
go sbomGen.Start(ciCtx)
|
||||||
|
|
||||||
go observability.StartNATSWatcher(ciCtx, bus)
|
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{
|
srv := &http.Server{
|
||||||
Addr: fmt.Sprintf(":%s", cfg.Port),
|
Addr: fmt.Sprintf(":%s", cfg.Port),
|
||||||
|
|||||||
@@ -1,29 +1,33 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"xorm.io/xorm"
|
"xorm.io/xorm"
|
||||||
|
|
||||||
|
"github.com/forgeo/forgebucket/internal/domain/signing"
|
||||||
"github.com/forgeo/forgebucket/internal/models"
|
"github.com/forgeo/forgebucket/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ArtifactHandler struct {
|
type ArtifactHandler struct {
|
||||||
db *xorm.Engine
|
db *xorm.Engine
|
||||||
artifactRoot string
|
artifactRoot string
|
||||||
|
keys *signing.KeyStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewArtifactHandler(db *xorm.Engine, artifactRoot string) *ArtifactHandler {
|
func NewArtifactHandler(db *xorm.Engine, artifactRoot string, keys *signing.KeyStore) *ArtifactHandler {
|
||||||
return &ArtifactHandler{db: db, artifactRoot: artifactRoot}
|
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) {
|
func (h *ArtifactHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
repoID, runID, ok := h.resolveRunIDs(w, r)
|
repoID, runID, ok := h.resolveRunIDs(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -40,8 +44,8 @@ func (h *ArtifactHandler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonOK(w, artifacts)
|
jsonOK(w, artifacts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload accepts a multipart file upload and stores it as an artifact.
|
// Upload accepts a multipart file upload, stores it as an artifact, and
|
||||||
// Callers must provide a valid Bearer access token with write scope (runner auth).
|
// immediately signs it using the server's signing key.
|
||||||
func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||||
repoID, runID, ok := h.resolveRunIDs(w, r)
|
repoID, runID, ok := h.resolveRunIDs(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -87,8 +91,13 @@ func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer dst.Close()
|
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 {
|
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)
|
jsonError(w, "could not write file", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -106,7 +115,7 @@ func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
RepoID: repoID,
|
RepoID: repoID,
|
||||||
Name: name,
|
Name: name,
|
||||||
StoragePath: relPath,
|
StoragePath: relPath,
|
||||||
Size: size,
|
Size: int64(len(content)),
|
||||||
ContentType: ct,
|
ContentType: ct,
|
||||||
}
|
}
|
||||||
if _, err := h.db.Insert(artifact); err != nil {
|
if _, err := h.db.Insert(artifact); err != nil {
|
||||||
@@ -114,10 +123,38 @@ func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sign the artifact and persist the bundle.
|
||||||
|
go h.signArtifact(artifact.ID, name, content)
|
||||||
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
jsonOK(w, artifact)
|
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.
|
// Download streams the artifact file to the client.
|
||||||
func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) {
|
func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) {
|
||||||
artifactID, err := strconv.ParseInt(chi.URLParam(r, "artifactID"), 10, 64)
|
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))
|
fullPath := filepath.Join(h.artifactRoot, filepath.FromSlash(artifact.StoragePath))
|
||||||
// Ensure the resolved path stays within artifactRoot (traversal guard).
|
|
||||||
if !isUnder(h.artifactRoot, fullPath) {
|
if !isUnder(h.artifactRoot, fullPath) {
|
||||||
jsonError(w, "forbidden", http.StatusForbidden)
|
jsonError(w, "forbidden", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
@@ -155,6 +191,63 @@ func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) {
|
|||||||
io.Copy(w, f) //nolint:errcheck
|
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) {
|
func (h *ArtifactHandler) resolveRunIDs(w http.ResponseWriter, r *http.Request) (repoID, runID int64, ok bool) {
|
||||||
rID, ok := resolveRepoID(h.db, w, r)
|
rID, ok := resolveRepoID(h.db, w, r)
|
||||||
if !ok {
|
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/handlers"
|
||||||
"github.com/forgeo/forgebucket/internal/api/middleware"
|
"github.com/forgeo/forgebucket/internal/api/middleware"
|
||||||
"github.com/forgeo/forgebucket/internal/config"
|
"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/events"
|
||||||
"github.com/forgeo/forgebucket/internal/observability"
|
"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 := chi.NewRouter()
|
||||||
|
|
||||||
r.Use(chimiddleware.Logger)
|
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)
|
auditH := handlers.NewAuditHandler(engine)
|
||||||
healthH := handlers.NewHealthHandler(engine, bus)
|
healthH := handlers.NewHealthHandler(engine, bus)
|
||||||
repoHealthH := handlers.NewRepoHealthHandler(engine)
|
repoHealthH := handlers.NewRepoHealthHandler(engine)
|
||||||
artifactH := handlers.NewArtifactHandler(engine, artifactRoot)
|
artifactH := handlers.NewArtifactHandler(engine, artifactRoot, &keys)
|
||||||
runnerH := handlers.NewRunnerHandler(engine)
|
runnerH := handlers.NewRunnerHandler(engine)
|
||||||
gitopsH := handlers.NewGitOpsHandler(engine, bus)
|
gitopsH := handlers.NewGitOpsHandler(engine, bus)
|
||||||
fedH := handlers.NewFederationHandler(engine, cfg)
|
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)
|
timelineH := handlers.NewTimelineHandler(engine, cfg.RepoRoot)
|
||||||
workspaceH := handlers.NewWorkspaceHandler(engine, cfg)
|
workspaceH := handlers.NewWorkspaceHandler(engine, cfg)
|
||||||
secretH := handlers.NewSecretHandler(engine, cfg.SessionSecret)
|
secretH := handlers.NewSecretHandler(engine, cfg.SessionSecret)
|
||||||
|
sbomH := handlers.NewSBOMHandler(engine, sbomGen)
|
||||||
|
|
||||||
// ── Git smart-HTTP transport ───────────────────────────────────────────────
|
// ── Git smart-HTTP transport ───────────────────────────────────────────────
|
||||||
// Regex constraint ensures only *.git paths match, so asset/SPA URLs
|
// 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}/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.Route("/members", func(r chi.Router) {
|
||||||
r.Get("/", memberH.List)
|
r.Get("/", memberH.List)
|
||||||
r.With(csrf).Post("/", memberH.Add)
|
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.Get("/lfs-settings", lfsH.Get)
|
||||||
r.With(csrf).Put("/lfs-settings", lfsH.Update)
|
r.With(csrf).Put("/lfs-settings", lfsH.Update)
|
||||||
r.Get("/health", repoHealthH.Get)
|
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.Route("/environments", func(r chi.Router) {
|
||||||
r.Get("/", envH.ListEnvironments)
|
r.Get("/", envH.ListEnvironments)
|
||||||
r.With(csrf).Post("/", envH.CreateEnvironment)
|
r.With(csrf).Post("/", envH.CreateEnvironment)
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ type Config struct {
|
|||||||
InstanceURL string
|
InstanceURL string
|
||||||
InstanceName string
|
InstanceName string
|
||||||
|
|
||||||
|
// Artifact signing (Phase 4)
|
||||||
|
// PEM-encoded ECDSA P-256 private key. If empty an ephemeral key is generated.
|
||||||
|
ArtifactSigningKey string
|
||||||
|
|
||||||
// Dev
|
// Dev
|
||||||
Debug bool
|
Debug bool
|
||||||
}
|
}
|
||||||
@@ -61,6 +65,9 @@ func Load() (*Config, error) {
|
|||||||
cfg.SessionSecret = requireEnv("SESSION_SECRET", &missing)
|
cfg.SessionSecret = requireEnv("SESSION_SECRET", &missing)
|
||||||
cfg.CSRFSecret = requireEnv("CSRF_SECRET", &missing)
|
cfg.CSRFSecret = requireEnv("CSRF_SECRET", &missing)
|
||||||
|
|
||||||
|
// Optional signing key
|
||||||
|
cfg.ArtifactSigningKey = os.Getenv("ARTIFACT_SIGNING_KEY")
|
||||||
|
|
||||||
// Optional OIDC
|
// Optional OIDC
|
||||||
cfg.OIDCIssuer = os.Getenv("OIDC_ISSUER")
|
cfg.OIDCIssuer = os.Getenv("OIDC_ISSUER")
|
||||||
cfg.OIDCClientID = os.Getenv("OIDC_CLIENT_ID")
|
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 {
|
if err := Run013(engine); err != nil {
|
||||||
return err
|
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