slash-port 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,271 @@
1
+ import { readFile, readlink } from 'node:fs/promises';
2
+ import { run } from './scan/shared.js';
3
+ /** Never let a probe take the interface down with it. */
4
+ async function attempt(work) {
5
+ try {
6
+ return await work();
7
+ }
8
+ catch {
9
+ return null;
10
+ }
11
+ }
12
+ /**
13
+ * The same, but keeping the one distinction that carries advice: a permission
14
+ * failure is somebody else's process and `sudo` would answer it, where a
15
+ * missing file is a process that exited between the scan and the probe. Both
16
+ * would otherwise arrive as the same silence.
17
+ */
18
+ async function attemptOrDenied(work) {
19
+ try {
20
+ return { value: await work() };
21
+ }
22
+ catch (error) {
23
+ const code = error.code;
24
+ return code === 'EACCES' || code === 'EPERM' ? { denied: true } : null;
25
+ }
26
+ }
27
+ /**
28
+ * lsof exits 1 when it matched nothing, which is a normal outcome and not a
29
+ * failure. Treating it as one is why macOS could report a count or nothing at
30
+ * all, but never "nothing connected" - the one answer that is often the point.
31
+ */
32
+ async function runTolerant(command, args) {
33
+ try {
34
+ return (await run(command, args)).stdout;
35
+ }
36
+ catch (error) {
37
+ const failure = error;
38
+ if (failure.code === 'ENOENT')
39
+ return null;
40
+ return typeof failure.stdout === 'string' ? failure.stdout : null;
41
+ }
42
+ }
43
+ /**
44
+ * Count established connections in lsof's field output.
45
+ *
46
+ * `-iTCP:PORT` matches a socket whose local *or* remote port is the one asked
47
+ * about, so every loopback connection appears twice - once from each end. Only
48
+ * the end whose local side carries the port is counted, or a dev server with
49
+ * three browser tabs open reports six.
50
+ */
51
+ export function countLsofEstablished(output, port) {
52
+ let total = 0;
53
+ for (const line of output.split('\n')) {
54
+ if (!line.startsWith('n'))
55
+ continue;
56
+ const arrow = line.indexOf('->');
57
+ if (arrow === -1)
58
+ continue;
59
+ if (line.slice(1, arrow).endsWith(`:${port}`))
60
+ total += 1;
61
+ }
62
+ return total;
63
+ }
64
+ /** `[[DD-]HH:]MM:SS` as ps writes it. */
65
+ export function parseElapsed(value) {
66
+ const match = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(value.trim());
67
+ if (!match)
68
+ return null;
69
+ const [, days, hours, minutes, seconds] = match;
70
+ return (Number(days ?? 0) * 86400 +
71
+ Number(hours ?? 0) * 3600 +
72
+ Number(minutes ?? 0) * 60 +
73
+ Number(seconds ?? 0));
74
+ }
75
+ /**
76
+ * `/proc/[pid]/stat`, whose second field is a command name in parentheses that
77
+ * may itself contain spaces and parentheses. Everything positional has to be
78
+ * counted from the last `)`, which is why this is not a `split`.
79
+ */
80
+ export function parseProcStat(content) {
81
+ const close = content.lastIndexOf(')');
82
+ if (close === -1)
83
+ return null;
84
+ const fields = content.slice(close + 2).trim().split(/\s+/);
85
+ // Fields after `comm` start at index 0 = state, so ppid is 1 and starttime,
86
+ // field 22 in the manual page, is 19 here.
87
+ const ppid = Number.parseInt(fields[1] ?? '', 10);
88
+ const startTicks = Number.parseInt(fields[19] ?? '', 10);
89
+ if (!Number.isInteger(ppid) || !Number.isInteger(startTicks))
90
+ return null;
91
+ return { ppid, startTicks };
92
+ }
93
+ function formatClock(startedMsAgo, now) {
94
+ return new Date(now - startedMsAgo).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
95
+ }
96
+ async function inspectLinux(entry, pid) {
97
+ const detail = {};
98
+ const stat = await attempt(() => readFile(`/proc/${pid}/stat`, 'utf8'));
99
+ const parsed = stat === null ? null : parseProcStat(stat);
100
+ if (parsed) {
101
+ detail.parentPid = parsed.ppid;
102
+ const uptime = await attempt(() => readFile('/proc/uptime', 'utf8'));
103
+ if (uptime) {
104
+ // The kernel counts a process's start in clock ticks since boot, so its
105
+ // age is the machine's age minus that. 100 ticks a second is the value
106
+ // Linux has shipped for decades and there is no portable way to read it.
107
+ const seconds = Number.parseFloat(uptime.split(/\s+/)[0] ?? '') - parsed.startTicks / 100;
108
+ if (Number.isFinite(seconds) && seconds >= 0) {
109
+ detail.uptimeSeconds = seconds;
110
+ detail.startedAt = formatClock(seconds * 1000, Date.now());
111
+ }
112
+ }
113
+ const comm = await attempt(() => readFile(`/proc/${parsed.ppid}/comm`, 'utf8'));
114
+ if (comm)
115
+ detail.parentName = comm.trim();
116
+ }
117
+ const status = await attempt(() => readFile(`/proc/${pid}/status`, 'utf8'));
118
+ const rss = status === null ? null : /^VmRSS:\s+(\d+)\s+kB$/m.exec(status);
119
+ if (rss?.[1])
120
+ detail.rssBytes = Number.parseInt(rss[1], 10) * 1024;
121
+ // Readable only for your own processes, which is exactly when it is useful.
122
+ // A permission failure is worth saying out loud, because `sudo` answers it.
123
+ const cwd = await attemptOrDenied(() => readlink(`/proc/${pid}/cwd`));
124
+ if (cwd && 'value' in cwd)
125
+ detail.cwd = cwd.value;
126
+ else if (cwd && 'denied' in cwd)
127
+ detail.cwdDenied = true;
128
+ const established = await attempt(() => countEstablishedLinux(entry.port));
129
+ if (established !== null)
130
+ detail.established = established;
131
+ return detail;
132
+ }
133
+ /**
134
+ * Connections on this port in state 01, ESTABLISHED. Read from the same tables
135
+ * the Linux scanner already parses, so this costs a file read and no process.
136
+ */
137
+ async function countEstablishedLinux(port) {
138
+ const hex = port.toString(16).toUpperCase().padStart(4, '0');
139
+ let total = 0;
140
+ for (const table of ['/proc/net/tcp', '/proc/net/tcp6']) {
141
+ const content = await attempt(() => readFile(table, 'utf8'));
142
+ if (!content)
143
+ continue;
144
+ for (const line of content.split('\n').slice(1)) {
145
+ const fields = line.trim().split(/\s+/);
146
+ if (fields.length < 4)
147
+ continue;
148
+ if (!fields[1]?.endsWith(`:${hex}`))
149
+ continue;
150
+ if (fields[3] === '01')
151
+ total += 1;
152
+ }
153
+ }
154
+ return total;
155
+ }
156
+ async function inspectDarwin(entry, pid) {
157
+ const detail = {};
158
+ const ps = await attempt(() => run('ps', ['-p', String(pid), '-o', 'ppid=,etime=,rss=,pcpu=']));
159
+ // Each field independently: one blank column must not take the other three
160
+ // down with it.
161
+ const fields = ps?.stdout.trim().split(/\s+/) ?? [];
162
+ const ppid = Number.parseInt(fields[0] ?? '', 10);
163
+ if (Number.isInteger(ppid)) {
164
+ detail.parentPid = ppid;
165
+ const parent = await attempt(() => run('ps', ['-p', String(ppid), '-o', 'comm=']));
166
+ // `comm` is a full path on macOS; the last component is the name.
167
+ const name = parent?.stdout.trim().split('/').pop();
168
+ if (name)
169
+ detail.parentName = name;
170
+ }
171
+ const elapsed = fields[1] === undefined ? null : parseElapsed(fields[1]);
172
+ if (elapsed !== null) {
173
+ detail.uptimeSeconds = elapsed;
174
+ detail.startedAt = formatClock(elapsed * 1000, Date.now());
175
+ }
176
+ const rssKb = Number.parseInt(fields[2] ?? '', 10);
177
+ if (Number.isInteger(rssKb))
178
+ detail.rssBytes = rssKb * 1024;
179
+ const cpu = Number.parseFloat(fields[3] ?? '');
180
+ if (Number.isFinite(cpu))
181
+ detail.cpuPercent = cpu;
182
+ // Two lsof runs, and neither needs the other's answer: waiting for them one
183
+ // after the other doubles how long the panel takes to fill in for nothing.
184
+ // The connection count is only asked for when the process is ours, because
185
+ // lsof cannot see another user's sockets and a count that saw half of them
186
+ // is worse than no count at all.
187
+ const [cwd, connections] = await Promise.all([
188
+ runTolerant('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn']),
189
+ entry.elevation === null
190
+ ? runTolerant('lsof', ['-nP', `-iTCP:${entry.port}`, '-sTCP:ESTABLISHED', '-Fn'])
191
+ : Promise.resolve(null),
192
+ ]);
193
+ const cwdLine = cwd?.split('\n').find((line) => line.startsWith('n/'));
194
+ if (cwdLine)
195
+ detail.cwd = cwdLine.slice(1);
196
+ else if (cwd !== null && entry.elevation !== null)
197
+ detail.cwdDenied = true;
198
+ if (connections !== null)
199
+ detail.established = countLsofEstablished(connections, entry.port);
200
+ return detail;
201
+ }
202
+ /**
203
+ * Windows gives up its process table cheaply and the rest of it expensively.
204
+ * `tasklist` is already a dependency of the scanner and answers memory;
205
+ * parents, working directories, and start times all need WMI or PowerShell,
206
+ * which cost about a second each, so those lines are simply not offered there.
207
+ */
208
+ async function inspectWin32(entry, pid) {
209
+ const detail = {};
210
+ const tasklist = await attempt(() => run('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH']));
211
+ // The thousands separator is whatever the locale says: a comma, a full stop,
212
+ // a space, or the non-breaking space French Windows uses.
213
+ const memory = tasklist === null ? null : /"([^"]+) K"\s*$/m.exec(tasklist.stdout.trim());
214
+ if (memory?.[1]) {
215
+ const kb = Number.parseInt(memory[1].replace(/\D/g, ''), 10);
216
+ if (Number.isInteger(kb))
217
+ detail.rssBytes = kb * 1024;
218
+ }
219
+ const netstat = await attempt(() => run('netstat', ['-ano', '-p', 'TCP']));
220
+ if (netstat)
221
+ detail.established = countNetstatEstablished(netstat.stdout, entry.port);
222
+ return detail;
223
+ }
224
+ /**
225
+ * Established connections on a port, from `netstat -ano`.
226
+ *
227
+ * The state word is translated on a localised Windows - `HERGESTELLT` on a
228
+ * German build - so the row's shape decides rather than its wording, the same
229
+ * way the Windows scanner already decides what is listening: a TCP row whose
230
+ * local port is the one asked about and whose foreign port is not zero is a
231
+ * connection to it.
232
+ */
233
+ export function countNetstatEstablished(output, port) {
234
+ let total = 0;
235
+ for (const line of output.split('\n')) {
236
+ const fields = line.trim().split(/\s+/);
237
+ if (fields.length < 4 || fields[0]?.toUpperCase() !== 'TCP')
238
+ continue;
239
+ if (!fields[1]?.endsWith(`:${port}`))
240
+ continue;
241
+ const peer = fields[2];
242
+ if (!peer)
243
+ continue;
244
+ const peerPort = Number.parseInt(peer.slice(peer.lastIndexOf(':') + 1), 10);
245
+ if (Number.isInteger(peerPort) && peerPort > 0)
246
+ total += 1;
247
+ }
248
+ return total;
249
+ }
250
+ /**
251
+ * Look a single process up in more detail, on demand.
252
+ *
253
+ * This is deliberately not part of the scan. A machine with four hundred
254
+ * listening sockets would pay four hundred times over for facts the reader is
255
+ * looking at one row at a time, so advanced mode asks only about the row under
256
+ * the cursor, and asks again when the cursor moves.
257
+ */
258
+ export async function inspectProcess(entry, platform = process.platform) {
259
+ if (entry.pid === null)
260
+ return {};
261
+ switch (platform) {
262
+ case 'linux':
263
+ return inspectLinux(entry, entry.pid);
264
+ case 'darwin':
265
+ return inspectDarwin(entry, entry.pid);
266
+ case 'win32':
267
+ return inspectWin32(entry, entry.pid);
268
+ default:
269
+ return {};
270
+ }
271
+ }
package/dist/kill.js CHANGED
@@ -1,4 +1,4 @@
1
- import { guardReason } from './describe.js';
1
+ import { elevationRemedy, guardReason } from './describe.js';
2
2
  const defaultWait = (ms) => new Promise((resolve) => {
3
3
  setTimeout(resolve, ms);
4
4
  });
@@ -20,7 +20,7 @@ export function probe(pid, kill = process.kill) {
20
20
  }
21
21
  }
22
22
  function describeTarget(entry) {
23
- const name = entry.processName ?? entry.label;
23
+ const name = entry.processName ?? entry.label ?? 'the process';
24
24
  return `${name} (pid ${entry.pid}) on port ${entry.port}`;
25
25
  }
26
26
  /**
@@ -35,7 +35,7 @@ function describeTarget(entry) {
35
35
  * On Windows there is no signal delivery: SIGTERM becomes TerminateProcess,
36
36
  * which cannot be caught or ignored, so a process never gets the chance to
37
37
  * shut down cleanly and `survived` is unreachable. The grace period still
38
- * applies — it is how long the process is given to disappear.
38
+ * applies - it is how long the process is given to disappear.
39
39
  */
40
40
  export async function killEntry(entry, options = {}) {
41
41
  const { signal = 'SIGTERM', graceMs = 3000, pollMs = 50, kill = process.kill, wait = defaultWait } = options;
@@ -51,7 +51,7 @@ export async function killEntry(entry, options = {}) {
51
51
  return {
52
52
  status: 'unresolved',
53
53
  signal: null,
54
- message: `Port ${entry.port} has an owner slash-port cannot see. Re-run with sudo to resolve it.`,
54
+ message: `Port ${entry.port} has an owner slash-port cannot see. Re-run with ${elevationRemedy()} to resolve it.`,
55
55
  };
56
56
  }
57
57
  const before = probe(entry.pid, kill);
@@ -66,7 +66,7 @@ export async function killEntry(entry, options = {}) {
66
66
  return {
67
67
  status: 'denied',
68
68
  signal: null,
69
- message: `${describeTarget(entry)} belongs to another user. Re-run with sudo to signal it.`,
69
+ message: `${describeTarget(entry)} belongs to another user. Re-run with ${elevationRemedy()} to signal it.`,
70
70
  };
71
71
  }
72
72
  try {
@@ -85,7 +85,7 @@ export async function killEntry(entry, options = {}) {
85
85
  return {
86
86
  status: 'denied',
87
87
  signal: null,
88
- message: `${describeTarget(entry)} belongs to another user. Re-run with sudo to signal it.`,
88
+ message: `${describeTarget(entry)} belongs to another user. Re-run with ${elevationRemedy()} to signal it.`,
89
89
  };
90
90
  }
91
91
  return {
package/dist/mode.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Everything decided before the first socket is read, and decided the same way
3
+ * every slash-* tool decides it: flags, then environment, then the default.
4
+ * There is no config file to consult, and there is not going to be one - this
5
+ * tool reads the socket table and writes nothing.
6
+ */
7
+ export const MODES = ['beginner', 'advanced'];
8
+ /**
9
+ * Beginner is the default, and deliberately so. The person who does not know
10
+ * what took port 3000 is the person who went looking for a tool that would
11
+ * tell them; someone who already knows can say `--advanced` once, or set
12
+ * `SLASH_PORT_MODE` and never say it again.
13
+ */
14
+ export const DEFAULT_MODE = 'beginner';
15
+ export function isMode(value) {
16
+ return MODES.includes(value);
17
+ }
18
+ export function resolveMode(flag, env = process.env) {
19
+ if (flag)
20
+ return flag;
21
+ const configured = env['SLASH_PORT_MODE']?.trim().toLowerCase();
22
+ if (configured && isMode(configured))
23
+ return configured;
24
+ return DEFAULT_MODE;
25
+ }
26
+ const TRUTHY = new Set(['1', 'true', 'yes', 'on']);
27
+ const FALSY = new Set(['0', 'false', 'no', 'off']);
28
+ /**
29
+ * Whether to ask the local Docker engine which container publishes a port.
30
+ *
31
+ * Off unless asked for. It is the only thing slash-port asks anything, and a
32
+ * tool whose whole claim is that it reads two local tables and stops should
33
+ * not quietly start talking to a daemon because a daemon happened to be there.
34
+ * `--docker` turns it on for one run; `SLASH_PORT_DOCKER=1` turns it on for
35
+ * good, and `--no-docker` still overrules that.
36
+ */
37
+ export function resolveDocker(flag, env = process.env) {
38
+ if (flag !== null)
39
+ return flag;
40
+ const configured = env['SLASH_PORT_DOCKER']?.trim().toLowerCase();
41
+ if (configured && TRUTHY.has(configured))
42
+ return true;
43
+ if (configured && FALSY.has(configured))
44
+ return false;
45
+ return false;
46
+ }
package/dist/ports.js CHANGED
@@ -24,7 +24,7 @@ function portNumber(raw) {
24
24
  /**
25
25
  * `3xxx` is every four-digit port beginning with 3, so a pattern is matched
26
26
  * against the port's digits rather than its value: `xxx` is 100 to 999, not 0
27
- * to 999. The bounds come from replacing every `x` with 0 and with 9 — a
27
+ * to 999. The bounds come from replacing every `x` with 0 and with 9 - a
28
28
  * leading `x` with 1, because no port is written with a leading zero.
29
29
  */
30
30
  function parsePattern(text) {
@@ -95,8 +95,8 @@ export function looksLikePort(raw) {
95
95
  return /^\d/.test(raw) || /^x[\dx]*$/i.test(raw);
96
96
  }
97
97
  /**
98
- * The filter box reads `3xxx` and `3000:3005` as ports, and everything else —
99
- * including a bare `3000` — as a substring, because the filter still has to
98
+ * The filter box reads `3xxx` and `3000:3005` as ports, and everything else -
99
+ * including a bare `3000` - as a substring, because the filter still has to
100
100
  * find pids, users, and project names. Half-typed input is not an error there,
101
101
  * so an unreadable selector is simply not one.
102
102
  */
@@ -1,4 +1,5 @@
1
- import { describe, guardReason } from '../describe.js';
1
+ import { assessRisk, browserUrl, describe, elevationReason, guardReason } from '../describe.js';
2
+ import { annotateContainers } from '../docker.js';
2
3
  import { ScanError } from '../types.js';
3
4
  import { scanDarwin } from './darwin.js';
4
5
  import { scanLinux } from './linux.js';
@@ -46,11 +47,18 @@ export function collapse(sockets) {
46
47
  user: richest.user ?? first.user,
47
48
  };
48
49
  const description = describe(base);
50
+ const guard = guardReason(base);
51
+ const elevation = elevationReason(base);
52
+ // Risk needs the guard and the elevation, and the URL needs the addresses,
53
+ // so both are settled here rather than inside `describe`, which only ever
54
+ // sees one socket's worth of the picture.
55
+ const shape = { ...base, ...description, guard, elevation };
56
+ const verdict = assessRisk(shape);
49
57
  entries.push({
50
- ...base,
51
- label: description.label,
52
- hint: description.hint,
53
- guard: guardReason(base),
58
+ ...shape,
59
+ url: browserUrl(shape),
60
+ risk: verdict.risk,
61
+ riskReason: verdict.reason,
54
62
  });
55
63
  }
56
64
  return sortEntries(entries);
@@ -58,16 +66,29 @@ export function collapse(sockets) {
58
66
  export function sortEntries(entries) {
59
67
  return entries.sort((a, b) => a.port - b.port || PROTOCOL_ORDER[a.protocol] - PROTOCOL_ORDER[b.protocol] || (a.pid ?? 0) - (b.pid ?? 0));
60
68
  }
61
- /** Read the local socket table. Nothing in this path touches the network. */
62
- export async function scan(options = {}) {
69
+ async function scanPlatform(options) {
63
70
  switch (process.platform) {
64
71
  case 'linux':
65
- return collapse(await scanLinux(options));
72
+ return scanLinux(options);
66
73
  case 'darwin':
67
- return collapse(await scanDarwin(options));
74
+ return scanDarwin(options);
68
75
  case 'win32':
69
- return collapse(await scanWin32(options));
76
+ return scanWin32(options);
70
77
  default:
71
78
  throw new ScanError(`slash-port has no scanner for ${process.platform}.`, 'Supported platforms are Linux, macOS, and Windows.');
72
79
  }
73
80
  }
81
+ /**
82
+ * Read the local socket table. Nothing in this path touches anything.
83
+ *
84
+ * The one thing that could is the container pass, and it only runs when it was
85
+ * asked for. What it talks to is a unix socket on this machine - the same kind
86
+ * of local file the scanners read - but "reads two local tables and stops" is
87
+ * a property worth keeping by default rather than one to opt back into.
88
+ */
89
+ export async function scan(options = {}) {
90
+ const entries = collapse(await scanPlatform(options));
91
+ if (options.docker !== true)
92
+ return entries;
93
+ return annotateContainers(entries);
94
+ }
@@ -18,7 +18,7 @@ export function parseNetstat(output) {
18
18
  if (protocol !== 'TCP' && protocol !== 'UDP')
19
19
  continue;
20
20
  // Localised builds translate the state word, so a TCP row also counts as
21
- // listening when it has no peer — only LISTEN has a foreign port of zero.
21
+ // listening when it has no peer - only LISTEN has a foreign port of zero.
22
22
  if (protocol === 'TCP') {
23
23
  if (fields.length < 5)
24
24
  continue;