Skip to content

Commit 1d607fb

Browse files
ralyodioclaude
andauthored
feat(prober): scan all 65535 ports (nmap -p-), stretch timeouts (#85)
Full TCP port scan instead of top-100 — the point of exposed-services drift is to catch anything open. Safe because targets are owner-verified and scanning runs on the dedicated off-Railway droplet. - prober: nmap -sT -Pn -p- -T4 --host-timeout 1500s; execFile ceiling 30m. - prober-queue: RUNNING_TIMEOUT_MS 15m -> 40m for long scans. - SSE route: MAX_MS -> 45m + 15s heartbeat so idle proxies don't drop the connection while a long scan sits in 'running'. - PRD §12: document full-range scan + ownership/off-Railway justification. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 101b56c commit 1d607fb

4 files changed

Lines changed: 30 additions & 14 deletions

File tree

‎app/(app)/projects/[id]/security/stream/route.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,13 @@ import { createClient } from "@/lib/supabase/server";
88

99
export const dynamic = "force-dynamic";
1010

11-
const POLL_MS = 1500;
12-
const MAX_MS = 5 * 60 * 1000;
11+
const POLL_MS = 2000;
12+
// A full 65535-port scan can run many minutes; keep the stream open long
13+
// enough to see it finish.
14+
const MAX_MS = 45 * 60 * 1000;
15+
// Emit an SSE comment periodically so idle proxies don't drop the connection
16+
// while a long scan sits in `running` with no status change.
17+
const HEARTBEAT_MS = 15_000;
1318

1419
export async function GET(
1520
req: NextRequest,
@@ -48,8 +53,13 @@ export async function GET(
4853

4954
const deadline = Date.now() + MAX_MS;
5055
let lastStatus = "";
56+
let lastBeat = Date.now();
5157
try {
5258
while (!closed && Date.now() < deadline) {
59+
if (Date.now() - lastBeat >= HEARTBEAT_MS) {
60+
if (!closed) controller.enqueue(encoder.encode(`: keep-alive\n\n`));
61+
lastBeat = Date.now();
62+
}
5363
const base = supabase
5464
.from("port_scans")
5565
.select("id, status, open_ports, completed_at")

‎docs/uptime-monitoring-prd.md‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -251,9 +251,12 @@ a scanner.
251251
can't take down production. GCP and Railway AUPs prohibit network scanning;
252252
scanning from the app IP risks the service's IP. The droplet has its own IP
253253
reputation and raw-socket access. See §12.3 for the job-transport design.
254-
- **Bounded + TCP-connect only.** Curated **top-~100 common service ports**, never
255-
full 65535; TCP `connect()` only (containers lack `CAP_NET_RAW` for SYN scans
256-
anyway); ICMP discovery skipped (`-Pn`-equivalent).
254+
- **Full port range, TCP-connect only.** Scans **all 65535 TCP ports** (`nmap
255+
-sT -Pn -p- -T4`) so nothing exposed is missed — the point of the feature. Only
256+
safe because targets are owner-verified and the scan runs on a dedicated
257+
off-Railway droplet. TCP `connect()` only (containers lack `CAP_NET_RAW` for SYN
258+
scans anyway); ICMP discovery skipped (`-Pn`); a 25-min nmap `--host-timeout`
259+
bounds heavily-filtered hosts.
257260
- **Rate-limited + infrequent.** Daily cadence, not per-minute; per-org caps.
258261

259262
### 12.2 Behavior
@@ -276,8 +279,8 @@ Railway (producer) Redis (broker) DO droplet (prober)
276279
────────────────── ────────────── ───────────────────
277280
API / worker enqueues ──▶ "prober" queue ◀── BullMQ Worker dials OUT
278281
port-scan jobs (rediss:// TLS) over rediss://, runs
279-
(repeatable = daily) nmap -sT -Pn --top-ports
280-
100, returns result
282+
(repeatable = daily) nmap -sT -Pn -p- -T4
283+
(all ports), returns result
281284
Railway QueueEvents ◀─── job "completed" ◀── (result = job return value)
282285
handler persists to
283286
Supabase, diffs

‎lib/prober-queue.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ const HIGH_RISK_PORTS = new Set([
1717
]);
1818
const LOW_RISK_PORTS = new Set([80, 443]);
1919

20-
// A running scan older than this is treated as timed out.
21-
const RUNNING_TIMEOUT_MS = 15 * 60 * 1000;
20+
// A running scan older than this is treated as timed out. Generous because a
21+
// full 65535-port scan (nmap -p-) can take many minutes on a filtered host.
22+
const RUNNING_TIMEOUT_MS = 40 * 60 * 1000;
2223

2324
let queue: Queue | null = null;
2425

‎prober/src/index.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//
33
// A BullMQ Worker running on a self-hosted DigitalOcean droplet. It dials OUT
44
// to Redis (rediss://) — no inbound port — pulls port-scan jobs off the
5-
// "prober" queue, runs a bounded `nmap -sT -Pn --top-ports 100` TCP-connect
5+
// "prober" queue, runs a full `nmap -sT -Pn -p- -T4` (all 65535) TCP-connect
66
// scan, and returns the open-port set as the job's return value. It writes no
77
// database; the Railway side handles results (baseline diff + alerts).
88
import { Worker, type Job, type ConnectionOptions } from "bullmq";
@@ -60,12 +60,14 @@ function assertScannableHost(host: string): void {
6060

6161
async function scan(job: PortScanJob): Promise<PortScanResult> {
6262
assertScannableHost(job.host);
63-
// -sT connect scan (no CAP_NET_RAW needed), -Pn skip host discovery,
64-
// --top-ports 100 bounded set, -oG - greppable output on stdout.
63+
// Full TCP port scan (all 65535): -sT connect scan (no CAP_NET_RAW needed),
64+
// -Pn skip host discovery, -p- every port, -T4 faster timing, and a 25-min
65+
// nmap-side host timeout so a heavily-filtered host returns what it found
66+
// instead of hanging. -oG - greppable output on stdout.
6567
const { stdout } = await pexecFile(
6668
"nmap",
67-
["-sT", "-Pn", "--top-ports", "100", "-oG", "-", job.host],
68-
{ timeout: 180_000, maxBuffer: 8 * 1024 * 1024 },
69+
["-sT", "-Pn", "-p-", "-T4", "--host-timeout", "1500s", "-oG", "-", job.host],
70+
{ timeout: 30 * 60_000, maxBuffer: 16 * 1024 * 1024 },
6971
);
7072

7173
const openPorts: OpenPort[] = [];

0 commit comments

Comments
 (0)