Files
o11y/pkg/modules/errortracking/implerrortracking/dsn.go
T
zeekay bc2c3be4a4 fix(errortracking): harden ingest per red review (HIGH-1/2, MED-1/2/3, LOW-1/2)
HIGH-1 amplification: cap events/envelope (1000); module.Ingest pre-aggregates
occurrences by fingerprint and store.UpsertIssues writes the whole batch in ONE
tx (a flood of identical events => one upsert, count+=N); per-org issue ceiling
(10000) drops NEW fingerprints past the cap; per-org token-bucket rate limit
(50/s burst 100) after DSN verify; retention/TTL sweep (DeleteStale) gated by
O11Y_ERRORTRACKING_RETENTION_DAYS (default 90).

MEDIUM-1: envelope item length is bounds-checked (>=0 && <= remaining) before it
is added to pos — a MaxInt64/negative length no longer overflows the slice bound;
falls back to newline framing.

MEDIUM-2: default-on secret redaction (sk-/AKIA/bearer/JWT/conn-string/PAN, always)
+ PII scrub (email/IP) unless O11Y_ERRORTRACKING_CAPTURE_PII=true (fail-secure,
mirrors llmobs capture-messages). Applied before the value enters the fingerprint.

MEDIUM-3: DSN keys are versioned '<v>:<hmac>' with a per-org revocation watermark
(o11y_ingest_revocations, wholesale-cached); rotate ONE org's min-version to revoke
only its below-min DSNs — no global secret roll.

LOW-1: lifecycle UpdateIssue is optimistic-concurrency guarded (version column,
WHERE version=expected, bump on write; stale => 409 conflict, not clobber). Ingest
never touches version.
LOW-2: orgFromContext uses NewUUID+error (no MustNewUUID panic on a bad claim).
INFO-1: corrected the occurrence-persistence claim + removed the dangling chsink.go
reference (only NoopSink exists; logs sink is an honest fast-follow).

Tests: 67 pass incl amplification-bounded (5000 events -> 1 upsert), event cap,
per-org ceiling, MaxInt64/negative length no-panic, rate-limit, secret/PII scrub,
versioned-key + isolated revocation, optimistic-update conflict, DeleteStale.
Crown-jewel isolation/parity/DSN tests unchanged and still green.
2026-07-10 14:54:25 -07:00

124 lines
4.6 KiB
Go

package implerrortracking
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"strings"
"github.com/google/uuid"
"github.com/hanzoai/o11y/pkg/valuer"
)
// The ingest endpoints authenticate with the Sentry-native DSN model: the caller
// presents a public key that proves it holds an org's ingest credential. We make
// that key STATELESS and KMS-backed rather than adding a per-key secret table:
//
// publicKey(org, v) = "<v>:" + hex(HMAC-SHA256(platformIngestSecret, "org:"+org+":v"+v))
//
// The platform secret comes from KMS (never plaintext, never committed). The key
// carries its VERSION so ONE org can be rotated in isolation: bump that org's
// min-version (RevocationStore.Rotate) and only its below-min DSNs stop verifying —
// no global secret roll. Verifying is a version check + a constant-time compare.
//
// The org travels in the DSN project segment; the key proves the caller may write
// to THAT org at THAT version. Resolution reuses iamidentn's exact UUIDv5 mapping so
// the row written here is read back by exactly that tenant.
const defaultKeyVersion = 1
// orgUUIDFromProject maps a DSN project segment to the o11y org UUID. It mirrors
// iamidentn.toUUID("org", …) BYTE-FOR-BYTE (a raw UUID passes through; a slug is
// UUIDv5 over the URL namespace with the "hanzo:o11y:org:" prefix) so ingest and
// the IAM read path resolve the SAME tenant id.
func orgUUIDFromProject(project string) (valuer.UUID, bool) {
project = strings.TrimSpace(project)
if project == "" {
return valuer.UUID{}, false
}
if u, err := valuer.NewUUID(project); err == nil {
return u, true
}
derived := uuid.NewSHA1(uuid.NameSpaceURL, []byte("hanzo:o11y:org:"+project))
return valuer.MustNewUUID(derived.String()), true
}
// publicKeyForVersion derives the versioned ingest public key for a project.
func publicKeyForVersion(secret []byte, project string, version int) string {
m := hmac.New(sha256.New, secret)
m.Write([]byte("org:" + strings.TrimSpace(project) + ":v" + strconv.Itoa(version)))
return strconv.Itoa(version) + ":" + hex.EncodeToString(m.Sum(nil))
}
// publicKeyFor derives the default (v1) key.
func publicKeyFor(secret []byte, project string) string {
return publicKeyForVersion(secret, project, defaultKeyVersion)
}
// verifyKey constant-time compares a presented "<v>:<hmac>" key against the expected
// one for its project, rejecting versions below the org's revocation watermark. An
// empty secret or key, a malformed version, or a below-min version never verify
// (fail closed).
func verifyKey(secret []byte, project, presented string, minVersion int) bool {
if len(secret) == 0 || presented == "" {
return false
}
i := strings.IndexByte(presented, ':')
if i <= 0 {
return false
}
version, err := strconv.Atoi(presented[:i])
if err != nil || version <= 0 {
return false
}
if version < minVersion {
return false // revoked by rotation
}
expected := publicKeyForVersion(secret, project, version)
return hmac.Equal([]byte(expected), []byte(presented))
}
// sentryKeyFromRequest extracts the presented public key from the Sentry auth
// surface, in precedence order: the X-Sentry-Auth header, then the ?sentry_key
// query param. (The envelope-header DSN is intentionally NOT trusted as an auth
// source — it is client body, not a credential channel.)
func sentryKeyFromRequest(r *http.Request) string {
if k := parseSentryAuthHeader(r.Header.Get("X-Sentry-Auth")); k != "" {
return k
}
return strings.TrimSpace(r.URL.Query().Get("sentry_key"))
}
// parseSentryAuthHeader pulls sentry_key out of a header like:
//
// Sentry sentry_version=7, sentry_key=1:abc123, sentry_client=sentry.python/1.2
func parseSentryAuthHeader(h string) string {
h = strings.TrimSpace(h)
if h == "" {
return ""
}
h = strings.TrimPrefix(h, "Sentry ")
for _, part := range strings.Split(h, ",") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) == 2 && strings.TrimSpace(kv[0]) == "sentry_key" {
return strings.TrimSpace(kv[1])
}
}
return ""
}
// MintDSN builds the default-version DSN an operator hands to an app to report into
// an org. host is the ingest origin (e.g. "o11y.hanzo.ai"); the SDK derives its
// endpoint as https://<host>/v1/o11y/api/<org>/envelope/, which the existing
// /v1/o11y mount forwards to this module's ingest route.
func MintDSN(secret []byte, host, org string) string {
return MintDSNVersion(secret, host, org, defaultKeyVersion)
}
// MintDSNVersion builds a DSN at a specific key version (used after rotating an org).
func MintDSNVersion(secret []byte, host, org string, version int) string {
return "https://" + publicKeyForVersion(secret, org, version) + "@" + host + "/v1/o11y/" + org
}