feat(alerts): deliver detection alerts server-side, with Expo and Web Push - #257
Merged
Merged
Conversation
Next 16 passes page params as a Promise. The org pages still read params.slug synchronously, so every page under /org/[slug] handed its client component slug=undefined and could not load the org.
The daemon -> cloud pipeline needs the web side of the v1 contract: - Migration: detections gain occurrences/last_detected_at/dedupe_key with a partial unique index; ingest_detections() dedupes on (server, rule, source IP, minute) so resends are safe; ingest_hardening_findings() upserts atomically while respecting acknowledged/resolved; remediation_actions gains `executing` and claim_server_remediations() hands pending dashboard actions to the daemon with a 5-minute lease. - /api/ingest: 1 MB streamed cap, per-event validation with a rejected[] list, one access lookup per batch, heartbeat hostname, daemon remediation rows, contract response; v1 bodies still accepted. - New claim and PATCH (executing -> executed/failed, 409 otherwise) routes. - SDK exports the contract's event/response types. - Dashboard: detections poll every 30 s, show xN and last seen, and honour ?detection=<id> (highlight or pinned); remediations show executing/executed/failed, source and error; online/offline comes from last_seen (3 min); empty states say how to link a server.
A spool replay after a lost response resent remediation events and the dashboard showed each daemon ban twice. The contract now has the daemon attach a stable event_id; ingest_remediations() inserts with ON CONFLICT DO NOTHING on a partial unique (server_id, metadata->>'event_id') index and the skipped rows count as deduplicated. Events without event_id (older daemons) insert as before.
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
ThreatCrush Security Scan16 finding(s) HIGH/CRITICAL: 1 | MEDIUM: 7 | LOW: 8
Snippets are redacted; ThreatCrush never prints matched credential material. |
… Push Alert rules and destinations could be configured in the dashboard but nothing ever sent them: the ingest route stored detections and stopped, and the destination "Test" button answered "not yet implemented" for email, PagerDuty and push. - lib/alerts: dispatchDetectionAlerts() matches enabled rules (min severity, server scope) against newly ingested detections, enforces rate_limit_per_hour per rule and records every attempt in the new alert_deliveries table (sent / failed / rate_limited). It never throws; a broken destination or missing provider key becomes a failed row. - Senders: Slack/Discord/webhook through safeFetch (SSRF guard), email through Resend, PagerDuty Events API v2, push to every device in the org. - lib/push: Expo push (chunks of 100, DeviceNotRegistered tokens deleted) and Web Push via web-push with VAPID (404/410 subscriptions deleted). - Ingest calls the dispatcher via after() so the daemon's request isn't delayed; the test-send route uses the real senders for the remaining types. - push-subscriptions accepts browser Web Push subscriptions (known push services only) and lists the caller's devices; the org alert settings page gets a "Browser notifications" control, and sign-out unsubscribes.
Stacking alerts on ingest v2 left two Severity unions and a string-typed RPC row, which failed next build's type check.
ralyodio
force-pushed
the
feat/cloud-alerts
branch
from
September 25, 2026 20:12
90f795e to
4c45f74
Compare
# Conflicts: # .env.example # apps/web/src/app/api/ingest/__tests__/route.test.ts # apps/web/src/app/api/ingest/route.ts # apps/web/src/app/api/orgs/[id]/alert-destinations/[dest_id]/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
apps/web/src/lib/alerts/).dispatchDetectionAlerts(detections)(the contract's Alerts hook) loads the org's enabledalert_rulesandalert_destinations. A rule fires whenseverity >= min_severity,server_scopeis empty or contains the server, and both the rule and its destination are enabled and in the same org. Every attempt is written to a newalert_deliveriestable (sent/failed/rate_limited, with the error). It never throws: a broken destination, a guard refusal or a missing provider key becomes afailedrow. Until now rules were configurable but nothing sent them.rate_limit_per_hourcounts the rule'ssent+failedrows in the trailing hour. Once the limit is reached, further matches are recorded asrate_limitedand not sent. A rule allows exactly N attempts per hour.safeFetch(SSRF guard, unchanged). Detection text is escaped so a monitored host cannot<!channel>or@everyone. Email goes through Resend, which the web app already uses. PagerDuty uses Events API v2, with one incident per detection viadedup_key. Push goes to everypush_subscriptionsrow of the org.apps/web/src/lib/push/).https://exp.host/--/api/v2/push/sendin chunks of 100. Tokens that returnDeviceNotRegisteredare deleted. OptionalEXPO_ACCESS_TOKEN.web-pushpackage with VAPID keys. Subscriptions that return 404/410 are deleted.{title, body, url, tag}, which is exactly whatpublic/sw.jsrenders.urlis/org/<slug>/detections?detection=<id>, which CloudIngest's detections page scrolls to.POST /api/ingestnow selects each inserted detection and callsafter(() => dispatchDetectionAlerts(inserted)), so the daemon's request is not delayed. This is kept minimal because CloudIngest (feat(web): cloud ingest v2, remediation claim/complete, live dashboard #252) rewrites this route. When merging, hookafter(() => dispatchDetectionAlerts(newDetections))after the detections RPC block; per CloudIngest its rows matchAlertableDetection.alert-destinations/[dest_id]/route.ts, only thedefault:branch changed. It used to answer "delivery not yet implemented server-side" for email, PagerDuty and push; it now callssendTestAlert()through the real senders and returns 502 with the reason on failure. SecurityFixes owns the rest of that file.push-subscriptionsroute.{kind:"webpush", subscription:{endpoint, keys:{p256dh, auth}}}. The endpoint must be https and on a known push service, and the keys must be base64url. The row is stored askeys = {provider:"webpush", p256dh, auth}.{endpoint}.pushManager.subscribe({userVisibleOnly:true, applicationServerKey})and registers the subscription. Turn off unsubscribes and deletes the row.PwaLifecycleunsubscribes the browser on sign-out, so a shared browser stops showing the org's alerts. With no session left, the push service's 410 makes the server delete the row on the next send..env.exampledocuments the VAPID keys and how to generate them,RESEND_API_KEYfor email alerts and the optionalEXPO_ACCESS_TOKEN. Inapps/mobile/README.md, the sentence "Nothing sends pushes yet" is replaced (CopyAndDocs informed).web-push@^3.6.7plus@types/web-push(dev) inapps/web. The lockfile was updated with pnpm. pnpm also re-resolved thejitipeer in the existing vite/electron-vite entries (2.6.1 → 1.21.7); I didn't change that directly.How verified
Migration applied to local Supabase (
docker exec supabase_db_threatcrush psql < supabase/migrations/20260925110000_alert_deliveries.sql): table, indexes and policies created.next dev -p 3441with freshly generated VAPID keys (npx web-push generate-vapid-keys), a throwaway user/org/server and 7 destinations with 8 rules. Detections went in through the realPOST /api/ingestwith the user's bearer token. Observedalert_deliveriesrows:sent.failed: Email delivery is not configured (RESEND_API_KEY not set).failed: PagerDuty HTTP 400: Invalid routing key.http://127.0.0.1:39441/internalfailed: Requests to internal addresses are not allowed, and the receiver got nothing on/internal.failed: All 1 registered devices had expired and were removed: a fake Expo token registered through the real route gotDeviceNotRegisteredfrom the real Expo API, and its row was deleted.rate_limit_per_hour = 2: the 1st and 2nd high detections weresent, the 3rd wasrate_limited("Rule limit of 2 alerts per hour reached").How I exercised safeFetch-guarded delivery locally without weakening the guard. The next dev process ran with a
NODE_OPTIONS=--requirestub (smoke only, not committed) that replaced two things underneath the guard:dns/promises.lookupansweredhooks.receiver-smoke.example → 203.0.113.10, an address the guard treats as public.resolveSafeAddress/safeFetchran unmodified, and the internal-URL destination above was still refused. The receiver captured the Slacktext(mentions escaped,<url|View in ThreatCrush>), the Discord embed withallowed_mentions:{parse:[]}, and the webhook JSON withX-ThreatCrush-Signature. I recomputed HMAC-SHA256 over the captured body and it matched.Real Web Push, two ways:
POST /api/orgs/:id/push-subscriptions. After an ingested detection, it received the push fromupdates.push.services.mozilla.comand decrypted it (aes128gcm) to{title:"[HIGH] Web push smoke detection", body:"Server: web-1\nSource IP: …", url:"…/org/<slug>/detections?detection=<id>", tag:"detection-<id>"}. I then unregistered a second channel at autopush and ingested again: the result wassent to 1 of 2 devicesand the dead subscription's row was deleted (404/410 cleanup).jmt17.google.com, so I added it to the allowlist. An ingested critical detection then arrived as a real notification shown bysw.js(fromregistration.getNotifications()): title/body/tag match, anddata.urlis the detection link. The delivery row saidsent to 2 of 2 devices. Turn off removed the local subscription and the server row. With notification permission denied, the card showed the "blocked" message and no button.ServiceWorker.deliverPushMessage(CDP) with the server payload produced a notification withdata.urlset.Test-send route on the dev server: Push →
{"success":true}; Email →502 Test failed: Email delivery is not configured (RESEND_API_KEY not set); PagerDuty →502 Test failed: PagerDuty HTTP 400: Invalid routing key.pnpm --filter @profullstack/threatcrush-web test: 61 files, 541 tests passed. The pre-commit hook built the CLI and web (TypeScript included) successfully.Not verified:
sw.js'snotificationclickopensdata.url, which I confirmed is set to the detection link.Blocked on the owner
NEXT_PUBLIC_VAPID_PUBLIC_KEY,VAPID_PRIVATE_KEY,VAPID_SUBJECT. Generate once withnpx web-push generate-vapid-keysand keep them stable: rotating them invalidates every browser subscription. The public key is inlined at build time, so set it beforenext build.RESEND_API_KEY(email alerts).EXPO_ACCESS_TOKEN, only if Enhanced push security is on for the Expo project.alerts@threatcrush.com(the domain already sendshello@threatcrush.com).supabase/migrations/20260925110000_alert_deliveries.sqlto supabase.threatcrush.com.apps/mobile/README.md) are needed before Expo pushes reach real phones.Decisions
pushdestination sends to every registered device (Expo + browser) of every member of the org. There is no per-user targeting yet.sentandfailedattempts count toward the rate limit;rate_limitedrows don't. Anulllimit means the column default of 60.X-ThreatCrush-Signature: sha256=<hex HMAC-SHA256(secret, raw body)>. The secret itself is never sent. The CLI daemon's local webhook sends the raw secret in a header namedX-Threatcrush-Signature, so the two differ.{event: "detection.created" | "test", title, severity, message, timestamp, url, detection?}.ThreatCrush Alerts <alerts@threatcrush.com>to the destination'sto(up to 10 comma-separated addresses). Customer SMTP settings were dropped from the form because nothing used them.NEXT_PUBLIC_VAPID_PUBLIC_KEY, the name.env.examplealready had, instead of adding a separateVAPID_PUBLIC_KEY.fcm.googleapis.com: Chrome and Chromium-based browsers.jmt17.google.com: open-source Chromium builds.updates.push.services.mozilla.com: Firefox.*.push.apple.com: Safari.*.notify.windows.com: Edge.alert_deliverieshas no retention job yet. Rate-limited rows accumulate under floods (CloudIngest's dedup reduces this).