diff --git a/app/(app)/projects/[id]/security/page.tsx b/app/(app)/projects/[id]/security/page.tsx index bfe8cc27..93d3097a 100644 --- a/app/(app)/projects/[id]/security/page.tsx +++ b/app/(app)/projects/[id]/security/page.tsx @@ -77,6 +77,12 @@ export default async function ProjectSecurityPage({ .order("created_at", { ascending: false }) .limit(10); const scans = (scansData ?? []) as ScanRow[]; + // If the most recent scan is still in flight, hand its id to the client so + // the SSE stream resumes on load. + const activeScanId = + scans[0] && (scans[0].status === "queued" || scans[0].status === "running") + ? scans[0].id + : null; return (
@@ -90,7 +96,7 @@ export default async function ProjectSecurityPage({ Scans run only against your own verified host.

- + {/* Open findings */} diff --git a/app/(app)/projects/[id]/security/request-scan-button.tsx b/app/(app)/projects/[id]/security/request-scan-button.tsx index 1e8d9e5f..18b756b9 100644 --- a/app/(app)/projects/[id]/security/request-scan-button.tsx +++ b/app/(app)/projects/[id]/security/request-scan-button.tsx @@ -1,36 +1,109 @@ "use client"; -import { useState, useTransition } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { requestPortScan } from "@/app/actions/portScans"; -export function RequestScanButton({ projectId }: { projectId: string }) { +type ScanStatus = "queued" | "running" | "done" | "failed"; + +const STATUS_LABEL: Record = { + queued: "Queued…", + running: "Scanning…", + done: "Done", + failed: "Failed", +}; +const STATUS_COLOR: Record = { + queued: "var(--color-muted)", + running: "var(--color-warn)", + done: "var(--color-pass)", + failed: "var(--color-fail)", +}; + +export function RequestScanButton({ + projectId, + activeScanId = null, +}: { + projectId: string; + activeScanId?: string | null; +}) { const router = useRouter(); - const [pending, startTransition] = useTransition(); + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const esRef = useRef(null); + + const subscribe = useCallback( + (scanId: string) => { + esRef.current?.close(); + const es = new EventSource( + `/projects/${projectId}/security/stream?scanId=${encodeURIComponent(scanId)}`, + ); + esRef.current = es; + setBusy(true); + + es.addEventListener("status", (e) => { + const d = JSON.parse((e as MessageEvent).data); + setStatus(d.status as ScanStatus); + }); + es.addEventListener("done", (e) => { + const d = JSON.parse((e as MessageEvent).data); + setStatus(d.scan.status as ScanStatus); + es.close(); + setBusy(false); + // Pull the freshly-persisted findings + history into the server render. + router.refresh(); + }); + es.onerror = () => { + es.close(); + setBusy(false); + }; + }, + [projectId, router], + ); + + // Resume streaming if a scan is already in flight when the page loads. + useEffect(() => { + if (activeScanId) subscribe(activeScanId); + return () => esRef.current?.close(); + }, [activeScanId, subscribe]); - function run() { - startTransition(async () => { - setError(null); - const res = await requestPortScan(projectId); - if (!res.ok) { - setError(res.error ?? "Failed to queue scan."); - return; - } - router.refresh(); - }); + async function run() { + setError(null); + setBusy(true); + setStatus("queued"); + const res = await requestPortScan(projectId); + if (!res.ok || !res.scanId) { + setError(res.error ?? "Failed to queue scan."); + setStatus(null); + setBusy(false); + return; + } + subscribe(res.scanId); } return ( -
- - {error &&

{error}

} +
+
+ {status && ( + + {status === "running" && ( + ● + )} + {STATUS_LABEL[status]} + + )} + +
+ {error &&

{error}

}
); } diff --git a/app/(app)/projects/[id]/security/stream/route.ts b/app/(app)/projects/[id]/security/stream/route.ts new file mode 100644 index 00000000..f82de0fe --- /dev/null +++ b/app/(app)/projects/[id]/security/stream/route.ts @@ -0,0 +1,92 @@ +// SSE stream for live port-scan feedback (uptime-monitoring-prd.md §12.4). +// The browser opens an EventSource here after requesting a scan; we push each +// status change (queued → running → done/failed) and, on completion, the open +// findings — then close. Server-side we poll the DB (which the worker drives +// from the BullMQ result), so the web tier needs no Redis connection. +import { NextRequest } from "next/server"; +import { createClient } from "@/lib/supabase/server"; + +export const dynamic = "force-dynamic"; + +const POLL_MS = 1500; +const MAX_MS = 5 * 60 * 1000; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const scanId = req.nextUrl.searchParams.get("scanId"); + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return new Response("unauthorized", { status: 401 }); + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + let closed = false; + const close = () => { + if (closed) return; + closed = true; + try { + controller.close(); + } catch { + /* already closed */ + } + }; + req.signal.addEventListener("abort", close); + + const send = (event: string, data: unknown) => { + if (closed) return; + controller.enqueue( + encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`), + ); + }; + + const deadline = Date.now() + MAX_MS; + let lastStatus = ""; + try { + while (!closed && Date.now() < deadline) { + const base = supabase + .from("port_scans") + .select("id, status, open_ports, completed_at") + .eq("project_id", id); + const { data: scan } = scanId + ? await base.eq("id", scanId).maybeSingle() + : await base.order("created_at", { ascending: false }).limit(1).maybeSingle(); + + if (scan) { + if (scan.status !== lastStatus) { + lastStatus = scan.status; + send("status", scan); + } + if (scan.status === "done" || scan.status === "failed") { + const { data: findings } = await supabase + .from("port_findings") + .select("id, port, service, severity, state") + .eq("project_id", id) + .eq("state", "open") + .order("severity", { ascending: false }); + send("done", { scan, findings: findings ?? [] }); + break; + } + } + await new Promise((r) => setTimeout(r, POLL_MS)); + } + } finally { + close(); + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} diff --git a/app/actions/portScans.ts b/app/actions/portScans.ts index 7ddcfda4..8162af75 100644 --- a/app/actions/portScans.ts +++ b/app/actions/portScans.ts @@ -8,7 +8,7 @@ import { createClient } from "@/lib/supabase/server"; // only record the request — RLS ensures the caller owns the project. export async function requestPortScan( projectId: string, -): Promise<{ ok: boolean; error?: string }> { +): Promise<{ ok: boolean; error?: string; scanId?: string }> { const supabase = await createClient(); const { data: { user }, @@ -29,17 +29,21 @@ export async function requestPortScan( return { ok: false, error: "Project URL is not a valid host." }; } - const { error } = await supabase.from("port_scans").insert({ - project_id: projectId, - host, - status: "queued", - requested_by: user.id, - }); + const { data: inserted, error } = await supabase + .from("port_scans") + .insert({ + project_id: projectId, + host, + status: "queued", + requested_by: user.id, + }) + .select("id") + .single(); if (error) { // Most likely the migration hasn't been applied yet in this environment. return { ok: false, error: `Could not queue scan: ${error.message}` }; } revalidatePath(`/projects/${projectId}/security`); - return { ok: true }; + return { ok: true, scanId: inserted.id }; } diff --git a/lib/prober-queue.ts b/lib/prober-queue.ts new file mode 100644 index 00000000..4a1bba7b --- /dev/null +++ b/lib/prober-queue.ts @@ -0,0 +1,178 @@ +// Railway-side bridge between the port_scans table and the DO-droplet prober +// (uptime-monitoring-prd.md §12). The web UI inserts a `queued` port_scans row; +// this module (run from worker/index.ts on a short interval) enqueues a BullMQ +// job to the "prober" queue, then reconciles finished jobs back into the DB. +// +// The droplet writes no DB — it returns the result as the job's return value, +// which we read here and persist (scan row + findings). Keeps Supabase creds +// off the droplet. +import { Queue, type ConnectionOptions, type Job } from "bullmq"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import { PROBER_QUEUE, type PortScanResult } from "./prober"; + +// Ports that are alarming when publicly exposed (databases, caches, admin, etc.). +const HIGH_RISK_PORTS = new Set([ + 1433, 1521, 2379, 3306, 3389, 5432, 5433, 5984, 6379, 6380, 9200, 9300, + 11211, 27017, 27018, +]); +const LOW_RISK_PORTS = new Set([80, 443]); + +// A running scan older than this is treated as timed out. +const RUNNING_TIMEOUT_MS = 15 * 60 * 1000; + +let queue: Queue | null = null; + +// Parse REDIS_URL into bullmq connection options so bullmq builds its own +// ioredis client (avoids a version clash between our ioredis and bullmq's). +function connectionOptions(): ConnectionOptions | null { + const url = process.env.REDIS_URL; + if (!url) return null; + const u = new URL(url); + return { + host: u.hostname, + port: Number(u.port || "6379"), + username: u.username ? decodeURIComponent(u.username) : undefined, + password: u.password ? decodeURIComponent(u.password) : undefined, + tls: u.protocol === "rediss:" ? {} : undefined, + maxRetriesPerRequest: null, + }; +} + +export function getProberQueue(): Queue | null { + const connection = connectionOptions(); + if (!connection) return null; + if (!queue) queue = new Queue(PROBER_QUEUE, { connection }); + return queue; +} + +function severityFor(port: number): "low" | "medium" | "high" { + if (HIGH_RISK_PORTS.has(port)) return "high"; + if (LOW_RISK_PORTS.has(port)) return "low"; + return "medium"; +} + +interface ScanRow { + id: string; + project_id: string; + host: string; + status: string; + created_at: string; +} + +async function persistResult( + supabase: SupabaseClient, + scan: ScanRow, + result: PortScanResult, +): Promise { + const openPorts = result.openPorts.map((p) => p.port); + + await supabase + .from("port_scans") + .update({ + status: "done", + open_ports: openPorts, + completed_at: new Date().toISOString(), + }) + .eq("id", scan.id); + + if (result.openPorts.length > 0) { + // Insert new findings; existing (project_id, port) rows keep their state + // (e.g. an accepted baseline entry) thanks to ignoreDuplicates. + const rows = result.openPorts.map((p) => ({ + project_id: scan.project_id, + scan_id: scan.id, + port: p.port, + service: p.service ?? null, + severity: severityFor(p.port), + state: "open" as const, + })); + await supabase + .from("port_findings") + .upsert(rows, { onConflict: "project_id,port", ignoreDuplicates: true }); + } +} + +async function markFailed( + supabase: SupabaseClient, + scanId: string, + reason: string, +): Promise { + await supabase + .from("port_scans") + .update({ status: "failed", error: reason, completed_at: new Date().toISOString() }) + .eq("id", scanId); +} + +// Enqueue newly-queued scans and reconcile ones already running. Safe to call +// on a short interval; no-ops cleanly when REDIS_URL isn't configured. +export async function processDuePortScans( + supabase: SupabaseClient, +): Promise<{ enqueued: number; completed: number; failed: number }> { + const q = getProberQueue(); + if (!q) return { enqueued: 0, completed: 0, failed: 0 }; + + let enqueued = 0; + let completed = 0; + let failed = 0; + + // 1) queued -> enqueue BullMQ job -> running + const { data: queuedRows } = await supabase + .from("port_scans") + .select("id, project_id, host, status, created_at") + .eq("status", "queued") + .limit(20); + + for (const scan of (queuedRows ?? []) as ScanRow[]) { + try { + // jobId = scan.id makes enqueue idempotent across retries. + await q.add( + "scan", + { host: scan.host, monitorId: scan.id }, + { + jobId: scan.id, + removeOnComplete: { age: 3600 }, + removeOnFail: { age: 86400 }, + }, + ); + await supabase.from("port_scans").update({ status: "running" }).eq("id", scan.id); + enqueued++; + } catch (e) { + await markFailed(supabase, scan.id, `enqueue failed: ${(e as Error).message}`); + failed++; + } + } + + // 2) running -> read finished job return value -> done / failed + const { data: runningRows } = await supabase + .from("port_scans") + .select("id, project_id, host, status, created_at") + .eq("status", "running") + .limit(50); + + for (const scan of (runningRows ?? []) as ScanRow[]) { + const job = (await q.getJob(scan.id)) as Job | undefined; + const age = Date.now() - new Date(scan.created_at).getTime(); + + if (!job) { + if (age > RUNNING_TIMEOUT_MS) { + await markFailed(supabase, scan.id, "prober job missing"); + failed++; + } + continue; + } + + const state = await job.getState(); + if (state === "completed") { + await persistResult(supabase, scan, job.returnvalue as PortScanResult); + completed++; + } else if (state === "failed") { + await markFailed(supabase, scan.id, job.failedReason ?? "prober job failed"); + failed++; + } else if (age > RUNNING_TIMEOUT_MS) { + await markFailed(supabase, scan.id, "scan timed out"); + failed++; + } + } + + return { enqueued, completed, failed }; +} diff --git a/package-lock.json b/package-lock.json index c1632afc..af6248e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,9 @@ "@supabase/ssr": "^0.10.3", "@supabase/supabase-js": "^2.105.4", "@types/nodemailer": "^8.0.0", + "bullmq": "^5.79.2", "cheerio": "^1.0.0", + "ioredis": "^5.11.1", "linkinator": "^7.6.1", "marked": "^14.1.3", "maxmind": "^5.0.6", @@ -1033,6 +1035,12 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, "node_modules/@ip-location-db/geolite2-city-mmdb": { "version": "2.3.2026052019", "resolved": "https://registry.npmjs.org/@ip-location-db/geolite2-city-mmdb/-/geolite2-city-mmdb-2.3.2026052019.tgz", @@ -1248,6 +1256,84 @@ "win32" ] }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -2603,6 +2689,61 @@ "node": "18 || 20 || >=22" } }, + "node_modules/bullmq": { + "version": "5.79.2", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.79.2.tgz", + "integrity": "sha512-FebD+8XCZl/hnS1R4to24L4EAN70XSndKZO0776M36vGRk5MKVhKlNFM8/34zXLXKyYB4QaeIPFhVXSYYGTHpQ==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.10.1", + "msgpackr": "2.0.4", + "node-abort-controller": "3.1.1", + "semver": "7.8.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "redis": ">=5.0.0" + }, + "peerDependenciesMeta": { + "redis": { + "optional": true + } + } + }, + "node_modules/bullmq/node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/bullmq/node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2731,6 +2872,15 @@ "node": ">=6" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2762,6 +2912,18 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -3010,6 +3172,23 @@ "node": ">=12" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -3034,6 +3213,15 @@ "robust-predicates": "^3.0.2" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3629,6 +3817,28 @@ "node": ">=12" } }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/jerrypick": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz", @@ -4038,6 +4248,18 @@ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -4059,6 +4281,15 @@ "node": "20 || >=22" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4177,6 +4408,43 @@ "npm": ">=6" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.4.tgz", + "integrity": "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -4276,6 +4544,27 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/nodemailer": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz", @@ -4761,6 +5050,27 @@ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -4869,11 +5179,10 @@ } }, "node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, @@ -5039,6 +5348,12 @@ "dev": true, "license": "MIT" }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/standardwebhooks": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", diff --git a/package.json b/package.json index f7f6af2c..e05342f8 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "@supabase/ssr": "^0.10.3", "@supabase/supabase-js": "^2.105.4", "@types/nodemailer": "^8.0.0", + "bullmq": "^5.79.2", "cheerio": "^1.0.0", + "ioredis": "^5.11.1", "linkinator": "^7.6.1", "marked": "^14.1.3", "maxmind": "^5.0.6", diff --git a/worker/index.ts b/worker/index.ts index 44dd256d..56ba9e32 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -36,6 +36,7 @@ import { processBrowserPost } from "../lib/sp/browserPost"; import { getOrMintInstallationToken } from "../lib/github/installations"; import { listInstallationRepos } from "../lib/github/app"; import { processUserAlerts } from "../lib/alerts/worker"; +import { processDuePortScans } from "../lib/prober-queue"; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; @@ -1257,6 +1258,19 @@ setInterval( () => auditStuckSweep().catch((e) => console.error("[worker] audit stuck sweep", e)), 60_000, ); +// Port-drift scans: bridge queued rows to the "prober" BullMQ queue and +// reconcile results (uptime-monitoring-prd.md §12). Short interval so the +// Security tab's SSE stream gets snappy queued→running→done feedback. +async function portScanSweep() { + const r = await processDuePortScans(supabase); + if (r.enqueued || r.completed || r.failed) { + console.log("[worker] port scan sweep", r); + } +} +setInterval( + () => portScanSweep().catch((e) => console.error("[worker] port scan sweep", e)), + 5_000, +); // Bind to loopback by default so the worker isn't reachable from the public // internet when colocated with the app. Override with WORKER_BIND=0.0.0.0 to