Skip to content

Privacy Hardening Action Plan โ€” opened 2026-05-11 โ€‹

Master tracker: #523 โ€” Privacy & Security Hardening โ€” Master Tracker. One issue, one PR. This doc is the canonical status surface; the issue is the canonical reference point.

Working doc. Captures the May 2026 privacy audit findings AND tracks the open work driven by them. Updated as items ship and as new findings surface.

Origin: This started as a point-in-time audit at docs/audit/AUDIT_2026-05-11.md and got reframed as a working action plan on 2026-05-14 โ€” the dashboard table made it the canonical tracker for follow-up engineering, counsel, and sprint work. Audit findings (ยง2โ€“ยง5, ยง9) stay frozen below; ongoing tracking lives in the dashboard at the top.

Author: privacy workstream Last updated: 2026-05-14 Companion docs: docs/privacy/PRIVACY_HARDENING_ROADMAP.md (living strategic roadmap โ€” sprint-level), docs/privacy/SEALED_IDENTITY.md (brief)

Action items at a glance โ€‹

Single-table summary of every open action item surfaced by this audit. Detail in the section linked in the See column. Status conventions: ๐ŸŸฆ open ยท ๐ŸŸก in progress ยท ๐ŸŸข done ยท โธ blocked.

Phase legend โ€‹

Phases are sequencing groups, not deadlines. P1 should land before P2 (so cleanup work doesn't regress). P2 and P3 can overlap. P4 (counsel) runs in parallel throughout. P5 covers pre-launch hardening and post-launch ongoing.

PhaseThemeRough timingEffort estimate
P1Foundation โ€” CI defense + roadmap accuracy. Lock in regression protection BEFORE cleanup so future PRs can't reintroduce the patterns.This week~2 days
P2Cleanup โ€” mechanical fixes, no design decisions blocking. Batchable into one or two PRs.This / next week~2โ€“3 days
P3Design-dependent โ€” items that need a product/UX/architectural decision before code can ship.1โ€“2 weeks (incl. design conversation)varies
P4Counsel track โ€” privacy policy, ToS, sub-processor enumeration, DPIA prep. Runs parallel to engineering, doesn't block.Ongoingcounsel time
P5Pre-launch & beyond โ€” pen test, type-safety hardening, architectural sprints, ongoing defense, future audits.Pre-launch onwardvaries

Engineering (code changes) โ€‹

PhasePriorityStatusItemEffortSee
P3CRITICAL๐ŸŸขNon-biometric IndexedDB device-key exposure. When a user skips biometric enrollment, the AES device key is stored plaintext in IndexedDB next to the wrapped entropy. Local malware / forensic access on the device can decrypt all "encrypted" profile data. Server-side guarantee intact; device-side claim conditional on biometrics. Mitigation: strongly encourage biometric enrollment + warning UI for non-biometric users + (long-term) derive device key from PIN. โœ… 0d3140d8 (on unmerged claude/privacy-hardening chain, pending roll-up PR to dev)~1 day, design neededยง10.1
P3HIGH๐ŸŸขEncrypt chat messages client-side (Olm/Megolm). messageService.sendMessage() writes plaintext to Firestore today. Design landed 2026-05-26, re-baselined 2026-05-30 in docs/privacy/CHAT_ENCRYPTION.md: Double Ratchet (Olm) for 1:1, Megolm foundation for bonfires, via the audited @matrix-org/matrix-sdk-crypto-wasm (pivoted off the GPL libsignal TS port). PIN-encrypted backup + recovery phrase; server-driven session rotation for bans; leave existing plaintext to TTL-expire. โœ… Phases 1โ€“3 + 5 (e28f7481, a6b4958e, fc4f9de5, 26dd29af, 1445b42c, 236a3339); Phase 4 (bonfire group sessions) deferred (on unmerged claude/privacy-hardening chain, pending roll-up PR to dev)~1.5โ€“2 weeks (Phases 1โ€“3 + 5)CHAT_ENCRYPTION.md
P2HIGH๐ŸŸขStrip auth headers from pinoHttp logs across all API services (auth, analytics, merchants, venues). Default serializer logs Authorization: Bearer <token>. โœ… c4cb1273 โ€” PINO_REDACT_CONFIG across all 8 API services (pending roll-up PR to dev)~30 minยง9.1
P2HIGH๐ŸŸขEncrypt email at email-passphrase signup (apps/web/src/lib/auth.js:126, 140). Call the existing emailEncryption library or /auth/email/encrypt endpoint. โœ… c4cb1273 (pending roll-up PR to dev)~30 minยง9.4
P2HIGH๐ŸŸขReplace targetEmail with targetUserId in adminActions audit log writes across merchantHandlers.js + adminUsers.js. โœ… c4cb1273 (pending roll-up PR to dev)~1 hrยง9.4
P2HIGH๐ŸŸขAdd metadata: { vibe, interests } to lantern_lit tracking to unlock venue-scoped mood/interest analytics. โœ… e0abe788 (pending roll-up PR to dev)~10 minยง3 + ยง6
P3HIGH๐ŸŸขAdd lantern-form privacy disclosure near submit button: "visible to nearby users for 48h + aggregated for venue analytics." โœ… 6a5027b1 (LightLanternForm.jsx, pending roll-up PR to dev)~30 minยง4 + ยง6
P3MEDIUM๐ŸŸขProfile settings copy clarification โ€” distinguish encrypted-at-rest from plaintext-when-lit; add tooltip near mood. โœ… 6a5027b1 (ProfileSettings.jsx, pending roll-up PR to dev)~30 minยง4 + ยง6
P2MEDIUM๐ŸŸขBin radius_km (<5km, 5-25km, 25km+) and hash city in venue_searched events. โœ… Radius binning c4cb1273 (merged via #544); city hashing done โ€” venue_searched now logs a peppered HMAC city_hash (venues/utility.js hashCity), fail-closed to omit when CITY_HASH_PEPPER unset so the readable name never lands. Activating the groupable hash needs the CITY_HASH_PEPPER secret provisioned for venues-api.~20 minยง3 + ยง6
P2MEDIUM๐ŸŸขHash phone in phoneReclaims collection (currently plaintext via phoneRecycling.js:113). โœ… c4cb1273 (pending roll-up PR to dev)~30 minยง9.4
P5MEDIUM๐ŸŸขProtect adminProfiles.phone and merchantProfiles.phone at rest. โœ… ENCRYPTED (reversible AES-256-GCM, reusing EMAIL_ENCRYPTION_KEY), not hashed โ€” these are contact numbers the admin UI displays/edits, so they must round-trip; one-way hashing (the original wording) would break display. Merchant: encrypted at all server writers + decrypted in getMerchantDetail. Admin: phone read/write moved server-side (/auth/admin/profile/phone, encrypt + peppered phoneHash); the deprecated phoneAdmin check-admin now matches by phoneHash (indexed) with a decrypt-scan fallback for un-migrated rows; client rules forbid client-side phone/phoneHash writes. Follow-ups: backfill existing admin/merchant phones (re-save migrates them); the users.phoneHash-style hash here is parallel, not a replacement.~half day eachยง9.4
P2HIGH๐ŸŸขadminInvites plaintext email + phone โ€” hash or delete-on-redemption. 7-day invite window currently exposes admin invitees' PII. โœ… e0abe788 (pending roll-up PR to dev)~1 hrยง10.3
P3HIGH๐ŸŸขAnthropic admin assistant PII redaction. Design landed 2026-05-26 in docs/privacy/ADMIN_ASSISTANT_REDACTION.md: two-layer model (client block-and-warn + server request-layer sanitizer). Scrubs phone/email/JWT/card; UIDs explicitly preserved as recommended identifier. โœ… 9a57866b (pending roll-up PR to dev)~half dayADMIN_ASSISTANT_REDACTION.md
P2MEDIUM๐ŸŸขExtend Sprint D.1 cascade to frens, featureRequests (non-anonymous), offers.createdBy. Currently a deleted user's userId orphans in these collections. โœ… e0abe788 (pending roll-up PR to dev)~half day totalยง10.3
P1MEDIUM๐ŸŸขCorrect PRIVACY_HARDENING_ROADMAP.md ยง2 / ยง3 threat-model rows to reflect reality (chat msgs plaintext; legacy phone surfaces). โœ… ยง3 chat row corrected in this commit (chat now E2EE, not plaintext)~10 minยง9.5
P2LOW๐ŸŸขSanitization sweep on app_crash / action_failed error message logging to belt-and-suspenders against PII bleed via thrown errors. โœ… e0abe788 (pending roll-up PR to dev)~20 minยง3 + ยง6
P5LOW๐ŸŸฆAudit-verify k-anonymity โ‰ฅ 3 enforcement on every merchant-surfaced metric. Constant exists; confirm enforcement on every report path.~1 hrยง6

Counsel / product (non-engineering) โ€‹

All P4 โ€” runs in parallel to engineering, doesn't block code work.

PhasePriorityStatusItemOwnerSee
P4HIGH๐ŸŸกProfileSettings.jsx references a non-existent "our privacy policy." โœ… Decision (2026-06-01): keep the line but add an explicit in-code NOTE/TODO flagging the missing policy (so it's not silently shipped as if one exists) โ€” the retention list is the de-facto disclosure until then. Still need to write the policy (below) and link it.Product + counselยง5 + ยง6
P4HIGH๐ŸŸฆWrite a public privacy policy. Use PRIVACY_HARDENING_ROADMAP.md ยง2 + ยง3 as source of truth (corrected per ยง9.5 first). Required for GDPR Art. 12โ€“14, CCPA notice-at-collection, app-store nutrition labels.Counselยง5 + ยง6
P4HIGH๐ŸŸฆWrite Terms of Service. Same compliance bucket.Counselยง6
P4HIGH๐ŸŸฆEnumerate sub-processors in the privacy policy when written: Resend (email), Anthropic (admin assistant), GitHub (feature requests), Discord (internal monitoring), Cloudflare/Railway (admin billing). Resend + Anthropic require GDPR Data Processing Addendums (DPAs).Counselยง10.2
P4MEDIUM๐ŸŸฆSubpoena response playbook โ€” per-request-shape doc of what we can/cannot produce.Counselยง6
P4MEDIUM๐ŸŸฆDPIA prep if EU market entry is on the roadmap. Sealed identity helps the DPIA; document the model.Counselยง6

Architectural (future sprint work) โ€‹

All P5 โ€” pre-launch hardening or post-launch sprint work.

PhasePriorityStatusItemSource
P5HIGH๐ŸŸฆSprint E โ€” Stage B (seal phoneHash โ†’ userId resolution with encryptedUserIdBlob). Multi-PR architectural commitment.Roadmap ยง4
P5MEDIUM๐ŸŸฆSprint D.3 โ€” admin panel UI + user self-delete flow wiring up the existing deleteUserCompletely callable.Roadmap ยง4
P5LOW๐ŸŸฆDifferential privacy on aggregate queries (combined-query inference defense). Future Phase 3+ item.Roadmap ยง6

Continuous defense (recurring infrastructure) โ€‹

PhasePriorityStatusItemEffortSee
P1HIGH๐ŸŸขGitleaks in CI โ€” block PRs that introduce secrets. Fast, low-false-positive. Single highest-leverage move. โœ… 53f21a4a (pending roll-up PR to dev)~30 minยง11.2
P1HIGH๐ŸŸขSemgrep with 10 custom rules drawn from this audit (users-plaintext-pii, adminActions-targetemail, console-log-user, messages-plaintext, lantern-coord-untruncated, etc.). Encodes dashboard findings as CI gates. โœ… 53f21a4a (pending roll-up PR to dev)~1 dayยง11.2
P1HIGH๐ŸŸขCODEOWNERS for privacy-critical paths โ€” require privacy review on auth.js, services/api/auth/, userDeletion.js, flash.js, sanitize.js. โœ… 53f21a4a (pending roll-up PR to dev)~30 minยง11.2
P1MEDIUM๐ŸŸขPR template with privacy checklist โ€” three questions about new Firestore fields / log calls / event metadata. โœ… 53f21a4a (pending roll-up PR to dev)~30 minยง11.2
P2HIGH๐ŸŸฆCloud DLP scheduled scan โ€” daily inspection job against Firestore + BigQuery. Alerts on unexpected PII patterns. Mechanical setup once; runs forever after.~half dayยง11.3
P5MEDIUM๐ŸŸฆTruffleHog weekly scheduled deep scan with --only-verified flag. Complements Gitleaks; catches what regex misses.~30 minยง11.2
P5MEDIUM๐ŸŸฆType-safe encrypted-field wrappers โ€” branded EncryptedField<T> type so wrong-type writes fail to compile. Architectural defense.~half sprintยง11.1
P5MEDIUM๐ŸŸฆsafeWriteUser() helper โ€” allowlist-based write helper for users/{uid}. Forces opt-IN for any new field.~half dayยง11.1
P5LOW๐ŸŸฆCanary data โ€” synthetic test user with known fingerprint; periodically grep BigQuery + Cloud Logging for leaks.~half dayยง11.3
P5HIGH๐ŸŸฆPre-launch pen test scoped to privacy claims (Trail of Bits / Cure53 / NCC Group / Bishop Fox).$15โ€“30k, ~2 weeks leadยง11.4
P5MEDIUM๐ŸŸฆBug bounty post-launch with explicit privacy tier (HackerOne / Bugcrowd).$5k starter + payoutsยง11.4
P5MEDIUM๐ŸŸฆQuarterly internal privacy audit โ€” re-run BQ verification commands in ยง8, grep audits, compare to baseline. New findings get dated supplemental sections.~half day / quarterยง11.4

Supplemental audit โ€” 2026-06-01 (post-#544 follow-up) โ€‹

New findings from a follow-up audit after the #544 roll-up merged, focused on the newer service / Firestore-rules / chat-E2EE surface area (most of the original baseline is now ๐ŸŸข). Verified items are noted. Tracked here per the maintenance discipline below.

PhasePriorityStatusItemEffortNotes
P1HIGH๐ŸŸขScheduler/cleanup endpoints authenticated on a spoofable header. /analytics/scheduled (publicly reachable via the /api/analytics/* worker proxy) + /lanterns/cleanup treated presence of X-CloudScheduler-JobName as auth โ€” an unauthenticated caller could trigger BQ aggregation, user-id pseudonymization, and TTL deletion. โœ… FIXED: new requireSchedulerOrAdmin guard accepts a constant-time SCHEDULER_SECRET bearer (Cloud Scheduler) OR a verified admin token; the spoofable header path is gone. Secure on deploy โ€” with no secret set, only the admin path works (scheduler pauses, never falls back). Infra to activate: set SCHEDULER_SECRET on analytics-api + lanterns-api and configure the Cloud Scheduler jobs to send Authorization: Bearer <secret>.done (code); ~30 min infraยง11
P1HIGH๐ŸŸกconnections rules integrity. Any user can create a connections doc pairing themselves with an arbitrary victim uid (no accepted-wave check) โ†’ unsolicited message-thread injection; and either party can mutate user1Id/user2Id on update (no pin). Fix: require an accepted wave (or server-create) + pin participant ids on update.~half dayยง9.5
P1HIGH๐ŸŸกDuplicate waves rules blocks with conflicting schemas (fromUserId/toUserId vs senderUserId/recipientUserId); rules OR them, so the looser block bypasses the stricter create validation. Fix: consolidate to one canonical block.~30 minยง9.5
P1HIGH๐ŸŸกRetire dead Cloud Functions writing plaintext PII. Legacy modules/adminUsers.js + modules/merchantUsers.js CFs (superseded by Cloud Run, no client calls them) still write plaintext phone + email. Retire like the phoneRecycling CF.~30 minยง9.4
P2HIGH๐ŸŸกApp Check enforced only on auth-api. โœ… Gated verifyAppCheck added to analytics/venues/lanterns/merchants, before verifyFirebaseToken on user-facing routes (health/openapi/public-event-schemas/scheduler excluded). MONITOR by default (logs missing/invalid, allows through); flip per-service to ENFORCE via APP_CHECK_ENFORCE=true after confirming clients send valid tokens. Bypassed outside production. To finish: watch the appcheck_missing/appcheck_invalid logs per service, then set APP_CHECK_ENFORCE=true.done (code, monitor); enforce = configยง11
P2MEDIUM๐ŸŸฆclaim-otk not scoped to connected peers โ€” any authed user can drain anyone's one-time-key pool / enumerate the device directory. Add a connection check in the claim handler.~1 hrยง9.5
P2MEDIUM๐ŸŸฆPlaintext-fallback chat messages not surfaced to recipients or monitored. encryptionFailed rides only the sender's return value; a systemic encrypt outage writes plaintext at scale silently (no alert, normal 30-day TTL). Propagate the flag to recipients + add monitoring; consider a shorter TTL for fallback plaintext.~half dayยง9.1
P2MEDIUM๐ŸŸฆmerchantPasswordResetTokens.email plaintext, never deleted (no TTL) โ€” persists indefinitely. Encrypt + add TTL / delete-on-use.~1 hrยง9.4
P2MEDIUM๐ŸŸฆusers.email written plaintext on admin/merchant create โ€” inconsistent with the encryptedEmail model elsewhere. Switch these creates to encryptedEmail.~1 hrยง9.4
P2MEDIUM๐ŸŸขmerchants-api has no rate limiter โ€” offer/audience enumeration & scraping. โœ… Added a per-user limiter (shared USER_RATE_LIMITS.offers_browse = 60/min) on GET /offers/active (the lat/lng-grid scrape vector); 429 over-limit. Business-data/cost concern, not user-PII.doneยง11
P1MEDIUM๐ŸŸกNo HSTS header on web + admin. Add Strict-Transport-Security to _headers (also backstops the privacy-correct Referrer-Policy).~10 minยง11
P5HIGH๐ŸŸฆNo device verification / safety numbers (chat). No cross-signing โ†’ an active key-substitution MITM by a compromised server/admin is not prevented; the threat-model table overstates insider protection. Add a ยง2 row + plan cross-signing / safety-number UX.~half sprintยง11
P3MEDIUM๐ŸŸฆRetire the deprecated check-admin enumeration oracle (phoneโ†’admin yes/no) once admins are on invite links; remove the PhonePinSignup call.~1 hrยง10.3
P5LOW๐ŸŸกChat E2EE nits. โœ… toDevice create rule now has a field allowlist + type checks (no arbitrary fields); โœ… restoreFromBackup rejects an unrecognized version; โœ… PIN-change-doesn't-rekey-backup caveat documented (CHAT_ENCRYPTION.md ยง8). Remaining: bind the backup canary to the bundle (AAD) โ€” deferred, deeper change.done (3/4)ยง9.5
P5LOW๐ŸŸฆCSP hardening: scripts allow unsafe-inline/unsafe-eval (both apps); admin loads Sveltia CMS from unpkg.com (pin/SRI or self-host). Plus optional COOP/COEP.~half dayยง11
P3LOW๐ŸŸขfrens contact-injection โ€” a saver could broadcast their lantern to an arbitrary uid with no prior interaction. โœ… Decision (2026-06-01): require a prior connection. Rule now requires the fren to reference a real connections doc whose participants are exactly {saver, saved} (frens are saved from the chat screen, which passes connection.id).doneยง10.3

Maintenance discipline โ€‹

When closing an item, change ๐ŸŸฆ โ†’ ๐ŸŸข and add a link to the PR / commit that closed it in the Item column (or as a footnote). Don't delete rows โ€” historical completion is useful context. New findings get added to the relevant section (ยง6 or ยง9.4) AND mirrored here.

1. Why this doc exists โ€‹

Three roles in one doc:

  1. Audit baseline (ยง2โ€“ยง5 + ยง9): point-in-time findings from the May 2026 three-layer privacy audit (architecture / analytics integration / UI copy / privacy-policy gap) plus the supplemental Cloud Logging + Firestore PII audit added 2026-05-14. These sections are frozen โ€” don't rewrite history; future audits get their own dated doc that compares against this baseline.
  2. Action tracker (dashboard at the top): single-table summary of every open task surfaced by the audit, with status, effort, and back-link to the detail section. This is the part that gets updated as work ships โ€” mark items ๐ŸŸข with a PR link when closed.
  3. Operating manual (ยง7, ยง10): how to keep the doc honest as the work progresses.

Companion to docs/privacy/PRIVACY_HARDENING_ROADMAP.md (the strategic, sprint-level living roadmap). This plan is more tactical โ€” individual fixes and gaps, not whole sprints.

2. Layer 1 โ€” architectural privacy posture (STRONG) โ€‹

10 PRs merged 2026-05-10 โ†’ 2026-05-11. Summary state:

SurfacePosture
users.phone plaintextโœ… Removed on new signups (Stage A phase 4). Migration script ready for existing rows (Sprint C operator step).
users.phoneHashโœ… HMAC-SHA-256 + KMS pepper (Stage A phases 1โ€“3).
users.encryptedSeed / encryptedBirthDate / encryptionCanaryโœ… AES-GCM ciphertext, key derived from user's PIN. Server cannot decrypt.
users.encryptedMood / encryptedInterestsโœ… AES-GCM (Sprint A). Server cannot decrypt.
lanterns.lat/lngโœ… Truncated to ~111m precision (Sprint A).
banned_accountsโœ… Hashes only, server-only Firestore rule.
lanterns/* retentionโœ… 48h via scheduled Cloud Function (Sprint B.1).
waves/* retentionโœ… 7d via scheduled Cloud Function (Sprint B.1).
connections/* + messagesโœ… 30d via scheduled Cloud Function, sub-collection cascade (Sprint B.1).
BigQuery raw eventsโœ… 90-day partition expiration enforced by BigQuery itself.
BigQuery aggregated event_counts_dailyโœ… No user_id, no per-user breakdown. Indefinite retention is safe.
GDPR cascade deletionโœ… deleteUserCompletely callable + BQ pseudonymization-on-deletion (Sprint D.1 + D.2).

Outstanding architectural items:

  • ๐ŸŸฆ Sprint D.3 โ€” UI integration: callable exists, but no admin panel button or user-self-delete flow yet.
  • ๐ŸŸฆ Sprint E โ€” Stage B: phoneHash โ†’ userId resolution is still server-resolvable. The brief calls for sealing this with encryptedUserIdBlob. Multi-PR architectural commitment (~8โ€“10 days).
  • ๐ŸŸฆ Firebase Auth phone-number residual: Firebase Auth itself maintains phoneNumber on user records (used for SMS verification). Stage B closes the Firestore phoneHash โ†’ userId path but not auth.getUserByPhoneNumber(X). Structural to using Firebase Auth's phone provider. Document as residual.

3. Layer 2 โ€” analytics integration (CLEAN with two nits) โ€‹

Verified against the actual code + actual BigQuery contents on lantern-app-dev.

What works โ€‹

Gaps worth flagging โ€‹

  1. lantern_lit metadata is null today. Verified across 120 events. Mood/interest exist on the lantern Firestore doc (profileVibe, profileInterests) but aren't denormalized into the event for venue-scoped analytics. Implication: merchant analytics cannot slice "moods at venue X" from BQ today. Trivial fix (~10 min client-side tracking change) โ€” see open items ยง5.
  2. venue_searched includes city + radius_km (packages/shared/analytics/index.js). Not a per-user-precise leak, but in low-density cities + small radius this narrows location. Worth binning radius (<5km, 5-25km, 25km+) or hashing the city if rare-locale users matter.
  3. app_crash / action_failed log truncated error messages and stack traces (apps/web/src/lib/flash.js:582-599). Unlikely to leak PII if upstream error handling is clean, but worth a one-time sanitization sweep.

Side-effect surfaces โ€‹

  • Cloud Logging (Pino HTTP middleware) logs request headers/method/path/status. No event metadata in URL paths. Error handler should not log req.body or req.user โ€” quick check warranted.
  • BigQuery write-failure logs in packages/forge/bigquery.js:76-84: log eventName, eventId, eventTier only on failure. No metadata, no user_id. Clean.

4. Layer 3 โ€” user-facing UI copy (INCOMPLETE) โ€‹

The architecture is honest; the UI is silent on the key transitions. Users can't reasonably understand the consent model from the existing copy alone.

Signup (apps/web/src/screens/auth/PhonePinSignup.jsx) โ€‹

  • โœ… Lines 713โ€“717 disclose: "Your birth date and phone number are encrypted on your device. We only verify you're 18+ for venue access."
  • โœ… Lines 825โ€“829 disclose the PIN-derived encryption key model.
  • โœ… Lines 939โ€“960: "Lantern servers only receive encrypted data (gibberish without your PIN)."
  • โŒ No warning that lighting a lantern will publish mood + interests in plaintext for 48h + aggregate for venue analytics. The phrase "all your data" misleads.

Lantern lighting form (apps/web/src/screens/... โ€” LightLanternForm.jsx) โ€‹

  • โš ๏ธ Line 148: "People nearby will see your interest, but your identity remains hidden until you meet." โ€” technically true, structurally incomplete.
  • โŒ No disclosure that mood goes plaintext for 48h.
  • โŒ No disclosure that mood + interests + free-text are aggregated for venue analytics.
  • โŒ No tooltip / info icon / fine print near mood, interests, or the free-text quote field.

Profile mood/interest settings (apps/web/src/screens/profile/ProfileSettings.jsx) โ€‹

  • โš ๏ธ Line 566: "Interests โ€” top interests show on your lantern card." Correct for interests, but no equivalent disclosure for mood.
  • โš ๏ธ Lines 690โ€“697: "Privacy Guarantees" box mentions encryption of sensitive data. Doesn't distinguish "encrypted at rest" from "plaintext when lit."
  • โŒ The Preview UI (lines 483โ€“489) does not clarify whether what's previewed is the encrypted profile state or what'll get published.
  • โŒ ProfileSettings.jsx also references "our privacy policy" but no policy document exists โ€” see ยง5.

Honesty scorecard โ€‹

FlowPrivacy-critical copy present?Architecturally honest?Top gap
Signupโœ… YesPartiallyNo mention of lantern-lighting publication
Lantern formMinimalPartiallyMood becomes plaintext; not disclosed
Profile settingsPartialPartiallyNo "encrypted at rest vs. plaintext when lit" distinction

5. Layer 4 โ€” privacy policy / ToS (DOES NOT EXIST) โ€‹

This is the biggest gap and the one with real legal exposure.

What exists today โ€‹

Risks โ€‹

  • Over-claiming: ProfileSettings UI references "our privacy policy" and claims TTL retention (48h/7d/30d) for check-ins/waves/chats. Sprint B.1 only just shipped โ€” until operators verify the scheduled Cloud Functions are deployed and running on dev, this is an aspirational claim in user-facing copy.
  • Under-claiming: nothing in any user-facing copy mentions the actual privacy strengths โ€” phone hashing, profile encryption, 90-day BQ retention, cascade deletion on Art. 17 requests. Significant marketing + legal-defense loss.
  • Compliance: no privacy policy = no GDPR Art. 12โ€“14 disclosure, no CCPA notice-at-collection, no Apple/Google app-store privacy nutrition label backing.

6. Open items / next batch of work โ€‹

Ordered by leverage ร— urgency.

Code (engineering) โ€‹

PriorityItemEffortWhy
HIGHAdd metadata: { vibe, interests } to lantern_lit event tracking~10 minUnlocks venue-scoped mood/interest analytics. Trivial code change.
HIGHAdd lantern-form privacy disclosure ("visible to nearby users for 48h + aggregated for venue analytics") near the submit button~30 minCloses the biggest UI honesty gap. Cheap.
MEDIUMAdd "this is your private profile mood โ€” published only when you light a lantern" copy in ProfileSettings~30 minCloses the encrypted-at-rest vs. plaintext-when-lit ambiguity.
MEDIUMBin radius_km and consider hashing city in venue_searched events~20 minCloses the low-density-locale fingerprint.
LOWSanitization sweep on app_crash / action_failed error message logging~20 minBelt-and-suspenders against accidental PII bleed via thrown errors.
LOWAudit-verify k-anonymity โ‰ฅ 3 enforcement on every merchant-surfaced metric~1 hrThe constant exists but should be confirmed on every report path.

Counsel / product (non-engineering) โ€‹

PriorityItemOwnerWhy
HIGHRemove the "our privacy policy" reference from ProfileSettings until a policy exists, OR write a minimal interim policy. Current state is a liability.Product + counselHard claim with no backing document.
HIGHWrite a public privacy policy. Use PRIVACY_HARDENING_ROADMAP.md ยง2 + ยง3 as source of truth.CounselGDPR Art. 12โ€“14, CCPA notice-at-collection, app-store compliance.
HIGHWrite a Terms of Service.CounselSame compliance bucket.
MEDIUMSubpoena response playbook (roadmap ยง5)CounselPer-request-shape doc of what we can/cannot produce.
MEDIUMDPIA prep if EU market entry is on the roadmapCounselSealed identity helps the DPIA; document the model.

Architectural (future engineering sprints) โ€‹

PriorityItemSource
HIGHSprint E โ€” Stage B (seal userId resolution)Roadmap ยง4
MEDIUMSprint D.3 โ€” admin / self-delete UI integrationRoadmap ยง4
LOWDifferential privacy on aggregate queries (combined-query inference defense)Roadmap ยง6
  1. Now (this week): Remove the "our privacy policy" UI reference (or stub the policy to an honest "we don't yet have a written policy; here's our engineering doc" link). Add the lantern-form disclosure. Close the over-claim gap.
  2. Within the month: Write the privacy policy + ToS. Use this audit + the roadmap as source material for counsel.
  3. Pre-launch: Sprint D.3 UI integration; verified k-anonymity โ‰ฅ 3 enforcement. Audit-verify the operator runbooks (Sprint C migration, Cloud Scheduler job for B.2).
  4. Stage B (Sprint E): Scope and start when there's capacity.

8. Verification commands (for future audits) โ€‹

Keep these handy โ€” they're how the BQ side of this audit was verified.

bash
# Confirm 90-day partition expiration on raw events
bq show --format=prettyjson lantern-app-dev:analytics.events | jq '.timePartitioning'

# Confirm aggregated table has no PII columns
bq show --schema --format=prettyjson lantern-app-dev:analytics.event_counts_daily | jq -r '.[].name'

# Distribution of user_id lengths (raw UID โ‰ˆ 28; pseudonymized = 64 hex)
bq query --use_legacy_sql=false "SELECT LENGTH(user_id) AS len, COUNT(*) AS n FROM \`lantern-app-dev.analytics.events\` WHERE user_id IS NOT NULL GROUP BY len ORDER BY len"

# What metadata gets logged with lantern lighting and mood/interest
bq query --use_legacy_sql=false "SELECT event_name, TO_JSON_STRING(metadata) AS meta FROM \`lantern-app-dev.analytics.events\` WHERE event_name IN ('lantern_lit','mood_adjusted','interest_added') ORDER BY timestamp DESC LIMIT 10"

# Verify no other dataset has user_id columns
for ds in analytics billing_attrib billing_marts billing_norm billing_raw ops logs; do
  bq ls --max_results=50 lantern-app-dev:$ds 2>/dev/null | tail -n +3 | awk '{print $1}' | while read t; do
    [ -z "$t" ] && continue
    bq show --schema --format=prettyjson "lantern-app-dev:$ds.$t" 2>/dev/null \
      | jq -r '.[].name' 2>/dev/null \
      | grep -qiE "^(user_id|userid|uid)$" && echo "โš  $ds.$t has user_id"
  done
done

9. Supplemental โ€” Cloud Logging + Firestore PII audit (2026-05-14) โ€‹

Follow-up to ยง3 (analytics integration) and ยง4 (UI copy), specifically asked: could GCP logs or Firestore accidentally pick up PII beyond what we've accounted for?

Two parallel sub-audits run. Findings below โ€” some material, surfaced here rather than buried in the open-items section because they affect the privacy claim accuracy.

9.1 Cloud Logging โ€” one medium-severity exposure โ€‹

  • MEDIUM: pinoHttp is mounted without a custom request serializer in services/api/auth/src/index.js:63, services/api/merchants/src/index.js:22, and the other API services. Default pino-std-serializers includes req.headers in the logged request object, which means Authorization: Bearer <Firebase ID token> and any custom auth headers go to Cloud Logging on every request. Not strictly PII, but token capture is a security concern bordering on PII bleed. Fix: add a serializers.req to each pinoHttp config that excludes headers and body:
    js
    pinoHttp({
      logger,
      serializers: {
        req: (req) => ({ method: req.method, url: req.url }),
      },
    })
  • Otherwise: clean. Sprint A's lazy migration in apps/web/src/lib/profileService.js logs only userId (and devLog suppresses in production). Sprint C's drop-plaintext-phone.mjs only logs row counts and skip reasons. Cloud Functions logger calls in phoneRecycling.js, adminUsers.js, etc. all log opaque UIDs only post-#479. Error handlers log err.message/err.stack, not req.body.

9.2 Firestore โ€” six findings, four contradicting the ยง2 threat-model claims โ€‹

The ยง2 table claims certain things are encrypted that are not. Listed by severity:

  1. HIGH โ€” connections/{cid}/messages/* are written PLAINTEXT. apps/web/src/lib/messageService.js:46โ€“60 sendMessage() writes { senderId, senderName: 'You', text: text.trim(), timestamp, status } to Firestore via addDoc with zero encryption call. The roadmap's ยง3 threat-model row "chats.* message bodies | encrypted client-side | No" is inaccurate. This is the single biggest privacy gap in the codebase right now โ€” every message a user has ever sent is sitting plaintext in Firestore, bounded only by the 30-day TTL Sprint B.1 just added.

  2. HIGH โ€” Email-passphrase signup path writes plaintext email to users/{uid}.email (apps/web/src/lib/auth.js:126, 140). An emailEncryption.js library exists at services/api/auth/src/lib/, and the /auth/email/encrypt endpoint is correctly called by PhonePinSignup post-creation, but the legacy email-passphrase signup flow bypasses it. Affects admin/merchant accounts that signed up via this path.

  3. HIGH โ€” adminActions.targetEmail plaintext. Multiple write sites in services/api/auth/src/handlers/merchantHandlers.js:493 and services/api/auth/src/routes/adminUsers.js:151, 261, 425, 640 write targetEmail as plaintext in the audit log. LOG_HYGIENE.md prescribes userId-only references in audit rows; these violate it. Tracked separately in #308 but worth flagging here.

  4. HIGH โ€” merchantPasswordResetTokens.email plaintext (services/api/auth/src/handlers/merchantHandlers.js:462). userId alone would be sufficient; storing the email plaintext duplicates an identifier that's already encrypted elsewhere.

  5. MEDIUM โ€” phoneReclaims.phoneNumber plaintext (services/functions/firebase/modules/phoneRecycling.js:113). Inconsistent with Sprint C's hashing strategy. Should write phoneHashOldOwner only.

  6. MEDIUM โ€” adminProfiles.phone and merchantProfiles.phone plaintext โ€” not enumerated in the ยง2 threat model at all. Separate collections from users. Phone is stored plaintext because of merchant/admin lookup-by-phone flows that were never migrated to hashed lookups.

9.3 Implications for the privacy claim โ€‹

The strongest defensible privacy claim from ยง2 should be revised to remove the "chats encrypted" assertion until it's actually true. Today the accurate claim is:

"User profile fields (mood, interests, birth date, recovery seed) are encrypted with keys derived from each user's PIN โ€” Lantern cannot decrypt. Chat messages, audit logs, and reset tokens contain plaintext content; chat messages are bounded by a 30-day TTL, audit logs by Cloud Logging retention (30d), reset tokens by their own short expiry. Phone numbers are hashed in users/ but remain plaintext in adminProfiles, merchantProfiles, and phoneReclaims โ€” three legacy surfaces that need follow-up."

9.4 New open items (added to ยง6 implicitly) โ€‹

PriorityItemEffort
HIGHEncrypt chat messages client-side via Olm/Megolm (Double Ratchet for 1:1, Megolm for bonfires) through the audited @matrix-org/matrix-sdk-crypto-wasm. Full design in docs/privacy/CHAT_ENCRYPTION.md โ€” schema, flows, phasing, library choice (pivoted off GPL libsignal). Bonfire foundation + server-driven rotation baked in from v1.~1.5โ€“2 weeks (Phases 1โ€“3 + 5), design landed
HIGHStrip auth headers from pinoHttp logs across all API services~30 min
HIGHEncrypt email at auth.js email-passphrase signup path (call the existing emailEncryption library or /auth/email/encrypt)~30 min
HIGHReplace targetEmail in adminActions with targetUserId only across the merchantHandlers + adminUsers write sites~1 hr
MEDIUMHash phone in phoneReclaims collection~30 min
MEDIUMMigrate adminProfiles.phone and merchantProfiles.phone to hashed (same KMS pepper as users.phoneHash)~half day each
MEDIUMCorrect the ยง2 / ยง3 threat-model rows in PRIVACY_HARDENING_ROADMAP.md to match reality~10 min

9.5 What this means for the architecture โ€‹

The privacy architecture for profile data is solid โ€” that's what Sprint A shipped and verified. The privacy architecture for chat content, audit logs, and legacy admin/merchant surfaces has known gaps that the roadmap previously claimed were closed. Worth a corrective sprint before any marketing or counsel-facing privacy claim references the roadmap.

10. Supplemental pass 2 โ€” outbound services, client storage, remaining Firestore, GCP surfaces (2026-05-14) โ€‹

Four parallel sub-audits run on 2026-05-14 to sweep surfaces the original audit didn't fully cover. One critical finding on the device side, three high-severity findings on outbound services or new collections, several medium-severity gaps in the cascade-deletion coverage.

10.1 Client-side storage โ€” one CRITICAL device-side finding โ€‹

  • CRITICAL โ€” Non-biometric path stores the device key plaintext in IndexedDB. apps/web/src/lib/keyCache.js:139-141. When a user signs up WITHOUT enrolling biometrics, the AES-256 device key used to wrap their entropy is exported as raw bytes and stored in IndexedDB alongside the wrapped entropy. A process with local IndexedDB read access (malware, browser extension, forensic access to a stolen device, shared-device co-user) can read both, decrypt the entropy, derive the encryption key, and read all the user's "encrypted" profile data. The server-side privacy guarantee remains intact โ€” Lantern still cannot decrypt anything. But the user-facing claim "even on your device, your data is encrypted" is conditional on biometric enrollment.

    This is documented in the code as a "convenience vs. security" tradeoff. Mitigations:

    • Strongly encourage biometric enrollment during signup (currently optional Step 4 per Phase 4 plan).
    • On non-biometric devices, surface a clear warning: "Without biometric protection, your data is recoverable on this device if compromised."
    • Long-term: derive the device key from a user-provided second factor (PIN) rather than storing it.
  • Otherwise client-side is clean:

    • localStorage: feature flags + pseudonymous IDs only. No phone, email, mood, interests.
    • sessionStorage: 30-minute biometric entropy cache (keyCache.js:276). Standard practice; cleared on tab close.
    • URL hash routing: pseudonymous IDs and server-opaque tokens only. No ?email= or ?phone= patterns.
    • Cookies: none custom; Firebase Auth uses server-side session cookies.
    • window.* globals: gated to import.meta.env.DEV. Production-safe utilities like window.lanternForceRefresh only.
    • React component state: auth-flow PII cleared after submission. No "stale PII" lingering.
    • devLog: properly suppressed in production via isDevelopment check (apps/web/src/lib/devLog.js).

10.2 Outbound third-party services โ€” three HIGH-severity, all undocumented in any privacy policy (which doesn't exist) โ€‹

SeverityServiceData sentSurface
HIGHResend (email transactional)Recipient email, display name, subject context, recovery backup encrypted blob (opaque to Resend)services/api/auth/src/lib/email.js, services/api/auth/src/routes/email.js:45โ€“100 โ€” admin invites, merchant invites, password resets, recovery delivery
HIGHAnthropic Claude API (admin assistant)Admin-typed messages with no automated redaction; conversation history; tool-call outputs (which can include user data the admin queried)services/api/assistant/src/services/anthropic.js; admin-only surface gated by verifyFirebaseToken + requireAdmin
HIGHGitHub APIuserId (Firebase UID), user-typed feature request title/description/use-case, collaborator emailservices/functions/firebase/modules/featureRequests.js:140โ€“200, githubAccess.js
MEDIUMDiscord webhook (internal monitoring)userId or "Anonymous", feature request body, environmentfeatureRequests.js:218โ€“290
LOWCloudflare API, Railway API (admin billing)Account/workspace IDs only โ€” no user PIIbilling.js, billingShared.js

Clean (verified absent): Twilio (Firebase Auth handles SMS natively), Sentry, Datadog, Loggly, Mixpanel, Amplitude, PostHog, OpenAI in prod, any non-admin LLM calls, any webhook outbound besides Discord.

Implication: When the privacy policy is written, it needs to enumerate Resend, Anthropic, GitHub, Discord, Cloudflare, Railway as sub-processors. Resend and Anthropic specifically require Data Processing Addendums (DPAs) under GDPR.

10.3 Remaining Firestore collections โ€” three new gaps โ€‹

SeverityCollectionFindingFile
HIGHadminInvitesStores admin email + phone plaintext for the 7-day invite window. Not covered by Sprint D.1 cascade deletion.services/api/auth/src/routes/adminInvite.js:61โ€“69
MEDIUMfrensuserId pairs (saverId, savedId) persist after user deletion. Sprint D.1 cascade does not clean this. Relationship graph orphans.services/functions/firebase/modules/frens.js:65โ€“68
MEDIUMfeatureRequestssubmittedBy = userId orphans on user deletion (when not flagged anonymous). Not in cascade.services/functions/firebase/modules/featureRequests.js:161โ€“178
MEDIUMofferscreatedBy = userId orphans on user deletion. Not in cascade. (Admin/merchant content, but still a userId trail.)services/api/merchants/src/routes/offers.js:134

Clean (verified safe):

  • userInvites โ€” token only, no PII, 7-day TTL.
  • appConfig โ€” system config only.
  • merchants (top-level) โ€” venueIds array; PII lives in merchantProfiles (already flagged in ยง9.2).

10.4 GCP infrastructure โ€” clean โ€‹

No new findings. Cloud Storage rules explicitly deny users/{userId}/* paths; only avatars/{userId}/* is used and is covered by Sprint D.1 cascade. Cloud Tasks / Pub/Sub / custom Cloud Scheduler payloads not used. Secret Manager holds operational secrets only. CI/CD workflows don't echo user data. Firebase Hosting cache headers are standard. Cloud Run access logs are structural to GCP and outside our seal (already noted as residual in ยง6).

10.5 New action items (mirrored into the dashboard at the top) โ€‹

Added to the dashboard:

  • CRITICAL โ€” biometric-or-warning UX on signup for the IndexedDB key exposure (~1 day, design needed)
  • HIGH โ€” adminInvites hash phone + email, or delete-on-redemption (~1 hr)
  • HIGH โ€” Resend / Anthropic / GitHub / Discord sub-processors enumerated in the privacy policy when written (counsel)
  • HIGH โ€” Anthropic admin assistant PII redaction policy (~half day) โ€” design landed: docs/privacy/ADMIN_ASSISTANT_REDACTION.md
  • MEDIUM โ€” Extend Sprint D.1 cascade to frens, featureRequests (where not anonymous), offers.createdBy (~half day total)

11. Continuous privacy defense โ€” keep the posture from decaying โ€‹

The audit findings above are point-in-time. Without ongoing defense, six months from now someone adds a feature that quietly leaks data and the architectural guarantee evaporates. Four layers of defense, each cheap on its own and strong in aggregate.

11.1 Architectural โ€” make leaks structurally hard โ€‹

Already in place (extend, don't replace):

  • Dual-layer metadata sanitizer in packages/forge/sanitize.js and apps/web/src/lib/flash.js. Forbidden-key list strips email, phone, name, etc. before BQ write. Every new sensitive field name should be added to this list.
  • Firestore security rules in firestore.rules. Already locks down banned_accounts/* server-only. Extend the same allow read: if false pattern to any new sensitive collection.

Worth adding:

  • Type-safe encrypted-field wrappers. Define a branded type EncryptedField<T> (TypeScript or zod). Any function writing to users/{uid} takes a typed payload requiring encryptedMood: EncryptedField<string> etc. The wrong-type write fails to compile. ~Half a sprint to implement; pays dividends forever.
  • safeWriteUser() helper that takes only an allowlist of fields. Trying to pass email fails. Forces every contributor to opt INTO writing a new field rather than out of forgetting one.

11.2 CI / PR gates โ€” catch leaks before merge โ€‹

Highest-leverage layer. Code-review humans miss things; CI doesn't.

ToolRoleSetup effortCost
Semgrep with custom rulesPattern linter that fails PRs on known-leak patterns~1 day to write 10 rules from this auditOSS
Gitleaks in CIBlock PRs that introduce secrets~30 minOSS
TruffleHog weekly scheduledDeep scan with verification (only-verified mode) โ€” catches what gitleaks misses~30 minOSS
CODEOWNERS for privacy pathsRequire privacy-team review on changes to apps/web/src/lib/auth.js, services/api/auth/, services/functions/firebase/modules/userDeletion.js, etc.~30 minFree
PR template with privacy checklist"Did you add a Firestore field / log call / event metadata? If yes, is it encrypted/hashed/explicitly safe?"~30 minFree
Pre-commit hooks (gitleaks-pre-commit)Local-machine grep before commit โ€” defense in depth, bypass-able~15 minFree

Custom Semgrep rules I'd bootstrap with, drawn directly from this audit's findings:

yaml
# 1. Block plaintext phone/email writes to users collection
- id: lantern-users-plaintext-pii
  pattern-either:
    - pattern: setDoc(doc(db, 'users', $UID), { ..., email: $X, ... })
    - pattern: setDoc(doc(db, 'users', $UID), { ..., phone: $X, ... })
  message: "Writing plaintext phone/email to users/{uid}. Use encryptedEmail/phoneHash."
  severity: ERROR

# 2. Block plaintext PII in adminActions writes
- id: lantern-adminactions-targetemail
  pattern: db.collection('adminActions').add({ ..., targetEmail: $X, ... })
  message: "adminActions audit rows should reference targetUserId only, never targetEmail."
  severity: ERROR

# 3. Block console.log on user-derived data outside devLog
- id: lantern-console-log-user
  pattern: console.$METHOD($X.email, ...)
  paths:
    exclude: ["**/*.test.*", "**/devLog.js"]
  severity: WARNING

# 4. Block message writes without encryption wrapper
- id: lantern-messages-plaintext
  pattern: addDoc($MESSAGES_REF, { ..., text: $X, ... })
  message: "Chat message text must be encrypted before write. Use encryptMessageText()."
  severity: ERROR

# 5. Block lantern doc writes without coord truncation
- id: lantern-lantern-coord-untruncated
  pattern-either:
    - pattern: addDoc($LANTERNS_REF, { ..., lat: $LAT, lng: $LNG, ... })
    - pattern: |
        $DOC = { ..., lat: $LAT, lng: $LNG, ... }
        ...
        await addDoc($LANTERNS_REF, $DOC)
  pattern-not-inside: |
    truncateCoord(...)
  message: "Lantern coords must be truncated via truncateCoord() before write."
  severity: ERROR

Plus the analogous rules for phoneReclaims, adminInvites, merchantPasswordResetTokens, and the pinoHttp request-header redaction. ~10 rules total; one-time write.

11.3 Runtime โ€” observe what actually lands โ€‹

Static checks catch known patterns. Runtime checks catch what slips through.

  • Cloud DLP (Data Loss Prevention) API โ€” GCP-native. Schedule a daily inspection job against Firestore + BigQuery. Looks for unexpected PII patterns (phone numbers, email addresses, SSNs, etc.). Alerts on findings to a Slack channel or Cloud Monitoring alert. Strongest single ongoing-defense move. ~half day to set up, free-ish (charged per scan, but Lantern's volume is small).
  • Canary data โ€” Create a synthetic test user (canary-{YYYY-MM-DD}@lantern.local) with a known fingerprint. Periodically grep BigQuery, Cloud Logging, and any exports for that fingerprint. If it shows up where it shouldn't, you have a leak. Optional but effective for paranoid systems.
  • Extend the dual-layer sanitizer โ€” every new sensitive field name added to forbidden-keys list. Already in place; just keep it current.

11.4 External โ€” assume internal eyes miss things โ€‹

WhenWhatCost
Pre-launch (one-time)Pen test firm scoped specifically to privacy. Trail of Bits, Cure53, NCC Group, Bishop Fox. ~1 week focused engagement on "find PII anywhere it shouldn't be."$15โ€“30k
Pre-launch (one-time)Privacy-specific SAST โ€” Privado.ai (privacy-focused) or TerraTrue (privacy lifecycle management). Cheaper alternative to a full pen test.$$$
Post-launch (ongoing)Bug bounty with explicit privacy tier (pay 2โ€“5x normal rates for privacy bugs). HackerOne or Bugcrowd handle logistics.$5k starter pool + payouts
Quarterly (internal)Recurring privacy audit โ€” this doc is the template. Re-run the BQ verification commands in ยง8, the file-grep audits, compare against the baseline. New findings get a dated supplemental section.Internal time, ~half day per quarter

11.5 What NOT to do โ€‹

  • Don't rely on developer discipline alone. "Everyone knows not to write email: data.email to Firestore" is exactly how the chat-message-plaintext bug shipped.
  • Don't ingest user data into a privacy-watching SaaS. A service that watches your data IS your data. Stick to pre-deploy CI checks and infrastructure-level scans (Cloud DLP runs inside your project).
  • Don't buy a privacy-management SaaS at $$$$ pricing until you've outgrown the free tools. Semgrep + Cloud DLP + Gitleaks + a quarterly audit gets you 80% of value at <10% of cost.

If starting from zero today:

  1. Gitleaks in CI (~30 min) โ€” blocks accidental secret commits at PR time. Highest leverage single move.
  2. Semgrep with 10 custom rules drawn from this audit (~1 day) โ€” encodes the dashboard findings as CI gates so future regressions get blocked.
  3. CODEOWNERS for privacy-critical paths (~30 min) โ€” apps/web/src/lib/auth.js, services/api/auth/, services/functions/firebase/modules/userDeletion.js, etc.
  4. PR template with privacy checklist (~30 min) โ€” three questions, mostly cultural.
  5. Cloud DLP scheduled scan (~half day) โ€” daily runtime check across Firestore + BQ.
  6. TruffleHog weekly cron (~30 min) โ€” deep scan complement to gitleaks.
  7. Pre-launch pen test (~2 weeks lead, $15โ€“30k) โ€” schedule when feature-complete on dev.
  8. Bug bounty + quarterly audit cadence (post-launch).

Steps 1โ€“4 are < 2 days total and close most of the practical "regression risk" surface. Steps 5โ€“6 add the runtime layer. Steps 7โ€“8 are the external assurance layer.

12. How to use this doc โ€‹

  • Refer to the dashboard at the top when picking up the next piece of privacy work.
  • Close items in the dashboard by changing ๐ŸŸฆ โ†’ ๐ŸŸข and adding the closing PR link inline in the row. Don't delete rows โ€” historical context is useful for the next audit.
  • New findings between audits get added to the dashboard AND mirrored to the appropriate detail section (ยง6 or ยง9). Date the addition.
  • For a full new audit (e.g., post-Stage-B or post-launch), create a new dated doc in docs/audit/ that compares findings against this plan as the baseline. Don't rewrite this one.
  • Sections ยง2โ€“ยง5, ยง9 are frozen as the audit baseline. If something changes architecturally (Sprint E lands, etc.), reflect that in PRIVACY_HARDENING_ROADMAP.md, not by rewriting history here.

Built with VitePress