From b87301219130dbe8fc62774420fbe26b8eb73e67 Mon Sep 17 00:00:00 2001 From: Kwaku Danso <72142185+cloud-dev101@users.noreply.github.com> Date: Tue, 19 May 2026 21:33:57 +0100 Subject: [PATCH] =?UTF-8?q?feat(tier2):=20smarter=20fraud=20detection=20?= =?UTF-8?q?=E2=80=94=20Block=20G?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-event fraud tuning. Hosts can now dial the medium / high / block boundaries, allowlist trusted networks, and feed verdicts back on flagged accesses — the seed corpus for a future ML model. Schema (migration 0011) - events.fraud_{medium,high,block}_threshold default 30/60/85 so existing events behave identically until a host changes them - access_logs.geo_{country,city,lat,lon} for future enrichment - fraud_feedback table — verdict ('legitimate' | 'suspicious') + note, PK on access_log_id so re-mark is an upsert - event_allowlists table — (event_id, ip_cidr) primary key, inet column so containment checks use the native >>= operator (indexed lookup) Domain - FraudThresholds with Valid() + Band() helpers; Default trio echoed through GET responses so the frontend doesn't duplicate constants - ParseAllowlistCIDR accepts bare IPs (auto-widens to /32 or /128) and canonicalises the output (203.0.113.42 → 203.0.113.42/32) - Event.Thresholds() falls back to defaults if columns weren't populated yet, so the API never wedges every score into "low" Storage - AllowlistRepo: List / Add / Remove + Matches() — the latter pushes CIDR containment into Postgres rather than streaming rows back - FeedbackRepo: Record (upserts) + ListForEvent (joined through guests) - EventRepo.GetThresholds + UpdateThresholds, plus the threshold columns baked into scanEvent so every event load carries them - AccessLogRepo.BelongsToEvent — stops a hostile editor on event A from marking event B's access logs API - GET/PUT /events/{id}/security/thresholds (viewer/editor) - GET/POST/DELETE /events/{id}/security/allowlist - POST /events/{id}/access-logs/{log_id}/feedback (editor) - GET /events/{id}/security/feedback - RSVP scoring path: allowlist short-circuit fires before the fraud engine; the engine's score is then re-banded against the event's thresholds (engine.Risk becomes advisory — API is the source of truth for "what counts as block here") - CORS Allow-Methods already includes PUT (Block D fix) Fraud engine - Single-signal cap: it now takes ≥2 sub-scores of ≥70 to push the final into HIGH. Fixes the well-known "second visit with a slightly shifted fingerprint scores 60+" false positive - Engine band remains advisory; API re-bands using per-event thresholds before deciding to block Frontend - SecurityCard.vue: visual band ribbon (proportional to thresholds), three sliders with mutual clamping so dragging medium past high pushes high (not an invalid ordering), reset-to-defaults button, CIDR allowlist with inline add + per-row remove, verdict-history inbox. Toast feedback on save/add/remove - "Security" tab added to the event-detail tab nav (5th tab, right of Analytics) - Viewer role hides write affordances; server enforces too Tests - Domain: ThresholdsBand, ThresholdsValid, ParseAllowlistCIDR (bare IP widening + traversal/typo rejection), FraudFeedbackValid - Integration: thresholds round-trip + invalid ordering rejection, allowlist CRUD + duplicate 409 + invalid CIDR 400 + IP auto-widen, feedback record + upsert + cross-tenant 404 + invalid verdict 400, viewer can read / editor can write / outsider gets 404 - Full integration suite green (315.8s, all 36 top-level tests pass) Co-Authored-By: Claude Opus 4.7 --- cmd/api/Dockerfile | 8 +- fraud-engine/app/scoring.py | 18 + frontend/app.vue | 12 +- frontend/components/AnalyticsCard.vue | 257 +++++++----- frontend/components/BrandingCard.vue | 147 ++++++- frontend/components/SecurityCard.vue | 386 ++++++++++++++++++ frontend/components/TeamCard.vue | 2 +- frontend/nuxt.config.ts | 19 + frontend/pages/dashboard/events/[id].vue | 89 +++- frontend/pages/rsvp/[token].vue | 9 +- internal/api/fraud_v2.go | 280 +++++++++++++ internal/api/rsvps.go | 29 ++ internal/api/server.go | 32 +- internal/domain/event.go | 25 ++ internal/domain/fraud_v2.go | 125 ++++++ internal/domain/fraud_v2_test.go | 86 ++++ internal/storage/access_logs.go | 15 + internal/storage/events.go | 15 +- internal/storage/fraud_v2.go | 242 +++++++++++ .../storage/migrations/0011_fraud_v2.down.sql | 14 + .../storage/migrations/0011_fraud_v2.up.sql | 44 ++ test/integration/fraud_v2_test.go | 241 +++++++++++ 22 files changed, 1953 insertions(+), 142 deletions(-) create mode 100644 frontend/components/SecurityCard.vue create mode 100644 internal/api/fraud_v2.go create mode 100644 internal/domain/fraud_v2.go create mode 100644 internal/domain/fraud_v2_test.go create mode 100644 internal/storage/fraud_v2.go create mode 100644 internal/storage/migrations/0011_fraud_v2.down.sql create mode 100644 internal/storage/migrations/0011_fraud_v2.up.sql create mode 100644 test/integration/fraud_v2_test.go diff --git a/cmd/api/Dockerfile b/cmd/api/Dockerfile index 6cf6a0f..4ee8998 100644 --- a/cmd/api/Dockerfile +++ b/cmd/api/Dockerfile @@ -16,7 +16,13 @@ RUN CGO_ENABLED=0 GOOS=linux go build \ FROM alpine:3.20 AS runtime RUN apk add --no-cache ca-certificates tzdata && \ addgroup -g 1000 app && \ - adduser -D -u 1000 -G app app + adduser -D -u 1000 -G app app && \ + # Pre-create the branding-uploads dir with the right ownership. + # Docker copies this directory's contents + ownership into a + # named volume on first mount, which is the only way to get the + # volume owned by UID 1000 without a chmod entrypoint hack. + mkdir -p /var/lib/guestguard/uploads && \ + chown -R 1000:1000 /var/lib/guestguard WORKDIR /app COPY --from=build /out/api /app/api diff --git a/fraud-engine/app/scoring.py b/fraud-engine/app/scoring.py index 9dc05f7..853f92f 100644 --- a/fraud-engine/app/scoring.py +++ b/fraud-engine/app/scoring.py @@ -100,6 +100,24 @@ class HeuristicScorer: weighted = sum(sub[k] * self.weights[k] for k in self.weights) final = int(round(min(max(weighted, 0), 100))) + # Tier 2 Block G — tighten the consecutive-fingerprint false + # positive. Pre-Block-G, a guest opening their invitation a second + # time with even a slightly-shifted device fingerprint (browser + # update, different network) would score ~60 (HIGH band): the + # fingerprint_mismatch sub-score of 100 × 0.40 weight = 40, plus a + # tiny baseline of repeated_access, easily tipped them over. + # + # The rule: a single signal can't push the score into HIGH (>= + # configured high threshold). It takes at least *two* sub-scores + # of >= 70 to escalate. The API re-bands using per-event + # thresholds, but we still cap at 55 here so a single signal + # caps at MEDIUM regardless of how strict the host has set their + # band boundaries. + strong_signals = sum(1 for v in sub.values() if v >= 70) + if strong_signals < 2 and final > 55: + final = 55 + reasons.append("single-signal cap applied (need ≥2 signals for HIGH)") + # Update baseline AFTER scoring so the first access sets it without # being penalised against itself. if baseline.fingerprint_digest is None: diff --git a/frontend/app.vue b/frontend/app.vue index 6f36d8d..ecaac0c 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -6,6 +6,16 @@ const route = useRoute() // page. Inside the app it just clutters the chrome. const showGithub = computed(() => route.path === '/') +// Guest-facing pages (RSVP submit, calendar invite accept, unsubscribe) +// are seen by people who don't have GuestGuard accounts and aren't trying +// to create one. The "Sign in / Get started" CTAs are noise for them and +// frame the page as a marketing surface, not the personal invitation it +// actually is. Hide both branches on these routes. +const isGuestPublicPage = computed(() => { + const p = route.path + return p.startsWith('/rsvp/') || p.startsWith('/invites/') || p.startsWith('/unsubscribe/') +}) + // Profile dropdown state. Closed by default; opens on click, closes on // outside click / Escape / route change. Industry-standard pattern: the // account-scoped actions (Account, Billing, Sign out) tuck under an avatar @@ -160,7 +170,7 @@ async function signOut() { -