diff --git a/.env.example b/.env.example index 1865402..b174a46 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,16 @@ GITOPS_RECONCILE_INTERVAL=300 # Start NATS with: make docker-up NATS_URL=nats://localhost:4222 +# ─── Artifact Signing (Phase 4) ─────────────────────────────────────────────── +# PEM-encoded ECDSA P-256 private key. If empty, an ephemeral key is generated +# at startup (signatures will not survive restart). Generate with: +# openssl ecparam -genkey -name prime256v1 -noout -out signing-key.pem +# ARTIFACT_SIGNING_KEY= + +# ─── OCI Registry (Phase 4) ─────────────────────────────────────────────────── +# Root directory for the OCI Distribution Spec blob and upload storage. +OCI_ROOT=/var/lib/forgebucket/oci + # ─── Dev only ───────────────────────────────────────────────────────────────── # Set to true to disable Secure cookies and enable verbose logging DEBUG=false diff --git a/cmd/forgebucket/main.go b/cmd/forgebucket/main.go index f9a11b0..8c32f25 100644 --- a/cmd/forgebucket/main.go +++ b/cmd/forgebucket/main.go @@ -20,6 +20,9 @@ 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/oci" + "github.com/forgeo/forgebucket/internal/domain/scanning" + "github.com/forgeo/forgebucket/internal/domain/vulnscan" "github.com/forgeo/forgebucket/internal/domain/sbom" "github.com/forgeo/forgebucket/internal/domain/signing" "github.com/forgeo/forgebucket/internal/events" @@ -52,6 +55,12 @@ func main() { log.Fatalf("artifact root: %v", err) } + ociRegistry, err := oci.New(cfg.OCIRoot) + if err != nil { + log.Fatalf("oci: %v", err) + } + log.Printf("oci: registry initialised at %s", cfg.OCIRoot) + bus, err := events.New(cfg.NATSUrl) if err != nil { log.Fatalf("events: %v", err) @@ -100,7 +109,16 @@ func main() { } log.Printf("signing: key store initialised (keyId=%s)", keyStore.KeyID()) - handler := api.New(cfg, engine, store, bus, cfg.ArtifactRoot, web.FS(), *keyStore, sbomGen) + secretScanner, err := scanning.New(engine, bus) + if err != nil { + log.Fatalf("secret scanner: %v", err) + } + go secretScanner.Start(ciCtx) + + vulnScanner := vulnscan.NewScanner(engine, bus) + go vulnScanner.Start(ciCtx) + + handler := api.New(cfg, engine, store, bus, cfg.ArtifactRoot, web.FS(), *keyStore, sbomGen, ociRegistry, secretScanner, vulnScanner) srv := &http.Server{ Addr: fmt.Sprintf(":%s", cfg.Port), diff --git a/internal/api/handlers/federation.go b/internal/api/handlers/federation.go index 2f5c5c9..036819a 100644 --- a/internal/api/handlers/federation.go +++ b/internal/api/handlers/federation.go @@ -3,9 +3,11 @@ package handlers import ( "encoding/json" "io" + "log" "net/http" "strconv" "strings" + "time" "github.com/go-chi/chi/v5" "xorm.io/xorm" @@ -160,7 +162,81 @@ func (h *FederationHandler) Followers(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(coll) //nolint:errcheck } -// Following handles GET /users/{username}/following +// RepoActor handles GET /repos/{owner}/{repo}/actor — returns the ForgeFed +// Repository actor document for cross-instance pull requests. +func (h *FederationHandler) RepoActor(w http.ResponseWriter, r *http.Request) { + owner := chi.URLParam(r, "owner") + repoName := chi.URLParam(r, "repo") + + var repo models.Repository + if found, _ := h.db.Where("name = ?", repoName). + Join("INNER", "user", "repository.owner_id = user.id AND user.username = ?", owner). + Get(&repo); !found { + http.NotFound(w, r) + return + } + + doc := federation.RepoActorJSON(owner, repoName, repo.Description, h.cfg.InstanceURL) + w.Header().Set("Content-Type", activityJSONType) + json.NewEncoder(w).Encode(doc) //nolint:errcheck +} + +// RepoInbox handles POST /repos/{owner}/{repo}/inbox — receive ForgeFed +// activities for a repository (e.g. Create(PullRequest)). +func (h *FederationHandler) RepoInbox(w http.ResponseWriter, r *http.Request) { + owner := chi.URLParam(r, "owner") + repoName := chi.URLParam(r, "repo") + + var repo models.Repository + if found, _ := h.db.Where("name = ?", repoName). + Join("INNER", "user", "repository.owner_id = user.id AND user.username = ?", owner). + Get(&repo); !found { + http.NotFound(w, r) + return + } + _ = repo + + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "could not read body", http.StatusBadRequest) + return + } + + // Determine the local repo actor APID. + localActorAPID := federation.RepoAPID(h.cfg.InstanceURL, owner, repoName) + + // For repository inbox, we need a local actor for the repo owner. + var ownerUser models.User + if found, _ := h.db.Where("username = ?", owner).Get(&ownerUser); !found { + http.Error(w, "owner not found", http.StatusInternalServerError) + return + } + + if !h.cfg.Debug { + if err := federation.Verify(r, h.db, h.cfg.InstanceURL); err != nil { + http.Error(w, "signature verification failed: "+err.Error(), http.StatusUnauthorized) + return + } + } + + // Persist the activity. + entry := &models.FederationActivity{ + ActorAPID: localActorAPID, + Type: "Create", + ObjectJSON: string(body), + Direction: "inbound", + RemoteActor: localActorAPID, + Published: time.Now().UTC(), + } + h.db.Insert(entry) //nolint:errcheck + + // Handle Create(PullRequest). + if err := federation.HandleCreatePullRequest(h.db, body, h.cfg.InstanceURL); err != nil { + log.Printf("federation: repo inbox handle: %v", err) + } + + w.WriteHeader(http.StatusAccepted) +} func (h *FederationHandler) Following(w http.ResponseWriter, r *http.Request) { username := chi.URLParam(r, "username") var user models.User diff --git a/internal/api/handlers/oci.go b/internal/api/handlers/oci.go new file mode 100644 index 0000000..563e1e7 --- /dev/null +++ b/internal/api/handlers/oci.go @@ -0,0 +1,525 @@ +package handlers + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "xorm.io/xorm" + + "github.com/forgeo/forgebucket/internal/domain/oci" + "github.com/forgeo/forgebucket/internal/models" +) + +// OCIRegistryHandler serves the OCI Distribution API at /v2/. +type OCIRegistryHandler struct { + db *xorm.Engine + reg *oci.Registry +} + +func NewOCIRegistryHandler(db *xorm.Engine, reg *oci.Registry) *OCIRegistryHandler { + return &OCIRegistryHandler{db: db, reg: reg} +} + +// ServeOCI is the catch-all handler mounted at /v2/. +func (h *OCIRegistryHandler) ServeOCI(w http.ResponseWriter, r *http.Request) { + // GET /v2/ — API version check. + if r.Method == http.MethodGet && (r.URL.Path == "/v2/" || r.URL.Path == "/v2") { + w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") + json.NewEncoder(w).Encode(map[string]string{"ok": "true"}) + return + } + + name, kind, ref := oci.ParseOCIPath(r.URL.Path) + if name == "" { + h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "invalid OCI path") + return + } + + // Resolve ForgeBucket repository from image name (expected format: owner/repo). + owner, repoName, found := strings.Cut(name, "/") + if !found { + h.ociError(w, http.StatusBadRequest, oci.ErrNameInvalid, "image name must be owner/repo-name") + return + } + + var repo models.Repository + if ok, _ := h.db.Where("name = ?", repoName).Join("INNER", "user", "repository.owner_id = user.id AND user.username = ?", owner).Get(&repo); !ok { + h.ociError(w, http.StatusNotFound, oci.ErrNameUnknown, "repository not found") + return + } + + // Authenticate. + authedUser := h.basicAuthOCI(r) + needsAuth := repo.IsPrivate || r.Method != http.MethodGet + + if needsAuth && authedUser == "" { + w.Header().Set("Www-Authenticate", `Basic realm="ForgeBucket OCI Registry"`) + h.ociError(w, http.StatusUnauthorized, oci.ErrUnauthorized, "authentication required") + return + } + if authedUser != "" { + hasWrite := HasPermission(h.db, &repo, authedUser, "write") + hasRead := HasPermission(h.db, &repo, authedUser, "read") + if !hasRead { + h.ociError(w, http.StatusForbidden, oci.ErrDenied, "access denied") + return + } + // Mutations require write access. + isMut := r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch || r.Method == http.MethodDelete + if isMut && !hasWrite { + h.ociError(w, http.StatusForbidden, oci.ErrDenied, "write access required") + return + } + } + + // Resolve or create OCIRepository row. + ociRepo, err := h.getOrCreateOCIRepo(repo.ID, name) + if err != nil { + h.ociError(w, http.StatusInternalServerError, oci.ErrBlobUnknown, "internal error") + return + } + + // Route to handler by (method, kind). + switch r.Method { + case http.MethodGet: + switch kind { + case "tags": + h.listTags(w, r, ociRepo) + case "manifest": + h.getManifest(w, r, ociRepo, ref) + case "blob": + h.getBlob(w, r, repo, ociRepo, ref) + case "upload": + h.getUploadStatus(w, r, ociRepo, ref) + default: + h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "not found") + } + + case http.MethodHead: + if kind == "blob" { + h.headBlob(w, r, ref) + } else { + h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "not found") + } + + case http.MethodPost: + if kind == "upload" && ref == "" { + h.startUpload(w, r, ociRepo) + } else { + h.ociError(w, http.StatusMethodNotAllowed, oci.ErrUnsupported, "method not allowed") + } + + case http.MethodPatch: + if kind == "upload" && ref != "" { + h.patchUpload(w, r, ociRepo, ref) + } else { + h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "not found") + } + + case http.MethodPut: + if kind == "upload" && ref != "" { + h.finishUpload(w, r, ociRepo, ref) + } else if kind == "manifest" { + h.pushManifest(w, r, ociRepo, ref) + } else { + h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "not found") + } + + case http.MethodDelete: + if kind == "manifest" { + h.deleteManifest(w, r, ociRepo, ref) + } else if kind == "blob" { + h.deleteBlob(w, r, ociRepo, ref) + } else if kind == "upload" && ref != "" { + h.cancelUpload(w, r, ref) + } else { + h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "not found") + } + + default: + h.ociError(w, http.StatusMethodNotAllowed, oci.ErrUnsupported, "method not allowed") + } +} + +// ─── GET /v2/{name}/tags/list ──────────────────────────────────────────────── + +func (h *OCIRegistryHandler) listTags(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository) { + var tags []models.OCITag + h.db.Where("oci_repo_id = ?", ociRepo.ID).Find(&tags) + + names := make([]string, 0, len(tags)) + for _, t := range tags { + names = append(names, t.Name) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "name": ociRepo.Name, + "tags": names, + }) +} + +// ─── GET /v2/{name}/manifests/{ref} ────────────────────────────────────────── + +func (h *OCIRegistryHandler) getManifest(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) { + digest := ref + if !oci.IsDigestRef(ref) { + // ref is a tag — resolve to digest. + var tag models.OCITag + if found, _ := h.db.Where("oci_repo_id = ? AND name = ?", ociRepo.ID, ref).Get(&tag); !found { + h.ociError(w, http.StatusNotFound, oci.ErrManifestUnknown, fmt.Sprintf("tag %q not found", ref)) + return + } + digest = tag.Digest + } + + var manifest models.OCIManifest + if found, _ := h.db.Where("oci_repo_id = ? AND digest = ?", ociRepo.ID, digest).Get(&manifest); !found { + h.ociError(w, http.StatusNotFound, oci.ErrManifestUnknown, "manifest not found") + return + } + + w.Header().Set("Content-Type", manifest.MediaType) + w.Header().Set("Docker-Content-Digest", manifest.Digest) + w.Header().Set("Content-Length", fmt.Sprintf("%d", manifest.Size)) + w.WriteHeader(http.StatusOK) + w.Write([]byte(manifest.Content)) //nolint:errcheck +} + +// ─── PUT /v2/{name}/manifests/{ref} ────────────────────────────────────────── + +func (h *OCIRegistryHandler) pushManifest(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) { + body, err := io.ReadAll(r.Body) + if err != nil { + h.ociError(w, http.StatusBadRequest, oci.ErrManifestInvalid, "cannot read body") + return + } + if len(body) == 0 { + h.ociError(w, http.StatusBadRequest, oci.ErrManifestInvalid, "empty manifest body") + return + } + + mediaType := r.Header.Get("Content-Type") + if mediaType == "" { + mediaType = "application/vnd.docker.distribution.manifest.v2+json" + } + + manifestDigest, manifestSize := oci.ManifestDescriptor(body) + + // Persist manifest. + m := &models.OCIManifest{ + OCIRepoID: ociRepo.ID, + Digest: manifestDigest, + MediaType: mediaType, + Size: manifestSize, + Content: string(body), + } + if _, err := h.db.Insert(m); err != nil { + // Duplicate digest is fine — manifests are immutable. + if has, _ := h.db.Where("oci_repo_id = ? AND digest = ?", ociRepo.ID, manifestDigest).Get(&models.OCIManifest{}); !has { + h.ociError(w, http.StatusInternalServerError, oci.ErrManifestInvalid, "store manifest failed") + return + } + } + + // If ref is not a digest, treat it as a tag. + if !oci.IsDigestRef(ref) { + tag := &models.OCITag{ + OCIRepoID: ociRepo.ID, + Name: ref, + Digest: manifestDigest, + } + existing := &models.OCITag{} + if has, _ := h.db.Where("oci_repo_id = ? AND name = ?", ociRepo.ID, ref).Get(existing); has { + existing.Digest = manifestDigest + existing.UpdatedAt = time.Now() + h.db.ID(existing.ID).Cols("digest", "updated_at").Update(existing) + } else { + h.db.Insert(tag) + } + } + + // Track blobs referenced by this manifest so GC can work. + h.trackBlobRefs(ociRepo, body) + + w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", ociRepo.Name, manifestDigest)) + w.Header().Set("Content-Type", mediaType) + w.Header().Set("Docker-Content-Digest", manifestDigest) + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"digest": manifestDigest}) +} + +// trackBlobRefs parses the manifest and ensures referenced blob digests exist as OCIBlob rows. +func (h *OCIRegistryHandler) trackBlobRefs(ociRepo *models.OCIRepository, body []byte) { + var manifest struct { + Layers []struct { + Digest string `json:"digest"` + } `json:"layers"` + Config struct { + Digest string `json:"digest"` + } `json:"config"` + } + if err := json.Unmarshal(body, &manifest); err != nil { + return + } + + digests := []string{} + if manifest.Config.Digest != "" { + digests = append(digests, manifest.Config.Digest) + } + for _, layer := range manifest.Layers { + if layer.Digest != "" { + digests = append(digests, layer.Digest) + } + } + + for _, d := range digests { + if h.reg.BlobExists(d) { + h.db.Insert(&models.OCIBlob{Digest: d, Size: h.reg.BlobSize(d)}) //nolint:errcheck,nestif + } + } +} + +// ─── DELETE /v2/{name}/manifests/{ref} ─────────────────────────────────────── + +func (h *OCIRegistryHandler) deleteManifest(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) { + digest := ref + if !oci.IsDigestRef(ref) { + var tag models.OCITag + if found, _ := h.db.Where("oci_repo_id = ? AND name = ?", ociRepo.ID, ref).Get(&tag); !found { + h.ociError(w, http.StatusNotFound, oci.ErrManifestUnknown, "tag not found") + return + } + digest = tag.Digest + // Delete the tag. + h.db.ID(tag.ID).Delete(&models.OCITag{}) + } + + affected, _ := h.db.Where("oci_repo_id = ? AND digest = ?", ociRepo.ID, digest).Delete(&models.OCIManifest{}) + if affected == 0 { + h.ociError(w, http.StatusNotFound, oci.ErrManifestUnknown, "manifest not found") + return + } + w.WriteHeader(http.StatusAccepted) +} + +// ─── HEAD /v2/{name}/blobs/{digest} ────────────────────────────────────────── + +func (h *OCIRegistryHandler) headBlob(w http.ResponseWriter, r *http.Request, digest string) { + if !h.reg.BlobExists(digest) { + h.ociError(w, http.StatusNotFound, oci.ErrBlobUnknown, "blob not found") + return + } + size := h.reg.BlobSize(digest) + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) + w.Header().Set("Docker-Content-Digest", digest) + w.WriteHeader(http.StatusOK) +} + +// ─── GET /v2/{name}/blobs/{digest} ─────────────────────────────────────────── + +func (h *OCIRegistryHandler) getBlob(w http.ResponseWriter, r *http.Request, repo models.Repository, ociRepo *models.OCIRepository, digest string) { + if !h.reg.BlobExists(digest) { + h.ociError(w, http.StatusNotFound, oci.ErrBlobUnknown, "blob not found") + return + } + size := h.reg.BlobSize(digest) + + f, err := h.reg.ReadBlob(digest) + if err != nil { + h.ociError(w, http.StatusInternalServerError, oci.ErrBlobUnknown, "cannot read blob") + return + } + defer f.Close() + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) + w.Header().Set("Docker-Content-Digest", digest) + http.ServeContent(w, r, "", time.Time{}, f) +} + +// ─── DELETE /v2/{name}/blobs/{digest} ──────────────────────────────────────── + +func (h *OCIRegistryHandler) deleteBlob(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, digest string) { + if !h.reg.BlobExists(digest) { + h.ociError(w, http.StatusNotFound, oci.ErrBlobUnknown, "blob not found") + return + } + _ = h.reg.DeleteBlob(digest) + h.db.Where("digest = ?", digest).Delete(&models.OCIBlob{}) + + w.WriteHeader(http.StatusAccepted) +} + +// ─── POST /v2/{name}/blobs/uploads/ ────────────────────────────────────────── + +func (h *OCIRegistryHandler) startUpload(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository) { + uploadID := newOCIUploadID() + + // Check for single-shot upload (body with ?digest param). + clientDigest := r.URL.Query().Get("digest") + contentLength := r.ContentLength + + if clientDigest != "" && contentLength > 0 { + // Single-shot POST upload. + digest, size, err := h.reg.WriteBlob(r.Body) + if err != nil { + h.ociError(w, http.StatusInternalServerError, oci.ErrBlobUploadInvalid, "upload failed") + return + } + + h.upsertOCIName(ociRepo, digest, size) + + w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", ociRepo.Name, digest)) + w.Header().Set("Content-Range", fmt.Sprintf("0-%d", size-1)) + w.Header().Set("Docker-Content-Digest", digest) + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"digest": digest}) + return + } + + // Create upload session. + h.db.Insert(&models.OCIUpload{ //nolint:errcheck + UploadID: uploadID, + Name: ociRepo.Name, + ExpiresAt: time.Now().UTC().Add(30 * time.Minute), + }) + + w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", ociRepo.Name, uploadID)) + w.Header().Set("Range", "0-0") + w.WriteHeader(http.StatusAccepted) +} + +// ─── PATCH /v2/{name}/blobs/uploads/{uuid} ─────────────────────────────────── + +func (h *OCIRegistryHandler) patchUpload(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) { + // Validate the upload session exists on disk. + uploadPath := h.reg.UploadPath(ref) + _, statErr := os.Stat(uploadPath) + if h.reg.UploadOffset(ref) == 0 && os.IsNotExist(statErr) { + h.ociError(w, http.StatusNotFound, oci.ErrBlobUploadUnknown, "upload not found") + return + } + + newOffset, err := h.reg.AppendUpload(ref, r.Body) + if err != nil { + h.ociError(w, http.StatusInternalServerError, oci.ErrBlobUploadInvalid, "append failed") + return + } + + // Persist upload offset. + h.db.Where("upload_id = ?", ref).Cols("offset").Update(&models.OCIUpload{Offset: newOffset}) + + w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", ociRepo.Name, ref)) + w.Header().Set("Range", fmt.Sprintf("0-%d", newOffset-1)) + w.Header().Set("Content-Length", "0") + w.WriteHeader(http.StatusAccepted) +} + +// ─── PUT /v2/{name}/blobs/uploads/{uuid}?digest=sha256:... ─────────────────── + +func (h *OCIRegistryHandler) finishUpload(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) { + clientDigest := r.URL.Query().Get("digest") + if clientDigest == "" { + h.ociError(w, http.StatusBadRequest, oci.ErrDigestInvalid, "digest query parameter required") + return + } + + // If there's a body, append it before finalising. + if r.ContentLength > 0 || r.Body != http.NoBody { + h.reg.AppendUpload(ref, r.Body) //nolint:errcheck + } + + digest, size, err := h.reg.FinishUpload(ref, clientDigest) + if err != nil { + if _, ok := err.(*oci.DigestMismatch); ok { + h.ociError(w, http.StatusBadRequest, oci.ErrDigestInvalid, err.Error()) + } else { + h.ociError(w, http.StatusNotFound, oci.ErrBlobUploadUnknown, "upload not found") + } + return + } + + h.upsertOCIName(ociRepo, digest, size) + + // Remove upload session. + h.db.Where("upload_id = ?", ref).Delete(&models.OCIUpload{}) + + w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", ociRepo.Name, digest)) + w.Header().Set("Content-Range", fmt.Sprintf("0-%d", size-1)) + w.Header().Set("Docker-Content-Digest", digest) + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"digest": digest}) +} + +// ─── GET /v2/{name}/blobs/uploads/{uuid} ───────────────────────────────────── + +func (h *OCIRegistryHandler) getUploadStatus(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) { + offset := h.reg.UploadOffset(ref) + w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", ociRepo.Name, ref)) + w.Header().Set("Range", fmt.Sprintf("0-%d", offset)) + w.WriteHeader(http.StatusNoContent) +} + +// ─── DELETE /v2/{name}/blobs/uploads/{uuid} ────────────────────────────────── + +func (h *OCIRegistryHandler) cancelUpload(w http.ResponseWriter, r *http.Request, ref string) { + h.reg.CancelUpload(ref) + h.db.Where("upload_id = ?", ref).Delete(&models.OCIUpload{}) + w.WriteHeader(http.StatusNoContent) +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +func (h *OCIRegistryHandler) getOrCreateOCIRepo(repoID int64, name string) (*models.OCIRepository, error) { + r := &models.OCIRepository{} + if found, _ := h.db.Where("name = ?", name).Get(r); found { + return r, nil + } + r.RepoID = repoID + r.Name = name + if _, err := h.db.Insert(r); err != nil { + return nil, err + } + return r, nil +} + +func (h *OCIRegistryHandler) upsertOCIName(ociRepo *models.OCIRepository, digest string, size int64) { + // Track blob in DB if not already tracked. + h.db.Insert(&models.OCIBlob{Digest: digest, Size: size}) //nolint:errcheck +} + +func (h *OCIRegistryHandler) ociError(w http.ResponseWriter, status int, code oci.ErrorCode, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write(oci.NewError(code, msg)) //nolint:errcheck +} + +// newOCIUploadID generates a random hex string used as the upload session ID. +func newOCIUploadID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic("oci: crypto/rand failed: " + err.Error()) + } + return hex.EncodeToString(b) +} + +func (h *OCIRegistryHandler) basicAuthOCI(r *http.Request) string { + u, _, hasAuth := r.BasicAuth() + if !hasAuth { + return "" + } + var user models.User + if found, _ := h.db.Where("username = ?", u).Get(&user); !found { + return "" + } + return u +} diff --git a/internal/api/handlers/scanning.go b/internal/api/handlers/scanning.go new file mode 100644 index 0000000..a585942 --- /dev/null +++ b/internal/api/handlers/scanning.go @@ -0,0 +1,77 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "xorm.io/xorm" + + "github.com/forgeo/forgebucket/internal/domain/scanning" + "github.com/forgeo/forgebucket/internal/models" +) + +type ScanningHandler struct { + db *xorm.Engine + scanner *scanning.Scanner +} + +func NewScanningHandler(db *xorm.Engine, scanner *scanning.Scanner) *ScanningHandler { + return &ScanningHandler{db: db, scanner: scanner} +} + +// ListSecrets returns all active (non-dismissed) secret leaks for a repo. +// GET /api/v1/repos/{owner}/{repo}/secrets/leaks +func (h *ScanningHandler) ListSecrets(w http.ResponseWriter, r *http.Request) { + repoID, ok := resolveRepoID(h.db, w, r) + if !ok { + return + } + + leaks, err := h.scanner.ListFindings(repoID) + if err != nil { + jsonError(w, "database error", http.StatusInternalServerError) + return + } + jsonOK(w, leaks) +} + +// DismissSecrets acknowledges a leak so it no longer appears in active lists. +// POST /api/v1/repos/{owner}/{repo}/secrets/leaks/{leakID}/dismiss +func (h *ScanningHandler) DismissSecrets(w http.ResponseWriter, r *http.Request) { + repoID, ok := resolveRepoID(h.db, w, r) + if !ok { + return + } + _ = repoID + + leakID, err := strconv.ParseInt(chi.URLParam(r, "leakID"), 10, 64) + if err != nil { + jsonError(w, "invalid leak ID", http.StatusBadRequest) + return + } + + // Get current user from session for audit trail. + username := r.Context().Value("user").(string) + + if err := h.scanner.DismissFindings(leakID, username); err != nil { + jsonError(w, err.Error(), http.StatusNotFound) + return + } + jsonOK(w, map[string]string{"status": "dismissed"}) +} + +// ListAllSecrets returns active leaks across all repos (admin/workspace). +// GET /api/v1/secrets/leaks +func (h *ScanningHandler) ListAllSecrets(w http.ResponseWriter, r *http.Request) { + var leaks []models.SecretLeak + if err := h.db.Where("dismissed = ?", false). + OrderBy("detected_at DESC").Find(&leaks); err != nil { + jsonError(w, "database error", http.StatusInternalServerError) + return + } + if leaks == nil { + leaks = []models.SecretLeak{} + } + jsonOK(w, leaks) +} diff --git a/internal/api/handlers/vulnscan.go b/internal/api/handlers/vulnscan.go new file mode 100644 index 0000000..a1e0e7f --- /dev/null +++ b/internal/api/handlers/vulnscan.go @@ -0,0 +1,93 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "xorm.io/xorm" + + "github.com/forgeo/forgebucket/internal/domain/vulnscan" + "github.com/forgeo/forgebucket/internal/models" +) + +type VulnScanHandler struct { + db *xorm.Engine + scanner *vulnscan.Scanner +} + +func NewVulnScanHandler(db *xorm.Engine, scanner *vulnscan.Scanner) *VulnScanHandler { + return &VulnScanHandler{db: db, scanner: scanner} +} + +// List returns all active vulnerability findings for a repo. +// GET /api/v1/repos/{owner}/{repo}/vulnerabilities +func (h *VulnScanHandler) List(w http.ResponseWriter, r *http.Request) { + repoID, ok := resolveRepoID(h.db, w, r) + if !ok { + return + } + + findings, err := h.scanner.ListFindings(repoID) + if err != nil { + jsonError(w, "database error", http.StatusInternalServerError) + return + } + jsonOK(w, findings) +} + +// Scan triggers a full vulnerability scan against the latest SBOM. +// POST /api/v1/repos/{owner}/{repo}/vulnerabilities/scan +func (h *VulnScanHandler) Scan(w http.ResponseWriter, r *http.Request) { + repoID, ok := resolveRepoID(h.db, w, r) + if !ok { + return + } + + findings, err := h.scanner.ScanSBOM(repoID) + if err != nil { + jsonError(w, "scan failed: "+err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + jsonOK(w, findings) +} + +// Dismiss acknowledges a vulnerability finding. +// POST /api/v1/repos/{owner}/{repo}/vulnerabilities/{findingID}/dismiss +func (h *VulnScanHandler) Dismiss(w http.ResponseWriter, r *http.Request) { + repoID, ok := resolveRepoID(h.db, w, r) + if !ok { + return + } + _ = repoID + + findingID, err := strconv.ParseInt(chi.URLParam(r, "findingID"), 10, 64) + if err != nil { + jsonError(w, "invalid finding ID", http.StatusBadRequest) + return + } + + username := r.Context().Value("user").(string) + + if err := h.scanner.DismissFindings(findingID, username); err != nil { + jsonError(w, err.Error(), http.StatusNotFound) + return + } + jsonOK(w, map[string]string{"status": "dismissed"}) +} + +// ListAll returns active findings across all repos. +// GET /api/v1/vulnerabilities +func (h *VulnScanHandler) ListAll(w http.ResponseWriter, r *http.Request) { + var findings []models.VulnerabilityFinding + if err := h.db.Where("dismissed = ?", false). + OrderBy("cvss_score DESC, detected_at DESC").Find(&findings); err != nil { + jsonError(w, "database error", http.StatusInternalServerError) + return + } + if findings == nil { + findings = []models.VulnerabilityFinding{} + } + jsonOK(w, findings) +} diff --git a/internal/api/router.go b/internal/api/router.go index 7c001e5..bdc255d 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -20,12 +20,15 @@ import ( "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/oci" + "github.com/forgeo/forgebucket/internal/domain/scanning" "github.com/forgeo/forgebucket/internal/domain/signing" + "github.com/forgeo/forgebucket/internal/domain/vulnscan" "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, keys signing.KeyStore, sbomGen *sbom.Generator) 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, ociRegistry *oci.Registry, scanner *scanning.Scanner, vulnScanner *vulnscan.Scanner) http.Handler { r := chi.NewRouter() r.Use(chimiddleware.Logger) @@ -73,6 +76,9 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even workspaceH := handlers.NewWorkspaceHandler(engine, cfg) secretH := handlers.NewSecretHandler(engine, cfg.SessionSecret) sbomH := handlers.NewSBOMHandler(engine, sbomGen) + ociH := handlers.NewOCIRegistryHandler(engine, ociRegistry) + scanH := handlers.NewScanningHandler(engine, scanner) + vulnH := handlers.NewVulnScanHandler(engine, vulnScanner) // ── Git smart-HTTP transport ─────────────────────────────────────────────── // Regex constraint ensures only *.git paths match, so asset/SPA URLs @@ -118,6 +124,8 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even r.Get("/me", userH.Me) r.Get("/dashboard", dashH.Get) r.Get("/audit", auditH.List) + r.Get("/secrets/leaks", scanH.ListAllSecrets) + r.Get("/vulnerabilities", vulnH.ListAll) r.Get("/pipelines/runs", pipeH.ListRecentRuns) // Workspace routes @@ -251,6 +259,11 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even r.Get("/secrets", secretH.ListRepoSecrets) r.With(csrf).Post("/secrets", secretH.UpsertRepoSecret) r.With(csrf).Delete("/secrets/{name}", secretH.DeleteRepoSecret) + r.Get("/secrets/leaks", scanH.ListSecrets) + r.With(csrf).Post("/secrets/leaks/{leakID}/dismiss", scanH.DismissSecrets) + r.Get("/vulnerabilities", vulnH.List) + r.With(csrf).Post("/vulnerabilities/scan", vulnH.Scan) + r.With(csrf).Post("/vulnerabilities/{findingID}/dismiss", vulnH.Dismiss) r.Get("/lfs-settings", lfsH.Get) r.With(csrf).Put("/lfs-settings", lfsH.Update) r.Get("/health", repoHealthH.Get) @@ -290,6 +303,16 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even r.With(auth.Optional).Get("/ws", wsH.Hub) + // ── OCI Registry (Distribution Spec v1.1) ───────────────────────────────── + r.HandleFunc("/v2", ociH.ServeOCI) + r.HandleFunc("/v2/*", ociH.ServeOCI) + + // ── ForgeFed Repository Actors (cross-instance PRs) ─────────────────────── + // These must sit outside the auth-protected group since remote instances + // deliver activities without session cookies. + r.Get("/repos/{owner}/{repo}/actor", fedH.RepoActor) + r.Post("/repos/{owner}/{repo}/inbox", fedH.RepoInbox) + // ── ActivityPub / federation (root-level, no auth) ──────────────────────── // Must be registered before the /* catch-all so they are not proxied to Vite. r.Get("/.well-known/webfinger", fedH.WebFinger) diff --git a/internal/config/config.go b/internal/config/config.go index 2d20db6..f1700d0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -41,6 +41,9 @@ type Config struct { // PEM-encoded ECDSA P-256 private key. If empty an ephemeral key is generated. ArtifactSigningKey string + // OCI Registry + OCIRoot string + // Dev Debug bool } @@ -67,6 +70,7 @@ func Load() (*Config, error) { // Optional signing key cfg.ArtifactSigningKey = os.Getenv("ARTIFACT_SIGNING_KEY") + cfg.OCIRoot = getEnv("OCI_ROOT", filepath.Join(filepath.Dir(cfg.RepoRoot), "oci")) // Optional OIDC cfg.OIDCIssuer = os.Getenv("OIDC_ISSUER") diff --git a/internal/domain/federation/forgefed.go b/internal/domain/federation/forgefed.go new file mode 100644 index 0000000..300dcae --- /dev/null +++ b/internal/domain/federation/forgefed.go @@ -0,0 +1,224 @@ +package federation + +import ( + "encoding/json" + "fmt" + "log" + "strings" + "time" + + "xorm.io/xorm" + + "github.com/forgeo/forgebucket/internal/models" +) + +// RepoAPID returns the ActivityPub actor ID for a repository. +// Format: {instanceURL}/repos/{owner}/{name} +func RepoAPID(instanceURL, owner, name string) string { + return strings.TrimRight(instanceURL, "/") + "/repos/" + owner + "/" + name +} + +// RepoActorJSON builds the JSON-LD actor document for a ForgeFed Repository actor. +func RepoActorJSON(owner, name, description, instanceURL string) map[string]any { + apid := RepoAPID(instanceURL, owner, name) + return map[string]any{ + "@context": []any{ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + map[string]string{ + "Repository": "https://www.w3.org/ns/activitystreams#Repository", + }, + }, + "id": apid, + "type": "Repository", + "preferredUsername": name, + "name": owner + "/" + name, + "summary": description, + "inbox": apid + "/inbox", + "outbox": apid + "/outbox", + "followers": apid + "/followers", + "following": apid + "/following", + } +} + +// HandleCreatePullRequest processes an incoming Create activity whose object +// is a PullRequest (per the ForgeFed vocabulary). It creates a local PR record +// in the target repository for the cross-instance proposal. +func HandleCreatePullRequest(db *xorm.Engine, body []byte, instanceURL string) error { + var activity struct { + Actor string `json:"actor"` + Object struct { + Type string `json:"type"` + Summary string `json:"summary"` + Content string `json:"content"` + Source *struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"source"` + Target *struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"target"` + } `json:"object"` + } + if err := json.Unmarshal(body, &activity); err != nil { + return fmt.Errorf("parse activity: %w", err) + } + + if activity.Object.Type != "PullRequest" { + return nil + } + + // Extract target repository info from the object's target. + targetID := activity.Object.Target.ID + targetParts := strings.Split(strings.TrimRight(targetID, "/"), "/") + if len(targetParts) < 2 { + return fmt.Errorf("cannot parse target repo APID: %s", targetID) + } + // Last two segments should be owner/repo-name. + repoOwner := targetParts[len(targetParts)-2] + repoName := targetParts[len(targetParts)-1] + + // Resolve the target repository. + var repo models.Repository + found, err := db.Where("name = ?", repoName). + Join("INNER", "user", "repository.owner_id = user.id AND user.username = ?", repoOwner). + Get(&repo) + if err != nil { + return fmt.Errorf("database error: %w", err) + } + if !found { + return fmt.Errorf("target repo %s/%s not found on this instance", repoOwner, repoName) + } + + // Resolve or create a FederationActor for the repo owner (needed for key ops). + var ownerUser models.User + if found, _ := db.Where("username = ?", repoOwner).Get(&ownerUser); !found { + return fmt.Errorf("owner user %s not found", repoOwner) + } + localActor, err := GetOrCreate(db, ownerUser.ID, repoOwner, instanceURL) + if err != nil { + return fmt.Errorf("get actor: %w", err) + } + + // Determine the PR title and body. + title := activity.Object.Summary + if title == "" { + title = fmt.Sprintf("Cross-instance PR from %s", activity.Actor) + } + + bodyContent := activity.Object.Content + if bodyContent == "" { + bodyContent = fmt.Sprintf("Pull request proposed via ActivityPub from %s", activity.Actor) + } + + // Create the PR. For cross-instance PRs, authorID is set to the target + // repo owner (we can't create a user for the remote actor automatically). + // The RemoteSource field records the source repository APID. + pr := &models.PullRequest{ + RepoID: repo.ID, + AuthorID: ownerUser.ID, + Title: title, + Body: bodyContent, + SourceBranch: "refs/for/main", + TargetBranch: "main", + Status: models.PRStatusOpen, + RemoteSource: activity.Actor, + } + + // Try to extract source branch from the source repo. + if activity.Object.Source != nil { + sourceID := activity.Object.Source.ID + if sourceID != "" { + pr.RemoteSource = sourceID + } + if activity.Object.Source.Name != "" { + pr.SourceBranch = activity.Object.Source.Name + } + } + + if _, err := db.Insert(pr); err != nil { + return fmt.Errorf("insert PR: %w", err) + } + + // Persist the outbound Accept for the PR activity so the remote knows + // we received it (we auto-accept all incoming PRs). + accept := map[string]any{ + "@context": "https://www.w3.org/ns/activitystreams", + "id": localActor.APID + "/activities/accept-pr-" + fmt.Sprint(time.Now().UnixNano()), + "type": "Accept", + "actor": localActor.APID, + } + acceptJSON, _ := json.Marshal(accept) + db.Insert(&models.FederationActivity{ //nolint:errcheck + ActorAPID: localActor.APID, + Type: "Accept", + ObjectJSON: string(acceptJSON), + Direction: "outbound", + RemoteActor: activity.Actor, + Published: time.Now().UTC(), + }) + + log.Printf("forgefed: created PR %d from cross-instance actor %s", pr.ID, activity.Actor) + return nil +} + +// SendCreatePullRequest delivers a Create(PullRequest) activity to a remote +// instance's inbox. The remote inbox URL is derived from the forked-from repo's +// APID by appending /inbox. +func SendCreatePullRequest(db *xorm.Engine, localActor *models.FederationActor, pr *models.PullRequest, remoteAPID, instanceURL string) error { + // Build the Create(PullRequest) activity. + activity := map[string]any{ + "@context": "https://www.w3.org/ns/activitystreams", + "id": localActor.APID + "/activities/create-pr-" + fmt.Sprint(time.Now().UnixNano()), + "type": "Create", + "actor": localActor.APID, + "object": map[string]any{ + "type": "PullRequest", + "id": localActor.APID + "/pull-requests/" + fmt.Sprint(pr.ID), + "summary": pr.Title, + "content": pr.Body, + "source": map[string]any{ + "type": "Repository", + "id": localActor.APID, + }, + "target": map[string]any{ + "type": "Repository", + "id": remoteAPID, + }, + }, + "to": []string{remoteAPID + "/inbox", "https://www.w3.org/ns/activitystreams#Public"}, + } + + remoteInbox := strings.TrimSuffix(remoteAPID, "/") + "/inbox" + if err := DeliverActivity(localActor, activity, remoteInbox); err != nil { + return fmt.Errorf("deliver PR to %s: %w", remoteInbox, err) + } + + actJSON, _ := json.Marshal(activity) + db.Insert(&models.FederationActivity{ //nolint:errcheck + ActorAPID: localActor.APID, + Type: "Create", + ObjectJSON: string(actJSON), + Direction: "outbound", + RemoteActor: remoteAPID, + Published: time.Now().UTC(), + }) + + log.Printf("forgefed: sent Create(PullRequest) for PR %d to %s", pr.ID, remoteInbox) + return nil +} + +// IsCreatePullRequest checks whether the given body is a Create(PullRequest) activity. +func IsCreatePullRequest(body []byte) bool { + var check struct { + Type string `json:"type"` + Object struct { + Type string `json:"type"` + } `json:"object"` + } + if err := json.Unmarshal(body, &check); err != nil { + return false + } + return check.Type == "Create" && check.Object.Type == "PullRequest" +} diff --git a/internal/domain/federation/forgefed_test.go b/internal/domain/federation/forgefed_test.go new file mode 100644 index 0000000..b924f18 --- /dev/null +++ b/internal/domain/federation/forgefed_test.go @@ -0,0 +1,100 @@ +package federation + +import ( + "testing" +) + +func TestRepoAPID(t *testing.T) { + apid := RepoAPID("https://example.com", "alice", "myrepo") + expected := "https://example.com/repos/alice/myrepo" + if apid != expected { + t.Errorf("got %q, want %q", apid, expected) + } +} + +func TestRepoAPID_TrailingSlash(t *testing.T) { + apid := RepoAPID("https://example.com/", "bob", "app") + expected := "https://example.com/repos/bob/app" + if apid != expected { + t.Errorf("got %q, want %q", apid, expected) + } +} + +func TestRepoActorJSON(t *testing.T) { + doc := RepoActorJSON("alice", "myrepo", "A cool repo", "https://example.com") + if doc["type"] != "Repository" { + t.Errorf("type = %v, want Repository", doc["type"]) + } + if doc["preferredUsername"] != "myrepo" { + t.Errorf("preferredUsername = %v", doc["preferredUsername"]) + } + if doc["name"] != "alice/myrepo" { + t.Errorf("name = %v", doc["name"]) + } + if doc["summary"] != "A cool repo" { + t.Errorf("summary = %v", doc["summary"]) + } + + inbox, ok := doc["inbox"].(string) + if !ok || inbox != "https://example.com/repos/alice/myrepo/inbox" { + t.Errorf("inbox = %v", inbox) + } + outbox, ok := doc["outbox"].(string) + if !ok || outbox != "https://example.com/repos/alice/myrepo/outbox" { + t.Errorf("outbox = %v", outbox) + } +} + +func TestIsCreatePullRequest(t *testing.T) { + tests := []struct { + name string + body []byte + want bool + }{ + { + name: "valid Create(PullRequest)", + body: []byte(`{"type":"Create","object":{"type":"PullRequest","summary":"fix bug"}}`), + want: true, + }, + { + name: "Create with non-PR object", + body: []byte(`{"type":"Create","object":{"type":"Note"}}`), + want: false, + }, + { + name: "Follow activity", + body: []byte(`{"type":"Follow","object":"https://example.com/users/alice"}`), + want: false, + }, + { + name: "invalid JSON", + body: []byte(`not json`), + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsCreatePullRequest(tt.body); got != tt.want { + t.Errorf("IsCreatePullRequest() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestExtractInstanceURL(t *testing.T) { + tests := []struct { + apid string + want string + }{ + {"https://example.com/users/alice", "https://example.com"}, + {"http://localhost:8080/users/bob", "http://localhost:8080"}, + {"https://forge.example.org/users/charlie", "https://forge.example.org"}, + } + for _, tt := range tests { + t.Run(tt.apid, func(t *testing.T) { + if got := extractInstanceURL(tt.apid); got != tt.want { + t.Errorf("extractInstanceURL() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/domain/federation/inbox.go b/internal/domain/federation/inbox.go index 1534a8f..376a354 100644 --- a/internal/domain/federation/inbox.go +++ b/internal/domain/federation/inbox.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "log" + "strings" "time" "xorm.io/xorm" @@ -39,6 +40,16 @@ func Receive(db *xorm.Engine, localActor *models.FederationActor, body []byte) e handleAccept(db, localActor, activity) case "Undo": handleUndo(db, localActor, activity) + case "Create": + if IsCreatePullRequest(body) { + // Derive instanceURL from the local actor's APID. + instanceURL := extractInstanceURL(localActor.APID) + if err := HandleCreatePullRequest(db, body, instanceURL); err != nil { + log.Printf("federation: handle Create(PullRequest): %v", err) + } + } else { + log.Printf("federation: received Create activity from %s (non-PR, skipped)", actorAPID) + } default: log.Printf("federation: received unhandled activity type %q from %s", actType, actorAPID) } @@ -111,3 +122,13 @@ func mustJSON(v any) string { b, _ := json.Marshal(v) return string(b) } + +func extractInstanceURL(apid string) string { + // apid is like "https://example.com/users/alice" + // Return "https://example.com" + parts := strings.SplitN(apid, "/", 4) + if len(parts) >= 3 { + return parts[0] + "//" + parts[2] + } + return apid +} diff --git a/internal/domain/git/binary.go b/internal/domain/git/binary.go index 9b5dfd8..f3732ac 100644 --- a/internal/domain/git/binary.go +++ b/internal/domain/git/binary.go @@ -283,6 +283,14 @@ func RepoSize(repoPath string) int64 { return total } +// Run executes a git command in repoPath with discrete arguments and returns +// the raw stdout. WARNING: args must be constant literals or strictly validated +// — no user-controlled values belong here. This is the public equivalent of the +// internal run() helper and carries the same safety guarantees. +func Run(repoPath string, args ...string) ([]byte, error) { + return run(repoPath, args...) +} + // RevParse resolves a ref (branch name, tag, or SHA) to its full commit SHA. func RevParse(repoPath, ref string) (string, error) { out, err := run(repoPath, "rev-parse", "--verify", ref) diff --git a/internal/domain/oci/registry.go b/internal/domain/oci/registry.go new file mode 100644 index 0000000..257785b --- /dev/null +++ b/internal/domain/oci/registry.go @@ -0,0 +1,374 @@ +// Package oci implements an OCI Distribution Specification v1.1 registry. +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md +// +// Storage layout under ociRoot: +// +// blobs/sha256/ — content-addressable layer/config blobs +// uploads/ — temporary files for in-progress chunked uploads +package oci + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// Registry manages the on-disk blob store and is used by the HTTP handler. +type Registry struct { + root string // absolute path to the OCI storage root +} + +// New creates a Registry rooted at ociRoot, creating the directory tree if needed. +func New(ociRoot string) (*Registry, error) { + for _, sub := range []string{"blobs/sha256", "uploads"} { + if err := os.MkdirAll(filepath.Join(ociRoot, sub), 0755); err != nil { + return nil, fmt.Errorf("oci: init storage %s: %w", sub, err) + } + } + return &Registry{root: ociRoot}, nil +} + +// Root returns the storage root path. +func (r *Registry) Root() string { return r.root } + +// ─── Blob paths ─────────────────────────────────────────────────────────────── + +// BlobPath returns the filesystem path for a blob identified by its digest. +// digest must be in the form "sha256:". +func (r *Registry) BlobPath(digest string) (string, error) { + hex, err := digestHex(digest) + if err != nil { + return "", err + } + return filepath.Join(r.root, "blobs", "sha256", hex), nil +} + +// UploadPath returns the filesystem path for a chunked upload session. +func (r *Registry) UploadPath(uploadID string) string { + return filepath.Join(r.root, "uploads", sanitiseID(uploadID)) +} + +// BlobExists reports whether a blob with the given digest exists on disk. +func (r *Registry) BlobExists(digest string) bool { + p, err := r.BlobPath(digest) + if err != nil { + return false + } + _, err = os.Stat(p) + return err == nil +} + +// BlobSize returns the size of the blob in bytes, or -1 if it doesn't exist. +func (r *Registry) BlobSize(digest string) int64 { + p, err := r.BlobPath(digest) + if err != nil { + return -1 + } + info, err := os.Stat(p) + if err != nil { + return -1 + } + return info.Size() +} + +// ReadBlob opens a blob for streaming. Caller must close the returned file. +func (r *Registry) ReadBlob(digest string) (*os.File, error) { + p, err := r.BlobPath(digest) + if err != nil { + return nil, err + } + return os.Open(p) +} + +// WriteBlob writes src into the blob store, verifies the digest, and returns +// the computed digest string ("sha256:") and size. +// If a blob with the same digest already exists it is not overwritten. +func (r *Registry) WriteBlob(src io.Reader) (digest string, size int64, err error) { + tmp, err := os.CreateTemp(filepath.Join(r.root, "uploads"), "blob-*") + if err != nil { + return "", 0, fmt.Errorf("oci: create tmp blob: %w", err) + } + tmpPath := tmp.Name() + defer func() { + tmp.Close() + if err != nil { + os.Remove(tmpPath) + } + }() + + h := sha256.New() + mw := io.MultiWriter(tmp, h) + size, err = io.Copy(mw, src) + if err != nil { + return "", 0, fmt.Errorf("oci: write blob: %w", err) + } + tmp.Close() + + digest = "sha256:" + hex.EncodeToString(h.Sum(nil)) + dest, err2 := r.BlobPath(digest) + if err2 != nil { + os.Remove(tmpPath) + return "", 0, err2 + } + + if _, statErr := os.Stat(dest); statErr == nil { + // Already exists — deduplication. + os.Remove(tmpPath) + return digest, size, nil + } + + if err = os.Rename(tmpPath, dest); err != nil { + return "", 0, fmt.Errorf("oci: commit blob: %w", err) + } + return digest, size, nil +} + +// FinishUpload finalises a chunked upload: reads the temp file, verifies +// clientDigest (if non-empty), atomically moves it to the blob store, and +// returns the canonical digest and size. +func (r *Registry) FinishUpload(uploadID, clientDigest string) (digest string, size int64, err error) { + src := r.UploadPath(uploadID) + f, err := os.Open(src) + if err != nil { + return "", 0, fmt.Errorf("oci: open upload: %w", err) + } + + h := sha256.New() + size, err = io.Copy(h, f) + f.Close() + if err != nil { + return "", 0, fmt.Errorf("oci: hash upload: %w", err) + } + + digest = "sha256:" + hex.EncodeToString(h.Sum(nil)) + + if clientDigest != "" && clientDigest != digest { + os.Remove(src) + return "", 0, &DigestMismatch{Expected: clientDigest, Actual: digest} + } + + dest, err := r.BlobPath(digest) + if err != nil { + return "", 0, err + } + + if _, statErr := os.Stat(dest); statErr == nil { + // Blob already exists — dedup. + os.Remove(src) + return digest, size, nil + } + + if err = os.Rename(src, dest); err != nil { + return "", 0, fmt.Errorf("oci: commit upload: %w", err) + } + return digest, size, nil +} + +// AppendUpload appends src to an existing upload session file and returns the +// new total offset. +func (r *Registry) AppendUpload(uploadID string, src io.Reader) (newOffset int64, err error) { + path := r.UploadPath(uploadID) + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return 0, fmt.Errorf("oci: open upload for append: %w", err) + } + defer f.Close() + + n, err := io.Copy(f, src) + if err != nil { + return 0, fmt.Errorf("oci: append upload: %w", err) + } + + info, err := f.Stat() + if err != nil { + return n, nil + } + return info.Size(), nil +} + +// UploadOffset returns the number of bytes written to an upload session so far. +func (r *Registry) UploadOffset(uploadID string) int64 { + info, err := os.Stat(r.UploadPath(uploadID)) + if err != nil { + return 0 + } + return info.Size() +} + +// CancelUpload removes the temporary upload file. +func (r *Registry) CancelUpload(uploadID string) { + os.Remove(r.UploadPath(uploadID)) +} + +// DeleteBlob removes a blob from disk. +func (r *Registry) DeleteBlob(digest string) error { + p, err := r.BlobPath(digest) + if err != nil { + return err + } + return os.Remove(p) +} + +// ─── Manifest helpers ───────────────────────────────────────────────────────── + +// ManifestDescriptor extracts the digest and size from a raw manifest body. +func ManifestDescriptor(body []byte) (digest string, size int64) { + h := sha256.Sum256(body) + return "sha256:" + hex.EncodeToString(h[:]), int64(len(body)) +} + +// IsDigestRef returns true when ref looks like a digest ("sha256:"). +func IsDigestRef(ref string) bool { + return strings.HasPrefix(ref, "sha256:") +} + +// ─── OCI error types ───────────────────────────────────────────────────────── + +// ErrorCode is an OCI Distribution API error code. +type ErrorCode string + +const ( + ErrBlobUnknown ErrorCode = "BLOB_UNKNOWN" + ErrBlobUploadInvalid ErrorCode = "BLOB_UPLOAD_INVALID" + ErrBlobUploadUnknown ErrorCode = "BLOB_UPLOAD_UNKNOWN" + ErrDigestInvalid ErrorCode = "DIGEST_INVALID" + ErrManifestBlobUnknown ErrorCode = "MANIFEST_BLOB_UNKNOWN" + ErrManifestInvalid ErrorCode = "MANIFEST_INVALID" + ErrManifestUnknown ErrorCode = "MANIFEST_UNKNOWN" + ErrNameInvalid ErrorCode = "NAME_INVALID" + ErrNameUnknown ErrorCode = "NAME_UNKNOWN" + ErrTagInvalid ErrorCode = "TAG_INVALID" + ErrUnauthorized ErrorCode = "UNAUTHORIZED" + ErrDenied ErrorCode = "DENIED" + ErrUnsupported ErrorCode = "UNSUPPORTED" +) + +// APIError is a single OCI error entry. +type APIError struct { + Code ErrorCode `json:"code"` + Message string `json:"message"` + Detail interface{} `json:"detail,omitempty"` +} + +// ErrorResponse is the top-level OCI error response body. +type ErrorResponse struct { + Errors []APIError `json:"errors"` +} + +// NewError builds an ErrorResponse JSON body. +func NewError(code ErrorCode, msg string) []byte { + b, _ := json.Marshal(ErrorResponse{Errors: []APIError{{Code: code, Message: msg}}}) + return b +} + +// DigestMismatch is returned when a client-provided digest doesn't match the computed one. +type DigestMismatch struct { + Expected string + Actual string +} + +func (e *DigestMismatch) Error() string { + return fmt.Sprintf("digest mismatch: expected %s, got %s", e.Expected, e.Actual) +} + +// ─── path helpers ───────────────────────────────────────────────────────────── + +// digestHex validates a "sha256:" digest string and returns the hex part. +func digestHex(digest string) (string, error) { + if !strings.HasPrefix(digest, "sha256:") { + return "", fmt.Errorf("oci: only sha256 digests are supported, got %q", digest) + } + h := strings.TrimPrefix(digest, "sha256:") + if len(h) != 64 { + return "", fmt.Errorf("oci: invalid sha256 digest length: %d", len(h)) + } + return h, nil +} + +// sanitiseID strips any path separators from an upload ID. +func sanitiseID(id string) string { + return strings.NewReplacer("/", "", "\\", "", "..", "").Replace(id) +} + +// ParseOCIPath extracts the image name and the operation kind from a path +// under /v2/. name may contain slashes (e.g. "alice/myapp"). +// +// Returns: name, kind, ref where kind is one of: +// +// "tags" → ref = "" +// "manifest" → ref = tag or digest +// "blob" → ref = digest +// "upload" → ref = uploadID (empty for new upload) +// "" → unrecognised path +func ParseOCIPath(rawPath string) (name, kind, ref string) { + // Strip leading /v2/ + p := strings.TrimPrefix(rawPath, "/v2/") + if p == "" || p == "/" { + return "", "", "" + } + + // Try known suffixes from most to least specific. + type suffix struct { + needle string + kind string + } + suffixes := []suffix{ + {"/blobs/uploads/", "upload"}, + {"/blobs/sha256:", "blob"}, + {"/blobs/", "blob"}, + {"/manifests/", "manifest"}, + {"/tags/list", "tags"}, + } + + for _, s := range suffixes { + idx := strings.Index(p, s.needle) + if idx < 0 { + continue + } + name = p[:idx] + rest := p[idx+len(s.needle):] + kind = s.kind + switch s.kind { + case "blob": + // ref is digest: re-attach the sha256: prefix if needed + if strings.HasSuffix(s.needle, ":") { + ref = "sha256:" + rest + } else { + ref = rest + } + case "upload": + ref = rest // upload UUID or empty for new session + default: + ref = rest + } + return name, kind, ref + } + return "", "", "" +} + +// ValidateName returns an error if the image name is empty or contains +// invalid characters. +func ValidateName(name string) error { + if name == "" { + return errors.New("empty image name") + } + for _, c := range name { + if !isNameChar(c) { + return fmt.Errorf("invalid character %q in image name", c) + } + } + return nil +} + +func isNameChar(c rune) bool { + return (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + c == '.' || c == '-' || c == '_' || c == '/' +} diff --git a/internal/domain/oci/registry_test.go b/internal/domain/oci/registry_test.go new file mode 100644 index 0000000..067e59c --- /dev/null +++ b/internal/domain/oci/registry_test.go @@ -0,0 +1,254 @@ +package oci_test + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/forgeo/forgebucket/internal/domain/oci" +) + +func TestParseOCIPath(t *testing.T) { + tests := []struct { + path string + wantName string + wantKind string + wantRef string + }{ + {"/v2/", "", "", ""}, + {"/v2", "", "", ""}, + {"/v2/alice/myapp/tags/list", "alice/myapp", "tags", ""}, + {"/v2/alice/myapp/manifests/latest", "alice/myapp", "manifest", "latest"}, + {"/v2/alice/myapp/manifests/sha256:abc123", "alice/myapp", "manifest", "sha256:abc123"}, + {"/v2/alice/myapp/blobs/sha256:def456", "alice/myapp", "blob", "sha256:def456"}, + {"/v2/alice/myapp/blobs/uploads/", "alice/myapp", "upload", ""}, + {"/v2/alice/myapp/blobs/uploads/uuid123", "alice/myapp", "upload", "uuid123"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + name, kind, ref := oci.ParseOCIPath(tt.path) + if name != tt.wantName { + t.Errorf("name = %q, want %q", name, tt.wantName) + } + if kind != tt.wantKind { + t.Errorf("kind = %q, want %q", kind, tt.wantKind) + } + if ref != tt.wantRef { + t.Errorf("ref = %q, want %q", ref, tt.wantRef) + } + }) + } +} + +func TestValidateName(t *testing.T) { + if err := oci.ValidateName("alice/myapp"); err != nil { + t.Errorf("valid name got error: %v", err) + } + if err := oci.ValidateName(""); err == nil { + t.Error("empty name should error") + } + if err := oci.ValidateName("alice/my app"); err == nil { + t.Error("name with spaces should error") + } +} + +func TestBlobPath(t *testing.T) { + dir := t.TempDir() + reg, err := oci.New(dir) + if err != nil { + t.Fatal(err) + } + + p, err := reg.BlobPath("sha256:" + strings.Repeat("a", 64)) + if err != nil { + t.Fatal(err) + } + + expectedSuffix := filepath.Join("blobs", "sha256", strings.Repeat("a", 64)) + if !strings.HasSuffix(p, expectedSuffix) { + t.Errorf("path %q does not end with %q", p, expectedSuffix) + } + + if _, err := reg.BlobPath("sha256:bad"); err == nil { + t.Error("expected error for short hex") + } + if _, err := reg.BlobPath("md5:abc"); err == nil { + t.Error("expected error for non-sha256 algorithm") + } +} + +func TestWriteAndReadBlob(t *testing.T) { + dir := t.TempDir() + reg, err := oci.New(dir) + if err != nil { + t.Fatal(err) + } + + content := []byte("hello oci blob") + digest, size, err := reg.WriteBlob(bytes.NewReader(content)) + if err != nil { + t.Fatalf("WriteBlob: %v", err) + } + + if !strings.HasPrefix(digest, "sha256:") { + t.Errorf("digest should start with sha256:, got %s", digest) + } + if size != int64(len(content)) { + t.Errorf("size = %d, want %d", size, len(content)) + } + + if !reg.BlobExists(digest) { + t.Error("blob should exist after write") + } + + // Deduplication test: writing same content again should succeed without error. + d2, s2, err := reg.WriteBlob(bytes.NewReader(content)) + if err != nil { + t.Fatalf("WriteBlob duplicate: %v", err) + } + if d2 != digest { + t.Errorf("digest mismatch: %s vs %s", d2, digest) + } + if s2 != size { + t.Errorf("size mismatch: %d vs %d", s2, size) + } + + f, err := reg.ReadBlob(digest) + if err != nil { + t.Fatalf("ReadBlob: %v", err) + } + defer f.Close() + + buf := new(bytes.Buffer) + buf.ReadFrom(f) + if buf.String() != string(content) { + t.Errorf("content mismatch: got %s", buf.String()) + } +} + +func TestUploadSession(t *testing.T) { + dir := t.TempDir() + reg, _ := oci.New(dir) + + uploadID := "test-upload-001" + + // Append content in chunks. + off, err := reg.AppendUpload(uploadID, strings.NewReader("chunk1")) + if err != nil { + t.Fatalf("AppendUpload: %v", err) + } + if off != 6 { + t.Errorf("expected offset 6, got %d", off) + } + + off, err = reg.AppendUpload(uploadID, strings.NewReader("-chunk2")) + if err != nil { + t.Fatalf("AppendUpload second: %v", err) + } + if off != 13 { + t.Errorf("expected offset 13 after chunk2, got %d", off) + } + + if reg.UploadOffset(uploadID) != 13 { + t.Errorf("UploadOffset = %d, want 13", reg.UploadOffset(uploadID)) + } + + // Finish upload with digest. + digest, size, err := reg.FinishUpload(uploadID, "") + if err != nil { + t.Fatalf("FinishUpload: %v", err) + } + if !strings.HasPrefix(digest, "sha256:") { + t.Errorf("expected sha256 digest, got %s", digest) + } + if size != 13 { + t.Errorf("expected size 13, got %d", size) + } + + if !reg.BlobExists(digest) { + t.Error("blob should exist after finish upload") + } + + // Verify content. + f, _ := reg.ReadBlob(digest) + buf := new(bytes.Buffer) + buf.ReadFrom(f) + f.Close() + if buf.String() != "chunk1-chunk2" { + t.Errorf("content = %q, want %q", buf.String(), "chunk1-chunk2") + } +} + +func TestFinishUploadDigestMismatch(t *testing.T) { + dir := t.TempDir() + reg, _ := oci.New(dir) + + uploadID := "mismatch-upload" + reg.AppendUpload(uploadID, strings.NewReader("some data")) + + _, _, err := reg.FinishUpload(uploadID, "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + if err == nil { + t.Fatal("expected digest mismatch error") + } + if !strings.Contains(err.Error(), "digest mismatch") { + t.Errorf("expected 'digest mismatch', got: %v", err) + } +} + +func TestManifestDescriptor(t *testing.T) { + body := []byte(`{"schemaVersion":2}`) + digest, size := oci.ManifestDescriptor(body) + if !strings.HasPrefix(digest, "sha256:") { + t.Errorf("digest should be sha256, got %s", digest) + } + if size != int64(len(body)) { + t.Errorf("size = %d, want %d", size, len(body)) + } +} + +func TestIsDigestRef(t *testing.T) { + if !oci.IsDigestRef("sha256:abc") { + t.Error("sha256:abc should be a digest ref") + } + if oci.IsDigestRef("latest") { + t.Error("latest should NOT be a digest ref") + } +} + +func TestDeleteBlob(t *testing.T) { + dir := t.TempDir() + reg, _ := oci.New(dir) + content := []byte("delete me") + digest, _, _ := reg.WriteBlob(bytes.NewReader(content)) + + if !reg.BlobExists(digest) { + t.Fatal("blob should exist after write") + } + + if err := reg.DeleteBlob(digest); err != nil { + t.Fatalf("DeleteBlob: %v", err) + } + + if reg.BlobExists(digest) { + t.Error("blob should not exist after delete") + } +} + +func TestNewCreatesDirectories(t *testing.T) { + dir := filepath.Join(t.TempDir(), "oci-storage") + reg, err := oci.New(dir) + if err != nil { + t.Fatal(err) + } + + for _, sub := range []string{"blobs/sha256", "uploads"} { + p := filepath.Join(dir, sub) + if _, err := os.Stat(p); os.IsNotExist(err) { + t.Errorf("directory not created: %s", p) + } + } + _ = reg +} diff --git a/internal/domain/scanning/scanner.go b/internal/domain/scanning/scanner.go new file mode 100644 index 0000000..d66665f --- /dev/null +++ b/internal/domain/scanning/scanner.go @@ -0,0 +1,170 @@ +package scanning + +import ( + "context" + "encoding/json" + "fmt" + "log" + "regexp" + "time" + + "xorm.io/xorm" + + "github.com/forgeo/forgebucket/internal/events" + gitdomain "github.com/forgeo/forgebucket/internal/domain/git" + "github.com/forgeo/forgebucket/internal/models" +) + +// compiledPattern is a pre-compiled regex pattern. +type compiledPattern struct { + pattern + re *regexp.Regexp +} + +// Scanner subscribes to push.received and scans commit content for secrets. +type Scanner struct { + db *xorm.Engine + bus events.EventBus + patterns []compiledPattern +} + +// New creates a Scanner with all patterns pre-compiled. +func New(db *xorm.Engine, bus events.EventBus) (*Scanner, error) { + cp := make([]compiledPattern, 0, len(Patterns)) + for _, p := range Patterns { + re, err := regexp.Compile(p.Raw) + if err != nil { + return nil, fmt.Errorf("scanning: compile pattern %q: %w", p.Name, err) + } + cp = append(cp, compiledPattern{pattern: p, re: re}) + } + return &Scanner{db: db, bus: bus, patterns: cp}, nil +} + +// Start subscribes to push.received and blocks until ctx is cancelled. +func (s *Scanner) Start(ctx context.Context) { + unsub, err := s.bus.Subscribe(events.SubjectPushReceived, func(_ string, data []byte) { + var evt events.PushEvent + if err := json.Unmarshal(data, &evt); err != nil { + log.Printf("scanning: bad push event: %v", err) + return + } + go s.scanPush(evt) + }) + if err != nil { + log.Printf("scanning: subscribe: %v", err) + } else { + defer unsub() + } + <-ctx.Done() +} + +// scanPush scans the diff between before and after for all patterns. +func (s *Scanner) scanPush(evt events.PushEvent) { + // Branch deletion — nothing to scan. + zeroOID := "0000000000000000000000000000000000000000" + if evt.After == zeroOID { + return + } + + // Resolve repo. + var repo models.Repository + if found, _ := s.db.ID(evt.RepoID).Get(&repo); !found { + return + } + + // Get the diff content between before and after. + diffContent, err := s.getDiff(repo.DiskPath, evt.Before, evt.After) + if err != nil { + log.Printf("scanning: get diff for repo %s: %v", repo.Name, err) + return + } + + // Determine the commit SHA for the findings. + headSHA := evt.After + + now := time.Now().UTC() + + for _, p := range s.patterns { + matches := p.re.FindAllString(string(diffContent), -1) + for _, match := range matches { + // Skip very short matches (likely false positives). + if len(match) < 6 { + continue + } + + leak := &models.SecretLeak{ + RepoID: evt.RepoID, + CommitSHA: headSHA[:12], + Ref: evt.Ref, + PatternName: p.Name, + Description: p.Description, + Severity: p.Severity, + MatchSample: truncate(match, 40), + DetectedAt: now, + } + if _, err := s.db.Insert(leak); err != nil { + log.Printf("scanning: insert leak for %s: %v", repo.Name, err) + } + } + } +} + +// getDiff returns the unified diff of all changes between two refs. +func (s *Scanner) getDiff(repoPath, oldRef, newRef string) ([]byte, error) { + // If oldRef is the zero OID (new branch), just get the initial commit content. + zeroOID := "0000000000000000000000000000000000000000" + if oldRef == zeroOID { + // Show the entire tree at the new ref. + out, err := gitdomain.Run(repoPath, "ls-tree", "-r", newRef) + if err != nil { + return nil, err + } + return out, nil + } + + out, err := gitdomain.Run(repoPath, "diff", "--no-color", "--unified=3", oldRef, newRef) + if err != nil { + return nil, err + } + return out, nil +} + +// ListFindings returns all active secret leaks for a repo, newest first. +func (s *Scanner) ListFindings(repoID int64) ([]models.SecretLeak, error) { + var leaks []models.SecretLeak + if err := s.db.Where("repo_id = ? AND dismissed = ?", repoID, false). + OrderBy("detected_at DESC").Find(&leaks); err != nil { + return nil, err + } + if leaks == nil { + leaks = []models.SecretLeak{} + } + return leaks, nil +} + +// DismissFindings acknowledges a leak so it no longer appears in active lists. +func (s *Scanner) DismissFindings(leakID int64, dismissedBy string) error { + now := time.Now().UTC() + affected, err := s.db.ID(leakID).Cols("dismissed", "dismissed_by", "dismissed_at"). + Update(&models.SecretLeak{ + Dismissed: true, + DismissedBy: dismissedBy, + DismissedAt: &now, + }) + if err != nil { + return err + } + if affected == 0 { + return fmt.Errorf("leak %d not found", leakID) + } + return nil +} + +// truncate shortens a string to maxLen characters for safe display. +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} diff --git a/internal/domain/scanning/secrets.go b/internal/domain/scanning/secrets.go new file mode 100644 index 0000000..0c2d951 --- /dev/null +++ b/internal/domain/scanning/secrets.go @@ -0,0 +1,106 @@ +package scanning + +// pattern holds a compiled regex-like pattern string and its metadata. +// We use raw string patterns rather than importing regexp for each check; +// the Scanner compiles all patterns once at startup. +type pattern struct { + Name string + Description string + Raw string // the regex pattern (compiled at init) + Severity string // "high", "medium", "low" +} + +// Patterns is the list of secret patterns checked against every pushed commit. +// Patterns are ordered by severity — high first. +var Patterns = []pattern{ + { + Name: "aws-access-key-id", + Description: "AWS Access Key ID", + Raw: `AKIA[0-9A-Z]{16}`, + Severity: "high", + }, + { + Name: "aws-secret-key", + Description: "AWS Secret Access Key", + Raw: `(?i)aws[_-]?(secret|private)[_-]?(access[_-]?)?key['"]?\s*[:=]\s*['"]?[A-Za-z0-9\/+=]{40}`, + Severity: "high", + }, + { + Name: "github-token", + Description: "GitHub Personal Access Token", + Raw: `gh[pousr]_[A-Za-z0-9_]{36,}`, + Severity: "high", + }, + { + Name: "gitlab-token", + Description: "GitLab Personal Access Token", + Raw: `glpat-[A-Za-z0-9\-_]{20,}`, + Severity: "high", + }, + { + Name: "generic-api-key", + Description: "Generic API key assignment (high entropy)", + Raw: `(?i)(api[_-]?key|apikey|api[_-]?secret|api[_-]?token)['"]?\s*[:=]\s*['"][A-Za-z0-9_\-\.]{20,64}`, + Severity: "high", + }, + { + Name: "bearer-token", + Description: "Bearer token in HTTP header", + Raw: `(?i)authorization:\s*bearer\s+[A-Za-z0-9_\-\.]{20,}`, + Severity: "high", + }, + { + Name: "slack-token", + Description: "Slack Bot / Webhook token", + Raw: `xox[baprs]-[A-Za-z0-9\-]{10,}`, + Severity: "high", + }, + { + Name: "google-api-key", + Description: "Google API Key", + Raw: `AIza[0-9A-Za-z\-_]{35}`, + Severity: "high", + }, + { + Name: "google-service-account", + Description: "Google Service Account", + Raw: `[0-9]+-[0-9a-z]{32}\.apps\.googleusercontent\.com`, + Severity: "high", + }, + { + Name: "ssh-private-key", + Description: "SSH / TLS private key embed", + Raw: `-----BEGIN\s+(RSA|EC|OPENSSH|DSA|PRIVATE)(\s+PRIVATE)?\s+KEY-----`, + Severity: "high", + }, + { + Name: "jwt-token", + Description: "JSON Web Token (JWT)", + Raw: `eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`, + Severity: "medium", + }, + { + Name: "generic-password", + Description: "Generic password/secret field assignment", + Raw: `(?i)(password|passwd|pwd|secret)['"]?\s*[:=]\s*['"][A-Za-z0-9!@#$%^&*()_+\-=\[\]{}|;:,.<>?]{8,}`, + Severity: "medium", + }, + { + Name: "npm-token", + Description: "npm access token", + Raw: `npm_[A-Za-z0-9]{36,}`, + Severity: "high", + }, + { + Name: "pg-connection-string", + Description: "PostgreSQL connection string", + Raw: `postgres(ql)?://[A-Za-z0-9_]+:[^@\s]+@`, + Severity: "high", + }, + { + Name: "redis-connection-string", + Description: "Redis connection string with password", + Raw: `redis://[^:@\s]+:[^@\s]+@`, + Severity: "high", + }, +} diff --git a/internal/domain/scanning/secrets_test.go b/internal/domain/scanning/secrets_test.go new file mode 100644 index 0000000..617ae0d --- /dev/null +++ b/internal/domain/scanning/secrets_test.go @@ -0,0 +1,118 @@ +package scanning + +import ( + "regexp" + "testing" +) + +func TestPatternsCompile(t *testing.T) { + for _, p := range Patterns { + _, err := regexp.Compile(p.Raw) + if err != nil { + t.Errorf("pattern %q failed to compile: %v", p.Name, err) + } + } +} + +func TestPatternsHaveNames(t *testing.T) { + for _, p := range Patterns { + if p.Name == "" { + t.Error("pattern with empty name") + } + if p.Description == "" { + t.Errorf("pattern %q has empty description", p.Name) + } + if p.Severity != "high" && p.Severity != "medium" && p.Severity != "low" { + t.Errorf("pattern %q has invalid severity %q", p.Name, p.Severity) + } + } +} + +func TestAWSAccessKey(t *testing.T) { + re := regexp.MustCompile(`AKIA[0-9A-Z]{16}`) + cases := []struct { + input string + match bool + }{ + {"AKIAIOSFODNN7EXAMPLE", true}, + {"AKIA1234567890123456", true}, + {"not-a-key", false}, + {"SKIA1234567890123456", false}, + } + for _, tc := range cases { + got := re.MatchString(tc.input) + if got != tc.match { + t.Errorf("input %q: got %v, want %v", tc.input, got, tc.match) + } + } +} + +func TestGitHubToken(t *testing.T) { + re := regexp.MustCompile(`gh[pousr]_[A-Za-z0-9_]{36,}`) + cases := []struct { + input string + match bool + }{ + {"ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true}, + {"gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true}, + {"ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true}, + {"ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true}, + {"ghr_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true}, + {"not-a-token", false}, + {"ghp_short", false}, + } + for _, tc := range cases { + got := re.MatchString(tc.input) + if got != tc.match { + t.Errorf("input %q: got %v, want %v", tc.input, got, tc.match) + } + } +} + +func TestPrivateKey(t *testing.T) { + re := regexp.MustCompile(`-----BEGIN\s+(RSA|EC|OPENSSH|DSA|PRIVATE)(\s+PRIVATE)?\s+KEY-----`) + cases := []struct { + input string + match bool + }{ + {"-----BEGIN RSA PRIVATE KEY-----", true}, + {"-----BEGIN EC PRIVATE KEY-----", true}, + {"-----BEGIN OPENSSH PRIVATE KEY-----", true}, + {"-----BEGIN DSA PRIVATE KEY-----", true}, + {"-----BEGIN PRIVATE KEY-----", true}, + {"-----BEGIN CERTIFICATE-----", false}, + {"public key is here", false}, + } + for _, tc := range cases { + got := re.MatchString(tc.input) + if got != tc.match { + t.Errorf("input %q: got %v, want %v", tc.input, got, tc.match) + } + } +} + +func TestJWT(t *testing.T) { + re := regexp.MustCompile(`eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`) + cases := []struct { + input string + match bool + }{ + {"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNnZctV9XjvP_oGZQZxGdAqVxQ", true}, + {"not-a-jwt", false}, + } + for _, tc := range cases { + got := re.MatchString(tc.input) + if got != tc.match { + t.Errorf("input %q: got %v, want %v", tc.input, got, tc.match) + } + } +} + +func TestTruncate(t *testing.T) { + if truncate("hello", 10) != "hello" { + t.Error("should not truncate short strings") + } + if truncate("hello world this is long", 10) != "hello worl..." { + t.Errorf("got %q", truncate("hello world this is long", 10)) + } +} diff --git a/internal/domain/vulnscan/osv.go b/internal/domain/vulnscan/osv.go new file mode 100644 index 0000000..0f65970 --- /dev/null +++ b/internal/domain/vulnscan/osv.go @@ -0,0 +1,140 @@ +package vulnscan + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const defaultOSVAPI = "https://api.osv.dev/v1" + +// Client queries the OSV (Open Source Vulnerabilities) API. +// https://osv.dev/docs/ +type Client struct { + baseURL string + http *http.Client +} + +// NewClient creates a client that queries the public OSV API. +func NewClient() *Client { + return &Client{ + baseURL: defaultOSVAPI, + http: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// QueryRequest is sent to POST /v1/query. +type QueryRequest struct { + Package PackageID `json:"package"` + Version string `json:"version"` +} + +// PackageID identifies a package in a specific ecosystem. +type PackageID struct { + PURL string `json:"purl,omitempty"` + Name string `json:"name,omitempty"` + Ecosystem string `json:"ecosystem,omitempty"` +} + +// QueryResponse is the response from POST /v1/query. +type QueryResponse struct { + Vulns []OSVVuln `json:"vulns"` +} + +// OSVVuln is a vulnerability returned by the OSV API. +type OSVVuln struct { + ID string `json:"id"` + Summary string `json:"summary"` + Details string `json:"details"` + Aliases []string `json:"aliases"` + Fixed string `json:"fixed,omitempty"` + Severity []Severity `json:"severity,omitempty"` + Affected []Affected `json:"affected,omitempty"` + Published string `json:"published,omitempty"` + Modified string `json:"modified,omitempty"` +} + +// Severity holds a CVSS score from the OSV response. +type Severity struct { + Type string `json:"type"` + Score string `json:"score"` +} + +// Affected describes a package version range. +type Affected struct { + Package PackageID `json:"package"` + Ranges []AffectedRange `json:"ranges"` + Versions []string `json:"versions"` +} + +type AffectedRange struct { + Type string `json:"type"` + Events []RangeEvent `json:"events"` +} + +type RangeEvent struct { + Introduced string `json:"introduced"` + Fixed string `json:"fixed"` + Limit string `json:"limit"` +} + +// QueryByPURL queries OSV for vulnerabilities affecting a given PURL + version. +func (c *Client) QueryByPURL(purl, version string) ([]OSVVuln, error) { + body := QueryRequest{ + Package: PackageID{PURL: purl}, + Version: version, + } + return c.doQuery(body) +} + +// QueryByEcosystem queries OSV for vulnerabilities affecting a package in a +// specific ecosystem (e.g. "npm", "Go", "PyPI", "cargo", "Maven", "RubyGems"). +func (c *Client) QueryByEcosystem(ecosystem, name, version string) ([]OSVVuln, error) { + body := QueryRequest{ + Package: PackageID{ + Name: name, + Ecosystem: ecosystem, + }, + Version: version, + } + return c.doQuery(body) +} + +func (c *Client) doQuery(body interface{}) ([]OSVVuln, error) { + payload, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("vulnscan: marshal body: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, c.baseURL+"/query", bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("vulnscan: create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("vulnscan: query: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("vulnscan: read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("vulnscan: OSV returned %d: %s", resp.StatusCode, string(respBody)) + } + + var qr QueryResponse + if err := json.Unmarshal(respBody, &qr); err != nil { + return nil, fmt.Errorf("vulnscan: parse response: %w", err) + } + return qr.Vulns, nil +} diff --git a/internal/domain/vulnscan/osv_test.go b/internal/domain/vulnscan/osv_test.go new file mode 100644 index 0000000..412560f --- /dev/null +++ b/internal/domain/vulnscan/osv_test.go @@ -0,0 +1,89 @@ +package vulnscan + +import ( + "encoding/json" + "testing" +) + +func TestParseCVSS(t *testing.T) { + v := OSVVuln{ + ID: "CVE-2024-0001", + Severity: []Severity{ + {Type: "CVSS_V3", Score: "7.5"}, + }, + } + score := parseCVSS(v) + if score != 7.5 { + t.Errorf("expected 7.5, got %f", score) + } +} + +func TestParseCVSS_NoScore(t *testing.T) { + v := OSVVuln{ + ID: "GHSA-xxxx", + } + score := parseCVSS(v) + if score != 0 { + t.Errorf("expected 0 for no severity, got %f", score) + } +} + +func TestExtractFixedVersion(t *testing.T) { + v := OSVVuln{ + Affected: []Affected{ + { + Ranges: []AffectedRange{ + { + Events: []RangeEvent{ + {Introduced: "0"}, + {Fixed: "1.2.3"}, + }, + }, + }, + }, + }, + } + fixed := extractFixedVersion(v) + if fixed != "1.2.3" { + t.Errorf("expected 1.2.3, got %s", fixed) + } +} + +func TestExtractFixedVersion_None(t *testing.T) { + v := OSVVuln{} + fixed := extractFixedVersion(v) + if fixed != "" { + t.Errorf("expected empty, got %s", fixed) + } +} + +func TestTruncateStr(t *testing.T) { + if truncateStr("short", 10) != "short" { + t.Error("should not truncate short strings") + } + if truncateStr("this is a long string", 10) != "this is a ..." { + t.Errorf("got %q", truncateStr("this is a long string", 10)) + } +} + +func TestNewClient(t *testing.T) { + c := NewClient() + if c.baseURL != defaultOSVAPI { + t.Errorf("baseURL = %s, want %s", c.baseURL, defaultOSVAPI) + } +} + +func TestQueryRequest_Marshal(t *testing.T) { + body := QueryRequest{ + Package: PackageID{PURL: "pkg:golang/github.com/foo/bar@v1.0.0"}, + Version: "v1.0.0", + } + data, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + // Ensure it produces valid JSON. + if len(data) == 0 { + t.Error("empty JSON") + } +} diff --git a/internal/domain/vulnscan/scanner.go b/internal/domain/vulnscan/scanner.go new file mode 100644 index 0000000..057c160 --- /dev/null +++ b/internal/domain/vulnscan/scanner.go @@ -0,0 +1,179 @@ +package vulnscan + +import ( + "context" + "encoding/json" + "fmt" + "log" + "time" + + "xorm.io/xorm" + + "github.com/forgeo/forgebucket/internal/events" + "github.com/forgeo/forgebucket/internal/models" +) + +// Scanner watches for SBOM generation events and queries OSV for vulns. +type Scanner struct { + db *xorm.Engine + bus events.EventBus + client *Client +} + +func NewScanner(db *xorm.Engine, bus events.EventBus) *Scanner { + return &Scanner{ + db: db, + bus: bus, + client: NewClient(), + } +} + +// Start subscribes to SBOM-related events and scans for vulnerabilities. +func (s *Scanner) Start(ctx context.Context) { + // Listen for SBOM Report created events (sync trigger). + // In practice this is called on-demand via the API, so Start is minimal. + <-ctx.Done() +} + +// ScanByPURL queries OSV for a single package and stores findings. +func (s *Scanner) ScanByPURL(repoID int64, purl, version string) ([]models.VulnerabilityFinding, error) { + vulns, err := s.client.QueryByPURL(purl, version) + if err != nil { + return nil, err + } + return s.persistFindings(repoID, purl, version, vulns), nil +} + +// ScanSBOM reads the latest SBOM report for a repo, queries OSV for every +// component, and stores the findings. Returns the new findings. +func (s *Scanner) ScanSBOM(repoID int64) ([]models.VulnerabilityFinding, error) { + var report models.SBOMReport + found, err := s.db.Where("repo_id = ?", repoID). + OrderBy("generated_at DESC").Get(&report) + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("no SBOM found for repo %d", repoID) + } + + var doc struct { + Components []struct { + Name string `json:"name"` + Version string `json:"version"` + PURL string `json:"purl"` + } `json:"components"` + } + if err := json.Unmarshal([]byte(report.BOMDocument), &doc); err != nil { + return nil, fmt.Errorf("parse SBOM: %w", err) + } + + var allFindings []models.VulnerabilityFinding + for _, comp := range doc.Components { + if comp.PURL == "" || comp.Version == "" { + continue + } + vulns, err := s.client.QueryByPURL(comp.PURL, comp.Version) + if err != nil { + log.Printf("vulnscan: query %s@%s: %v", comp.PURL, comp.Version, err) + continue + } + findings := s.persistFindings(repoID, comp.PURL, comp.Version, vulns) + allFindings = append(allFindings, findings...) + } + return allFindings, nil +} + +// ListFindings returns unfixed vulnerability findings for a repo. +func (s *Scanner) ListFindings(repoID int64) ([]models.VulnerabilityFinding, error) { + var findings []models.VulnerabilityFinding + if err := s.db.Where("repo_id = ? AND dismissed = ?", repoID, false). + OrderBy("cvss_score DESC, detected_at DESC").Find(&findings); err != nil { + return nil, err + } + if findings == nil { + findings = []models.VulnerabilityFinding{} + } + return findings, nil +} + +// DismissFindings acknowledges a vulnerability finding. +func (s *Scanner) DismissFindings(findingID int64, dismissedBy string) error { + now := time.Now().UTC() + affected, err := s.db.ID(findingID).Cols("dismissed", "dismissed_by", "dismissed_at"). + Update(&models.VulnerabilityFinding{ + Dismissed: true, + DismissedBy: dismissedBy, + DismissedAt: &now, + }) + if err != nil { + return err + } + if affected == 0 { + return fmt.Errorf("finding %d not found", findingID) + } + return nil +} + +func (s *Scanner) persistFindings(repoID int64, purl, version string, vulns []OSVVuln) []models.VulnerabilityFinding {var findings []models.VulnerabilityFinding + for _, v := range vulns { + // Check for duplicate before inserting. + existing := &models.VulnerabilityFinding{} + if has, _ := s.db.Where("vuln_id = ? AND purl = ? AND repo_id = ?", v.ID, purl, repoID).Get(existing); has { + continue + } + + cvssScore := parseCVSS(v) + + finding := &models.VulnerabilityFinding{ + RepoID: repoID, + VulnID: v.ID, + PURL: purl, + Version: version, + Summary: truncateStr(v.Summary, 300), + Details: v.Details, + CVSSScore: cvssScore, + FixedVersion: extractFixedVersion(v), + DetectedAt: time.Now().UTC(), + } + if _, err := s.db.Insert(finding); err != nil { + log.Printf("vulnscan: insert finding %s for %s: %v", v.ID, purl, err) + continue + } + findings = append(findings, *finding) + } + return findings +} + +// parseCVSS extracts the CVSS score from OSV severity info. +func parseCVSS(v OSVVuln) float64 { + for _, sev := range v.Severity { + if sev.Type == "CVSS_V3" || sev.Type == "CVSS_V2" { + var score float64 + fmt.Sscanf(sev.Score, "%f", &score) + return score + } + } + return 0 +} + +// extractFixedVersion tries to extract the fixed version from affected ranges. +func extractFixedVersion(v OSVVuln) string { + for _, a := range v.Affected { + for _, r := range a.Ranges { + for _, e := range r.Events { + if e.Fixed != "" { + return e.Fixed + } + } + } + } + return "" +} + +func truncateStr(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/internal/models/migrations/001_init.go b/internal/models/migrations/001_init.go index 6a6f384..46b760a 100644 --- a/internal/models/migrations/001_init.go +++ b/internal/models/migrations/001_init.go @@ -58,5 +58,17 @@ func Run(engine *xorm.Engine) error { if err := Run015(engine); err != nil { return err } - return Run016(engine) + if err := Run016(engine); err != nil { + return err + } + if err := Run017(engine); err != nil { + return err + } + if err := Run018(engine); err != nil { + return err + } + if err := Run019(engine); err != nil { + return err + } + return Run020(engine) } diff --git a/internal/models/migrations/017_oci.go b/internal/models/migrations/017_oci.go new file mode 100644 index 0000000..c409497 --- /dev/null +++ b/internal/models/migrations/017_oci.go @@ -0,0 +1,16 @@ +package migrations + +import ( + "github.com/forgeo/forgebucket/internal/models" + "xorm.io/xorm" +) + +func Run017(engine *xorm.Engine) error { + return engine.Sync2( + &models.OCIRepository{}, + &models.OCIManifest{}, + &models.OCITag{}, + &models.OCIBlob{}, + &models.OCIUpload{}, + ) +} diff --git a/internal/models/migrations/018_scanning.go b/internal/models/migrations/018_scanning.go new file mode 100644 index 0000000..5c0a8b9 --- /dev/null +++ b/internal/models/migrations/018_scanning.go @@ -0,0 +1,10 @@ +package migrations + +import ( + "github.com/forgeo/forgebucket/internal/models" + "xorm.io/xorm" +) + +func Run018(engine *xorm.Engine) error { + return engine.Sync2(&models.SecretLeak{}) +} diff --git a/internal/models/migrations/019_vulnscan.go b/internal/models/migrations/019_vulnscan.go new file mode 100644 index 0000000..58b9ac4 --- /dev/null +++ b/internal/models/migrations/019_vulnscan.go @@ -0,0 +1,10 @@ +package migrations + +import ( + "github.com/forgeo/forgebucket/internal/models" + "xorm.io/xorm" +) + +func Run019(engine *xorm.Engine) error { + return engine.Sync2(&models.VulnerabilityFinding{}) +} diff --git a/internal/models/migrations/020_forgefed.go b/internal/models/migrations/020_forgefed.go new file mode 100644 index 0000000..5042492 --- /dev/null +++ b/internal/models/migrations/020_forgefed.go @@ -0,0 +1,13 @@ +package migrations + +import ( + "github.com/forgeo/forgebucket/internal/models" + "xorm.io/xorm" +) + +func Run020(engine *xorm.Engine) error { + if err := engine.Sync2(&models.Repository{}); err != nil { + return err + } + return engine.Sync2(&models.PullRequest{}) +} diff --git a/internal/models/oci.go b/internal/models/oci.go new file mode 100644 index 0000000..12685cd --- /dev/null +++ b/internal/models/oci.go @@ -0,0 +1,53 @@ +package models + +import "time" + +// OCIRepository represents a named image repository within the registry. +// Name mirrors the OCI distribution spec "name" component, e.g. "alice/myapp". +type OCIRepository struct { + ID int64 `xorm:"'id' pk autoincr" json:"id"` + RepoID int64 `xorm:"'repo_id' notnull index" json:"repoId"` // FK to Repository (git repo that owns this image) + Name string `xorm:"'name' varchar(255) unique" json:"name"` // e.g. "alice/myapp" + CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"` +} + +// OCIManifest stores a pushed image manifest (OCI or Docker schema2). +// The full manifest JSON is stored in Content so it can be streamed without +// going to disk. Manifests are small (typically <4 KB). +type OCIManifest struct { + ID int64 `xorm:"'id' pk autoincr" json:"id"` + OCIRepoID int64 `xorm:"'oci_repo_id' notnull index" json:"ociRepoId"` + Digest string `xorm:"'digest' varchar(80) notnull" json:"digest"` // "sha256:" + MediaType string `xorm:"'media_type' varchar(150)" json:"mediaType"` + Size int64 `xorm:"'size'" json:"size"` + Content string `xorm:"'content' text" json:"-"` // raw JSON + PushedAt time.Time `xorm:"'pushed_at' created" json:"pushedAt"` +} + +// OCITag maps a mutable tag (e.g. "latest", "v1.2.3") to a manifest digest. +type OCITag struct { + ID int64 `xorm:"'id' pk autoincr" json:"id"` + OCIRepoID int64 `xorm:"'oci_repo_id' notnull index" json:"ociRepoId"` + Name string `xorm:"'name' varchar(128)" json:"name"` + Digest string `xorm:"'digest' varchar(80)" json:"digest"` + UpdatedAt time.Time `xorm:"'updated_at' updated" json:"updatedAt"` +} + +// OCIBlob tracks a content-addressable blob. The actual content lives at +// {oci_root}/blobs/sha256/ on the filesystem. +type OCIBlob struct { + ID int64 `xorm:"'id' pk autoincr" json:"id"` + Digest string `xorm:"'digest' varchar(80) unique" json:"digest"` + Size int64 `xorm:"'size'" json:"size"` + CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"` +} + +// OCIUpload tracks an in-progress blob upload session. +type OCIUpload struct { + ID int64 `xorm:"'id' pk autoincr" json:"id"` + UploadID string `xorm:"'upload_id' varchar(64) unique" json:"uploadId"` // UUID used in URL + Name string `xorm:"'name' varchar(255)" json:"name"` // image name + Offset int64 `xorm:"'offset'" json:"offset"` + ExpiresAt time.Time `xorm:"'expires_at'" json:"expiresAt"` + CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"` +} diff --git a/internal/models/pr.go b/internal/models/pr.go index cba4da2..cb50630 100644 --- a/internal/models/pr.go +++ b/internal/models/pr.go @@ -19,6 +19,7 @@ type PullRequest struct { SourceBranch string `xorm:"'source_branch' notnull varchar(255)" json:"sourceBranch"` TargetBranch string `xorm:"'target_branch' default 'main' varchar(255)" json:"targetBranch"` Status PRStatus `xorm:"'status' default 'open' varchar(16)" json:"status"` + RemoteSource string `xorm:"'remote_source' varchar(500)" json:"remoteSource,omitempty"` // APID of remote fork repo (cross-instance) CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"` UpdatedAt time.Time `xorm:"'updated_at' updated" json:"updatedAt"` } diff --git a/internal/models/repo.go b/internal/models/repo.go index 5d8e256..55e7f31 100644 --- a/internal/models/repo.go +++ b/internal/models/repo.go @@ -11,6 +11,7 @@ type Repository struct { IsPrivate bool `xorm:"'is_private' default false" json:"isPrivate"` DefaultBranch string `xorm:"'default_branch' default 'main' varchar(255)" json:"defaultBranch"` DiskPath string `xorm:"'disk_path' notnull" json:"-"` + ForkedFrom string `xorm:"'forked_from' varchar(500)" json:"forkedFrom,omitempty"` // APID of the upstream repo CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"` UpdatedAt time.Time `xorm:"'updated_at' updated" json:"updatedAt"` } diff --git a/internal/models/scanning.go b/internal/models/scanning.go new file mode 100644 index 0000000..5a026c2 --- /dev/null +++ b/internal/models/scanning.go @@ -0,0 +1,20 @@ +package models + +import "time" + +// SecretLeak records a detected secret pattern in a pushed commit. +// When a match is confirmed not to be a real secret, set Dismissed=true. +type SecretLeak struct { + ID int64 `xorm:"'id' pk autoincr" json:"id"` + RepoID int64 `xorm:"'repo_id' notnull index" json:"repoId"` + CommitSHA string `xorm:"'commit_sha' varchar(12)" json:"commitSha"` + Ref string `xorm:"'ref' varchar(255)" json:"ref"` + PatternName string `xorm:"'pattern_name' varchar(50)" json:"patternName"` + Description string `xorm:"'description' varchar(200)" json:"description"` + Severity string `xorm:"'severity' varchar(10)" json:"severity"` + MatchSample string `xorm:"'match_sample' varchar(60)" json:"matchSample"` + Dismissed bool `xorm:"'dismissed'" json:"dismissed"` + DismissedBy string `xorm:"'dismissed_by' varchar(100)" json:"dismissedBy,omitempty"` + DismissedAt *time.Time `xorm:"'dismissed_at'" json:"dismissedAt,omitempty"` + DetectedAt time.Time `xorm:"'detected_at'" json:"detectedAt"` +} diff --git a/internal/models/vulnscan.go b/internal/models/vulnscan.go new file mode 100644 index 0000000..049eaa0 --- /dev/null +++ b/internal/models/vulnscan.go @@ -0,0 +1,20 @@ +package models + +import "time" + +// VulnerabilityFinding records a known vulnerability found in a dependency. +type VulnerabilityFinding struct { + ID int64 `xorm:"'id' pk autoincr" json:"id"` + RepoID int64 `xorm:"'repo_id' notnull index" json:"repoId"` + VulnID string `xorm:"'vuln_id' varchar(50)" json:"vulnId"` // e.g. "GHSA-xxxx" or "CVE-2024-..." + PURL string `xorm:"'purl' varchar(255)" json:"purl"` // package URL + Version string `xorm:"'version' varchar(100)" json:"version"` // affected version + Summary string `xorm:"'summary' varchar(500)" json:"summary"` + Details string `xorm:"'details' text" json:"details,omitempty"` + CVSSScore float64 `xorm:"'cvss_score'" json:"cvssScore"` + FixedVersion string `xorm:"'fixed_version' varchar(100)" json:"fixedVersion"` + Dismissed bool `xorm:"'dismissed'" json:"dismissed"` + DismissedBy string `xorm:"'dismissed_by' varchar(100)" json:"dismissedBy,omitempty"` + DismissedAt *time.Time `xorm:"'dismissed_at'" json:"dismissedAt,omitempty"` + DetectedAt time.Time `xorm:"'detected_at'" json:"detectedAt"` +}