-
- {pending ? "Queuing…" : "Run scan"}
-
- {error &&
{error}
}
+
+
+ {status && (
+
+ {status === "running" && (
+ ●
+ )}
+ {STATUS_LABEL[status]}
+
+ )}
+
+ {busy ? "Working…" : "Run scan"}
+
+
+ {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