From 0379627180c85711661aa2fdea0409f636e24103 Mon Sep 17 00:00:00 2001 From: ErenAri Date: Sun, 28 Jun 2026 18:58:53 +0300 Subject: [PATCH 1/2] feat(api): GitHub Marketplace purchase webhook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a signature-verified webhook endpoint for the bpfcompat GitHub Marketplace listing at POST /github/marketplace/webhook (api.kernelguard.net/github/marketplace/webhook). - internal/marketplace: transport-agnostic domain package — HMAC-SHA256 signature verification (constant-time), marketplace_purchase parsing, and a durable append-only JSONL ledger (0600; raw payload can carry a billing email). Close errors surfaced so a failed flush isn't dropped. - internal/api/handlers_marketplace.go: thin handler with an explicit status contract — 503 if the secret is unset (never runs open), 401 on bad/missing signature (coarse message, no oracle), 400 malformed, 200 ping/recorded/ ignored, 500 on persistence failure so GitHub redelivers. - Auth is the delivery signature, not API key/JWT (GitHub can't present those); route registered at top-level, outside /api. - New env knob BPFCOMPAT_GITHUB_MARKETPLACE_WEBHOOK_SECRET cataloged in internal/envref (+ regenerated docs/env-reference.md). - docs/github-marketplace.md: operator setup + response contract. - Tests: signature (valid/missing/tampered/wrong-secret), parse (known/unknown/ missing action), ledger append/perms, and full handler matrix. Scope is ingestion + durable recording; turning a purchase into an entitlement is a deliberate follow-up that consumes the ledger. Co-Authored-By: Claude Opus 4.8 --- docs/env-reference.md | 6 + docs/github-marketplace.md | 70 ++++++++ internal/api/handlers_marketplace.go | 132 ++++++++++++++ internal/api/handlers_marketplace_test.go | 154 ++++++++++++++++ internal/api/server.go | 12 ++ internal/envref/envref.go | 7 + internal/marketplace/marketplace.go | 208 ++++++++++++++++++++++ internal/marketplace/marketplace_test.go | 159 +++++++++++++++++ 8 files changed, 748 insertions(+) create mode 100644 docs/github-marketplace.md create mode 100644 internal/api/handlers_marketplace.go create mode 100644 internal/api/handlers_marketplace_test.go create mode 100644 internal/marketplace/marketplace.go create mode 100644 internal/marketplace/marketplace_test.go diff --git a/docs/env-reference.md b/docs/env-reference.md index b657bb2..1a5c2d0 100644 --- a/docs/env-reference.md +++ b/docs/env-reference.md @@ -65,6 +65,12 @@ Generated by `bpfcompat env --markdown`. Do not edit by hand. | `BPFCOMPAT_RUNTIME_DECISIONS_MAX_BYTES` | 67108864 | runtime_decisions.jsonl rotation cap. | | `BPFCOMPAT_RUNTIME_DECISIONS_MAX_FILES` | 10 | runtime_decisions retention. | +## GitHub Marketplace + +| Variable | Default | Description | +|---|---|---| +| `BPFCOMPAT_GITHUB_MARKETPLACE_WEBHOOK_SECRET` | _(unset)_ | Shared secret for the GitHub Marketplace listing webhook. Deliveries to /github/marketplace/webhook are authenticated by their HMAC-SHA256 signature against this value. When unset, the endpoint returns 503 (never runs open). | + ## HTTP Server | Variable | Default | Description | diff --git a/docs/github-marketplace.md b/docs/github-marketplace.md new file mode 100644 index 0000000..5ada0dd --- /dev/null +++ b/docs/github-marketplace.md @@ -0,0 +1,70 @@ +# GitHub Marketplace webhook + +The API server exposes a webhook endpoint for the bpfcompat GitHub Marketplace +listing. It receives `marketplace_purchase` events (purchase, change, cancel, +free-trial) and appends each verified delivery to a durable ledger for later +reconciliation and provisioning. + +## Endpoint + +``` +POST https://api.kernelguard.net/github/marketplace/webhook +``` + +- **Authentication:** the delivery's HMAC-SHA256 signature + (`X-Hub-Signature-256`) — *not* an API key. GitHub cannot present the API + key/JWT used by the rest of the write surface, so the signature is the sole + authenticator. +- **Content type:** `application/json` (the signature is computed over the raw + JSON body; the `application/x-www-form-urlencoded` form is not supported). + +## Configuration + +Set the shared secret on the server to the exact value configured on the +listing's webhook: + +``` +BPFCOMPAT_GITHUB_MARKETPLACE_WEBHOOK_SECRET= +``` + +When this is unset the endpoint returns **503** and records nothing — it never +runs open. + +On the GitHub side (listing → *Webhook*): +- **Payload URL:** `https://api.kernelguard.net/github/marketplace/webhook` +- **Secret:** the same value as the env var above +- **SSL verification:** enabled + +## Response contract + +| Status | When | +|---|---| +| `200` | ping, a recorded `marketplace_purchase`, or an acknowledged non-purchase event | +| `400` | malformed payload, or missing `X-GitHub-Event` header | +| `401` | missing or invalid signature | +| `405` | non-POST method | +| `413` | body exceeds 1 MiB | +| `500` | failed to persist the event — GitHub will redeliver | +| `503` | webhook secret not configured | + +A `500` on persistence is deliberate: GitHub retries failed deliveries, so a +transient disk error doesn't silently drop a paid purchase event. + +## Ledger + +Verified events are appended as JSON Lines to: + +``` +/marketplace/events.jsonl +``` + +The file is `0600` (the raw payload can include an organization billing email). +Each line carries a flat summary (action, account, plan, billing cycle) plus the +raw payload under `raw` so nothing is lost. + +## What this does *not* do (yet) + +This endpoint **ingests and records** purchase events. Turning a purchase into +an entitlement (granting access, emailing the buyer, updating a plan) is a +separate step that consumes the ledger — intentionally decoupled so ingestion +stays simple, idempotent-friendly, and independently testable. diff --git a/internal/api/handlers_marketplace.go b/internal/api/handlers_marketplace.go new file mode 100644 index 0000000..a1be64b --- /dev/null +++ b/internal/api/handlers_marketplace.go @@ -0,0 +1,132 @@ +package api + +import ( + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/kernel-guard/bpfcompat/internal/marketplace" +) + +// handleGitHubMarketplaceWebhook receives GitHub Marketplace purchase events +// for the bpfcompat listing. It is NOT behind the API-key/JWT auth used by the +// rest of the write surface — GitHub can't present those — so the delivery's +// HMAC-SHA256 signature is the sole authenticator. Verified events are appended +// to a durable ledger; provisioning/entitlement is a downstream step that reads +// that ledger. +// +// Status contract (chosen so GitHub redelivers only when it should): +// - 405 wrong method +// - 503 webhook secret not configured (endpoint refuses to run open) +// - 401 missing/invalid signature +// - 400 malformed payload / missing event header +// - 200 ping, recorded purchase, or acknowledged-but-ignored other event +// - 500 persistence failure (GitHub will redeliver) +func (s *Server) handleGitHubMarketplaceWebhook(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + secret := strings.TrimSpace(os.Getenv(envGitHubMarketplaceWebhookSecret)) + if secret == "" { + writeError(w, http.StatusServiceUnavailable, + fmt.Sprintf("github marketplace webhook is not configured; set %s", envGitHubMarketplaceWebhookSecret)) + return + } + + // Read the raw body — the signature is computed over the exact bytes GitHub + // sent, so verify before any re-encoding. MaxBytesReader bounds a hostile + // sender. + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxMarketplaceWebhookBytes)) + if err != nil { + writeError(w, http.StatusRequestEntityTooLarge, "request body too large or unreadable") + return + } + + deliveryID := strings.TrimSpace(r.Header.Get("X-GitHub-Delivery")) + eventType := strings.TrimSpace(r.Header.Get("X-GitHub-Event")) + + if err := marketplace.VerifySignature(secret, r.Header.Get("X-Hub-Signature-256"), body); err != nil { + // Log the precise reason server-side; return a coarse message so the + // response can't be used as a signature oracle. + s.log().Warn("rejected github marketplace webhook", + slog.String("reason", err.Error()), + slog.String("delivery", deliveryID), + slog.String("event", eventType), + ) + if errors.Is(err, marketplace.ErrSecretNotConfigured) { + writeError(w, http.StatusServiceUnavailable, "github marketplace webhook is not configured") + return + } + writeError(w, http.StatusUnauthorized, "invalid webhook signature") + return + } + + switch eventType { + case "": + writeError(w, http.StatusBadRequest, "missing X-GitHub-Event header") + return + case "ping": + // GitHub sends a ping when the webhook is first configured. + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "pong": true}) + return + case marketplace.EventType: + // fall through to purchase handling + default: + // Acknowledge unrelated events with 200 so GitHub doesn't retry them. + s.log().Info("ignoring non-purchase github marketplace delivery", + slog.String("event", eventType), + slog.String("delivery", deliveryID), + ) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "ignored_event": eventType}) + return + } + + event, err := marketplace.ParseEvent(body) + if err != nil { + s.log().Warn("malformed github marketplace payload", + slog.String("error", err.Error()), + slog.String("delivery", deliveryID), + ) + writeError(w, http.StatusBadRequest, fmt.Sprintf("parse marketplace event: %v", err)) + return + } + + rec := marketplace.NewLedgerRecord(event, deliveryID, time.Now().UTC().Format(time.RFC3339), body) + path, err := marketplace.AppendLedger(s.cfg.WorkDir, rec) + if err != nil { + // Return 500 so GitHub redelivers rather than silently dropping a paid + // purchase event. + s.log().Error("persist github marketplace event failed", + slog.String("error", err.Error()), + slog.String("delivery", deliveryID), + slog.String("action", event.Action), + ) + writeError(w, http.StatusInternalServerError, "failed to record marketplace event") + return + } + + s.log().Info("github marketplace event recorded", + slog.String("action", event.Action), + slog.Bool("action_known", marketplace.KnownActions[event.Action]), + slog.String("delivery", deliveryID), + slog.String("account", event.MarketplacePurchase.Account.Login), + slog.Int64("account_id", event.MarketplacePurchase.Account.ID), + slog.String("plan", event.MarketplacePurchase.Plan.Name), + slog.String("ledger", filepath.Base(path)), + ) + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "action": event.Action, + "account": event.MarketplacePurchase.Account.Login, + "plan": event.MarketplacePurchase.Plan.Name, + }) +} diff --git a/internal/api/handlers_marketplace_test.go b/internal/api/handlers_marketplace_test.go new file mode 100644 index 0000000..aad5314 --- /dev/null +++ b/internal/api/handlers_marketplace_test.go @@ -0,0 +1,154 @@ +package api + +import ( + "bufio" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/kernel-guard/bpfcompat/internal/marketplace" +) + +const testMarketplaceBody = `{ + "action": "purchased", + "sender": {"login": "octocat", "id": 1, "type": "User"}, + "marketplace_purchase": { + "account": {"type": "Organization", "id": 18, "login": "acme"}, + "billing_cycle": "monthly", + "unit_count": 1, + "plan": {"id": 9, "name": "Team", "monthly_price_in_cents": 4900} + } +}` + +func signMarketplace(secret, body string) string { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(body)) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +} + +func marketplaceRequest(body, sig, event string) *http.Request { + req := httptest.NewRequest(http.MethodPost, "/github/marketplace/webhook", strings.NewReader(body)) + if sig != "" { + req.Header.Set("X-Hub-Signature-256", sig) + } + if event != "" { + req.Header.Set("X-GitHub-Event", event) + } + req.Header.Set("X-GitHub-Delivery", "test-delivery-1") + return req +} + +func TestMarketplaceWebhookValidPurchaseRecorded(t *testing.T) { + dir := t.TempDir() + secret := "whsec" + t.Setenv(envGitHubMarketplaceWebhookSecret, secret) + s := &Server{cfg: Config{WorkDir: dir}} + + rec := httptest.NewRecorder() + s.handleGitHubMarketplaceWebhook(rec, marketplaceRequest(testMarketplaceBody, signMarketplace(secret, testMarketplaceBody), "marketplace_purchase")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + // Ledger written with the parsed summary. + path := marketplace.LedgerPath(dir) + f, err := os.Open(path) //nolint:gosec // test temp path + if err != nil { + t.Fatalf("ledger not written: %v", err) + } + defer f.Close() + sc := bufio.NewScanner(f) + if !sc.Scan() { + t.Fatalf("ledger is empty") + } + var got marketplace.LedgerRecord + if err := json.Unmarshal(sc.Bytes(), &got); err != nil { + t.Fatalf("ledger line not JSON: %v", err) + } + if got.Action != "purchased" || got.AccountLogin != "acme" || got.PlanName != "Team" { + t.Fatalf("ledger record wrong: %+v", got) + } +} + +func TestMarketplaceWebhookRejectsBadSignature(t *testing.T) { + t.Setenv(envGitHubMarketplaceWebhookSecret, "whsec") + s := &Server{cfg: Config{WorkDir: t.TempDir()}} + + rec := httptest.NewRecorder() + s.handleGitHubMarketplaceWebhook(rec, marketplaceRequest(testMarketplaceBody, signMarketplace("wrong", testMarketplaceBody), "marketplace_purchase")) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + if _, err := os.Stat(marketplace.LedgerPath(s.cfg.WorkDir)); !os.IsNotExist(err) { + t.Fatalf("ledger must not be written on bad signature") + } +} + +func TestMarketplaceWebhookSecretNotConfigured(t *testing.T) { + t.Setenv(envGitHubMarketplaceWebhookSecret, "") + s := &Server{cfg: Config{WorkDir: t.TempDir()}} + + rec := httptest.NewRecorder() + s.handleGitHubMarketplaceWebhook(rec, marketplaceRequest(testMarketplaceBody, "sha256=deadbeef", "marketplace_purchase")) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", rec.Code) + } +} + +func TestMarketplaceWebhookPing(t *testing.T) { + secret := "whsec" + t.Setenv(envGitHubMarketplaceWebhookSecret, secret) + s := &Server{cfg: Config{WorkDir: t.TempDir()}} + + body := `{"zen":"Keep it simple."}` + rec := httptest.NewRecorder() + s.handleGitHubMarketplaceWebhook(rec, marketplaceRequest(body, signMarketplace(secret, body), "ping")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 for ping", rec.Code) + } + if !strings.Contains(rec.Body.String(), "pong") { + t.Fatalf("ping response missing pong: %s", rec.Body.String()) + } +} + +func TestMarketplaceWebhookIgnoresOtherEvents(t *testing.T) { + secret := "whsec" + t.Setenv(envGitHubMarketplaceWebhookSecret, secret) + s := &Server{cfg: Config{WorkDir: t.TempDir()}} + + body := `{"action":"opened"}` + rec := httptest.NewRecorder() + s.handleGitHubMarketplaceWebhook(rec, marketplaceRequest(body, signMarketplace(secret, body), "issues")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 for ignored event", rec.Code) + } + if !strings.Contains(rec.Body.String(), "ignored_event") { + t.Fatalf("expected ignored_event ack: %s", rec.Body.String()) + } + if _, err := os.Stat(marketplace.LedgerPath(s.cfg.WorkDir)); !os.IsNotExist(err) { + t.Fatalf("ledger must not be written for ignored events") + } +} + +func TestMarketplaceWebhookMethodNotAllowed(t *testing.T) { + t.Setenv(envGitHubMarketplaceWebhookSecret, "whsec") + s := &Server{cfg: Config{WorkDir: t.TempDir()}} + + req := httptest.NewRequest(http.MethodGet, "/github/marketplace/webhook", nil) + rec := httptest.NewRecorder() + s.handleGitHubMarketplaceWebhook(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want 405", rec.Code) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index b86011e..8bdce69 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -112,6 +112,14 @@ const ( envSourceCompileTimeout = "BPFCOMPAT_API_SOURCE_COMPILE_TIMEOUT" envSourceCompileAllowExtraFlags = "BPFCOMPAT_API_SOURCE_COMPILE_ALLOW_EXTRA_FLAGS" envAllowAnonymousRead = "BPFCOMPAT_API_ALLOW_ANONYMOUS_READ" + // envGitHubMarketplaceWebhookSecret holds the shared secret configured on + // the GitHub Marketplace listing's webhook. Deliveries are authenticated by + // their HMAC-SHA256 signature against this secret; when unset the endpoint + // returns 503 (it can never be left open without a secret). + envGitHubMarketplaceWebhookSecret = "BPFCOMPAT_GITHUB_MARKETPLACE_WEBHOOK_SECRET" + // maxMarketplaceWebhookBytes caps the webhook body. Marketplace payloads + // are a few KiB; 1 MiB is a generous hard ceiling against a hostile sender. + maxMarketplaceWebhookBytes = 1 << 20 headerAPIKey = "X-API-Key" headerIdentityToken = "X-API-Identity-Token" @@ -526,6 +534,10 @@ func (s *Server) serve(ctx context.Context) error { mux.HandleFunc("/results", s.handleDemoResult) mux.HandleFunc("/demo-result", s.handleDemoResult) mux.HandleFunc("/badge/", s.handleBadge) + // GitHub Marketplace purchase webhook. Lives at a top-level path (not under + // /api) to match the URL configured on the Marketplace listing, and is + // authenticated by the delivery's HMAC signature rather than an API key. + mux.HandleFunc("/github/marketplace/webhook", s.handleGitHubMarketplaceWebhook) if metricsEnabled() { mux.Handle("/metrics", s.handleMetrics()) } diff --git a/internal/envref/envref.go b/internal/envref/envref.go index b05f6ce..1c644dd 100644 --- a/internal/envref/envref.go +++ b/internal/envref/envref.go @@ -471,6 +471,13 @@ var catalog = []Var{ Description: "Allow file:// artifact URIs. Off by default; enable only for trusted on-host caches.", }, + // ---------- GitHub Marketplace ---------- + { + Name: "BPFCOMPAT_GITHUB_MARKETPLACE_WEBHOOK_SECRET", Default: "", + Category: "GitHub Marketplace", + Description: "Shared secret for the GitHub Marketplace listing webhook. Deliveries to /github/marketplace/webhook are authenticated by their HMAC-SHA256 signature against this value. When unset, the endpoint returns 503 (never runs open).", + }, + // ---------- Signing ---------- { Name: "BPFCOMPAT_SIGNING_MODE", Default: "local", diff --git a/internal/marketplace/marketplace.go b/internal/marketplace/marketplace.go new file mode 100644 index 0000000..c039feb --- /dev/null +++ b/internal/marketplace/marketplace.go @@ -0,0 +1,208 @@ +// Package marketplace handles GitHub Marketplace webhook events +// (marketplace_purchase) for the bpfcompat listing: verifying the delivery +// signature, parsing the payload, and persisting each event to a durable +// append-only ledger for later reconciliation. +// +// This package is transport-agnostic on purpose — signature verification takes +// the raw bytes + header value, and persistence takes a workDir — so the HTTP +// handler in internal/api stays thin and the logic is unit-testable without a +// server. Entitlement/provisioning (what a purchase *grants*) is intentionally +// out of scope here: the ledger is the source of truth a later step consumes. +package marketplace + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// EventType is the value GitHub sends in the X-GitHub-Event header for a +// Marketplace purchase event. +const EventType = "marketplace_purchase" + +// signaturePrefix is the algorithm tag GitHub prepends to the hex digest in +// the X-Hub-Signature-256 header. +const signaturePrefix = "sha256=" + +var ( + // ErrSecretNotConfigured indicates no webhook secret is set, so no + // delivery can be trusted. The handler maps this to 503. + ErrSecretNotConfigured = errors.New("marketplace webhook secret is not configured") + // ErrInvalidSignature covers a missing, malformed, or mismatched + // signature. The handler maps this to 401. The message is deliberately + // coarse so it cannot be used as a verification oracle. + ErrInvalidSignature = errors.New("invalid webhook signature") + // ErrInvalidPayload indicates the body is not a well-formed event. + ErrInvalidPayload = errors.New("invalid marketplace payload") +) + +// KnownActions are the marketplace_purchase actions GitHub documents. Unknown +// actions are still recorded (GitHub may add more) but callers can use this to +// decide whether to act on one. +var KnownActions = map[string]bool{ + "purchased": true, + "changed": true, + "cancelled": true, + "pending_change": true, + "pending_change_cancelled": true, +} + +// Account is the buyer (org or user) attached to a purchase, plus the GitHub +// account that triggered the event (sender). +type Account struct { + Type string `json:"type"` + ID int64 `json:"id"` + Login string `json:"login"` + OrganizationBillingEmail string `json:"organization_billing_email,omitempty"` +} + +// Plan is the Marketplace listing plan the buyer is on. +type Plan struct { + ID int64 `json:"id"` + Name string `json:"name"` + MonthlyPriceInCents int64 `json:"monthly_price_in_cents"` + YearlyPriceInCents int64 `json:"yearly_price_in_cents"` + PriceModel string `json:"price_model"` +} + +// Purchase is the marketplace_purchase object (and previous_* on changes). +type Purchase struct { + Account Account `json:"account"` + BillingCycle string `json:"billing_cycle"` + UnitCount int64 `json:"unit_count"` + OnFreeTrial bool `json:"on_free_trial"` + FreeTrialEndsOn string `json:"free_trial_ends_on,omitempty"` + NextBillingDate string `json:"next_billing_date,omitempty"` + Plan Plan `json:"plan"` +} + +// Event is a parsed marketplace_purchase webhook payload. +type Event struct { + Action string `json:"action"` + EffectiveDate string `json:"effective_date"` + Sender Account `json:"sender"` + MarketplacePurchase Purchase `json:"marketplace_purchase"` + PreviousMarketplacePurchase *Purchase `json:"previous_marketplace_purchase,omitempty"` +} + +// VerifySignature checks the X-Hub-Signature-256 header value against an +// HMAC-SHA256 of the exact request body using the shared secret. It is +// constant-time and returns a coarse error so it cannot be used as an oracle. +// +// The body MUST be the raw bytes GitHub sent — verify before any re-encoding. +func VerifySignature(secret, signatureHeader string, body []byte) error { + if strings.TrimSpace(secret) == "" { + return ErrSecretNotConfigured + } + sig := strings.TrimSpace(signatureHeader) + if !strings.HasPrefix(sig, signaturePrefix) { + return fmt.Errorf("%w: missing %q prefix", ErrInvalidSignature, signaturePrefix) + } + want, err := hex.DecodeString(strings.TrimPrefix(sig, signaturePrefix)) + if err != nil { + return fmt.Errorf("%w: signature is not valid hex", ErrInvalidSignature) + } + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write(body) + got := mac.Sum(nil) + if subtle.ConstantTimeCompare(got, want) != 1 { + return ErrInvalidSignature + } + return nil +} + +// ParseEvent decodes a marketplace_purchase payload and validates the minimum +// shape (an action must be present). Unknown actions are accepted; callers can +// consult KnownActions. +func ParseEvent(body []byte) (Event, error) { + // Decode leniently: GitHub's payload carries many fields we don't model and + // adds more over time, so we must NOT DisallowUnknownFields here. + var event Event + if err := json.Unmarshal(body, &event); err != nil { + return Event{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + } + if strings.TrimSpace(event.Action) == "" { + return Event{}, fmt.Errorf("%w: missing action", ErrInvalidPayload) + } + return event, nil +} + +// LedgerRecord is one line in the append-only events ledger: a flat summary +// for quick scanning plus the raw payload so nothing is lost for reconciliation. +type LedgerRecord struct { + ReceivedAt string `json:"received_at"` + DeliveryID string `json:"delivery_id,omitempty"` + EventType string `json:"event_type"` + Action string `json:"action"` + ActionKnown bool `json:"action_known"` + AccountLogin string `json:"account_login,omitempty"` + AccountID int64 `json:"account_id,omitempty"` + AccountType string `json:"account_type,omitempty"` + PlanID int64 `json:"plan_id,omitempty"` + PlanName string `json:"plan_name,omitempty"` + BillingCycle string `json:"billing_cycle,omitempty"` + UnitCount int64 `json:"unit_count,omitempty"` + OnFreeTrial bool `json:"on_free_trial,omitempty"` + Raw json.RawMessage `json:"raw,omitempty"` +} + +// NewLedgerRecord builds a record from a parsed event, the delivery id, the +// raw payload, and a receive timestamp (RFC3339, passed in so callers control +// the clock and the function stays deterministic in tests). +func NewLedgerRecord(event Event, deliveryID, receivedAt string, raw []byte) LedgerRecord { + return LedgerRecord{ + ReceivedAt: receivedAt, + DeliveryID: strings.TrimSpace(deliveryID), + EventType: EventType, + Action: event.Action, + ActionKnown: KnownActions[event.Action], + AccountLogin: event.MarketplacePurchase.Account.Login, + AccountID: event.MarketplacePurchase.Account.ID, + AccountType: event.MarketplacePurchase.Account.Type, + PlanID: event.MarketplacePurchase.Plan.ID, + PlanName: event.MarketplacePurchase.Plan.Name, + BillingCycle: event.MarketplacePurchase.BillingCycle, + UnitCount: event.MarketplacePurchase.UnitCount, + OnFreeTrial: event.MarketplacePurchase.OnFreeTrial, + Raw: append(json.RawMessage(nil), raw...), + } +} + +// LedgerPath returns the JSONL ledger path under workDir. +func LedgerPath(workDir string) string { + return filepath.Join(filepath.Clean(workDir), "marketplace", "events.jsonl") +} + +// AppendLedger appends one record to the events ledger, creating the directory +// as needed. It surfaces Close errors so a failed flush isn't silently lost +// (the handler returns 5xx so GitHub redelivers). Returns the ledger path. +func AppendLedger(workDir string, rec LedgerRecord) (string, error) { + path := LedgerPath(workDir) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", fmt.Errorf("create marketplace ledger directory: %w", err) + } + line, err := json.Marshal(rec) + if err != nil { + return "", fmt.Errorf("marshal marketplace ledger record: %w", err) + } + // 0o600: the raw payload can carry an org billing email; keep it owner-only. + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return "", fmt.Errorf("open marketplace ledger: %w", err) + } + if _, err := f.Write(append(line, '\n')); err != nil { + _ = f.Close() + return "", fmt.Errorf("append marketplace ledger record: %w", err) + } + if err := f.Close(); err != nil { + return "", fmt.Errorf("close marketplace ledger: %w", err) + } + return path, nil +} diff --git a/internal/marketplace/marketplace_test.go b/internal/marketplace/marketplace_test.go new file mode 100644 index 0000000..0b238ab --- /dev/null +++ b/internal/marketplace/marketplace_test.go @@ -0,0 +1,159 @@ +package marketplace + +import ( + "bufio" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" +) + +const samplePurchase = `{ + "action": "purchased", + "effective_date": "2026-06-28T00:00:00+00:00", + "sender": {"login": "octocat", "id": 1, "type": "User"}, + "marketplace_purchase": { + "account": {"type": "Organization", "id": 18, "login": "acme", "organization_billing_email": "billing@acme.example"}, + "billing_cycle": "monthly", + "unit_count": 1, + "on_free_trial": false, + "next_billing_date": "2026-07-28T00:00:00+00:00", + "plan": {"id": 9, "name": "Team", "monthly_price_in_cents": 4900, "yearly_price_in_cents": 49900, "price_model": "flat-rate"} + } +}` + +func sign(secret, body string) string { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(body)) + return signaturePrefix + hex.EncodeToString(mac.Sum(nil)) +} + +func TestVerifySignature(t *testing.T) { + secret := "s3cr3t" + body := []byte(samplePurchase) + good := sign(secret, samplePurchase) + + if err := VerifySignature(secret, good, body); err != nil { + t.Fatalf("valid signature rejected: %v", err) + } + + cases := []struct { + name string + sec string + sig string + body []byte + want error + }{ + {"no secret", "", good, body, ErrSecretNotConfigured}, + {"missing prefix", secret, hex.EncodeToString([]byte("x")), body, ErrInvalidSignature}, + {"not hex", secret, signaturePrefix + "zz", body, ErrInvalidSignature}, + {"wrong secret", "other", good, body, ErrInvalidSignature}, + {"tampered body", secret, good, []byte(samplePurchase + " "), ErrInvalidSignature}, + {"empty sig", secret, "", body, ErrInvalidSignature}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := VerifySignature(tc.sec, tc.sig, tc.body) + if !errors.Is(err, tc.want) { + t.Fatalf("got %v, want errors.Is %v", err, tc.want) + } + }) + } +} + +func TestParseEvent(t *testing.T) { + event, err := ParseEvent([]byte(samplePurchase)) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if event.Action != "purchased" { + t.Fatalf("action = %q, want purchased", event.Action) + } + if !KnownActions[event.Action] { + t.Fatalf("purchased should be a known action") + } + if got := event.MarketplacePurchase.Account.Login; got != "acme" { + t.Fatalf("account login = %q, want acme", got) + } + if got := event.MarketplacePurchase.Plan.Name; got != "Team" { + t.Fatalf("plan name = %q, want Team", got) + } + if got := event.MarketplacePurchase.Plan.MonthlyPriceInCents; got != 4900 { + t.Fatalf("monthly price = %d, want 4900", got) + } +} + +func TestParseEventRejectsMissingAction(t *testing.T) { + if _, err := ParseEvent([]byte(`{"marketplace_purchase":{}}`)); !errors.Is(err, ErrInvalidPayload) { + t.Fatalf("expected ErrInvalidPayload, got %v", err) + } + if _, err := ParseEvent([]byte(`not json`)); !errors.Is(err, ErrInvalidPayload) { + t.Fatalf("expected ErrInvalidPayload for bad json, got %v", err) + } +} + +func TestParseEventAcceptsUnknownAction(t *testing.T) { + event, err := ParseEvent([]byte(`{"action":"some_new_action","marketplace_purchase":{}}`)) + if err != nil { + t.Fatalf("unknown action should still parse: %v", err) + } + if KnownActions[event.Action] { + t.Fatalf("some_new_action should not be known") + } +} + +func TestAppendLedger(t *testing.T) { + dir := t.TempDir() + event, err := ParseEvent([]byte(samplePurchase)) + if err != nil { + t.Fatalf("parse: %v", err) + } + rec := NewLedgerRecord(event, "delivery-123", "2026-06-28T12:00:00Z", []byte(samplePurchase)) + + path, err := AppendLedger(dir, rec) + if err != nil { + t.Fatalf("append: %v", err) + } + if path != LedgerPath(dir) { + t.Fatalf("path = %q, want %q", path, LedgerPath(dir)) + } + + // Owner-only perms because the raw payload can carry a billing email. + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat ledger: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("ledger perm = %o, want 600", perm) + } + + // Append a second record and verify both lines are valid JSON. + if _, err := AppendLedger(dir, rec); err != nil { + t.Fatalf("second append: %v", err) + } + f, err := os.Open(path) //nolint:gosec // test-controlled temp path + if err != nil { + t.Fatalf("open ledger: %v", err) + } + defer f.Close() + lines := 0 + sc := bufio.NewScanner(f) + for sc.Scan() { + var got LedgerRecord + if err := json.Unmarshal(sc.Bytes(), &got); err != nil { + t.Fatalf("ledger line %d not valid JSON: %v", lines, err) + } + if got.DeliveryID != "delivery-123" || got.PlanName != "Team" || !got.ActionKnown { + t.Fatalf("ledger line %d fields wrong: %+v", lines, got) + } + lines++ + } + if lines != 2 { + t.Fatalf("ledger has %d lines, want 2", lines) + } + _ = filepath.Dir(path) +} From db4120eef71bf9452ee7b45202a2c5993f75e096 Mon Sep 17 00:00:00 2001 From: ErenAri Date: Sun, 28 Jun 2026 19:02:27 +0300 Subject: [PATCH 2/2] fix(lint): satisfy golangci-lint v2 on the marketplace webhook - errorlint: wrap the json error with %w (multi-error) in ParseEvent - gocritic httpNoBody: use http.NoBody in the method-not-allowed test - gosec G101 false positive: annotate the webhook-secret env-var NAME const (it's a key name, not a credential); G101 stays active elsewhere - drop an unused path/filepath import + dead line in the ledger test Co-Authored-By: Claude Opus 4.8 --- internal/api/handlers_marketplace_test.go | 2 +- internal/api/server.go | 2 +- internal/marketplace/marketplace.go | 2 +- internal/marketplace/marketplace_test.go | 2 -- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/internal/api/handlers_marketplace_test.go b/internal/api/handlers_marketplace_test.go index aad5314..1c853eb 100644 --- a/internal/api/handlers_marketplace_test.go +++ b/internal/api/handlers_marketplace_test.go @@ -144,7 +144,7 @@ func TestMarketplaceWebhookMethodNotAllowed(t *testing.T) { t.Setenv(envGitHubMarketplaceWebhookSecret, "whsec") s := &Server{cfg: Config{WorkDir: t.TempDir()}} - req := httptest.NewRequest(http.MethodGet, "/github/marketplace/webhook", nil) + req := httptest.NewRequest(http.MethodGet, "/github/marketplace/webhook", http.NoBody) rec := httptest.NewRecorder() s.handleGitHubMarketplaceWebhook(rec, req) diff --git a/internal/api/server.go b/internal/api/server.go index 8bdce69..41183c8 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -116,7 +116,7 @@ const ( // the GitHub Marketplace listing's webhook. Deliveries are authenticated by // their HMAC-SHA256 signature against this secret; when unset the endpoint // returns 503 (it can never be left open without a secret). - envGitHubMarketplaceWebhookSecret = "BPFCOMPAT_GITHUB_MARKETPLACE_WEBHOOK_SECRET" + envGitHubMarketplaceWebhookSecret = "BPFCOMPAT_GITHUB_MARKETPLACE_WEBHOOK_SECRET" //nolint:gosec // G101 false positive: this is the env-var NAME, not a secret value // maxMarketplaceWebhookBytes caps the webhook body. Marketplace payloads // are a few KiB; 1 MiB is a generous hard ceiling against a hostile sender. maxMarketplaceWebhookBytes = 1 << 20 diff --git a/internal/marketplace/marketplace.go b/internal/marketplace/marketplace.go index c039feb..6eb5f55 100644 --- a/internal/marketplace/marketplace.go +++ b/internal/marketplace/marketplace.go @@ -126,7 +126,7 @@ func ParseEvent(body []byte) (Event, error) { // adds more over time, so we must NOT DisallowUnknownFields here. var event Event if err := json.Unmarshal(body, &event); err != nil { - return Event{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err) + return Event{}, fmt.Errorf("%w: %w", ErrInvalidPayload, err) } if strings.TrimSpace(event.Action) == "" { return Event{}, fmt.Errorf("%w: missing action", ErrInvalidPayload) diff --git a/internal/marketplace/marketplace_test.go b/internal/marketplace/marketplace_test.go index 0b238ab..4e6dbf3 100644 --- a/internal/marketplace/marketplace_test.go +++ b/internal/marketplace/marketplace_test.go @@ -8,7 +8,6 @@ import ( "encoding/json" "errors" "os" - "path/filepath" "testing" ) @@ -155,5 +154,4 @@ func TestAppendLedger(t *testing.T) { if lines != 2 { t.Fatalf("ledger has %d lines, want 2", lines) } - _ = filepath.Dir(path) }