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() { -