Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/env-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
70 changes: 70 additions & 0 deletions docs/github-marketplace.md
Original file line number Diff line number Diff line change
@@ -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=<the listing 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:

```
<workdir>/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.
132 changes: 132 additions & 0 deletions internal/api/handlers_marketplace.go
Original file line number Diff line number Diff line change
@@ -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,
})
}
154 changes: 154 additions & 0 deletions internal/api/handlers_marketplace_test.go
Original file line number Diff line number Diff line change
@@ -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", http.NoBody)
rec := httptest.NewRecorder()
s.handleGitHubMarketplaceWebhook(rec, req)

if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want 405", rec.Code)
}
}
12 changes: 12 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" //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

headerAPIKey = "X-API-Key"
headerIdentityToken = "X-API-Identity-Token"
Expand Down Expand Up @@ -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())
}
Expand Down
Loading
Loading