portmind-monorepo 0.1.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.
- package/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +161 -0
- package/biome.json +26 -0
- package/package.json +15 -0
- package/packages/cli/package.json +53 -0
- package/packages/cli/src/index.ts +66 -0
- package/packages/cli/src/renderTable.ts +34 -0
- package/packages/cli/tests/cli.test.ts +27 -0
- package/packages/cli/tests/renderTable.test.ts +49 -0
- package/packages/cli/tsconfig.json +9 -0
- package/packages/core/package.json +45 -0
- package/packages/core/src/config.ts +93 -0
- package/packages/core/src/index.ts +4 -0
- package/packages/core/src/scan/enrichProcess.ts +109 -0
- package/packages/core/src/scan/index.ts +13 -0
- package/packages/core/src/scan/parseLsof.ts +74 -0
- package/packages/core/src/scan/scanner.ts +88 -0
- package/packages/core/src/types.ts +35 -0
- package/packages/core/tests/config.test.ts +20 -0
- package/packages/core/tests/parseLsof.test.ts +43 -0
- package/packages/core/tests/scanner.test.ts +28 -0
- package/packages/core/tsconfig.json +9 -0
- package/packages/core/vitest.config.ts +8 -0
- package/pnpm-workspace.yaml +2 -0
- package/tsconfig.base.json +19 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-09-06
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- Monorepo scaffold (`packages/core`, `packages/cli`) with pnpm workspaces, shared TypeScript config, and Biome for lint/format.
|
|
13
|
+
- `@portmind/core`: `PortEntry` data shape and `PortmindConfig` schema with built-in defaults, matching the spec exactly (AI opt-in and off by default, explicit AI field allowlist).
|
|
14
|
+
- `@portmind/core`: real port scanner (Phase 1) - `lsof`-based TCP/UDP listening socket discovery with process enrichment (cmdline, cwd, start time via `ps` and `lsof -d cwd`), deduplicated across dual-stack/duplicate file descriptors.
|
|
15
|
+
- `@portmind/cli`: `portmind list` now runs a real scan and renders a table or `--json`, with working `--range`, `--docker-only`, and `--unusual` filters.
|
|
16
|
+
- Vitest test suite: `DEFAULT_CONFIG` invariants, `lsof` field-output parsing (mocked, no real sockets required), socket deduplication, table rendering, and an end-to-end CLI smoke test.
|
|
17
|
+
- README and this CHANGELOG.
|
|
18
|
+
- MIT `LICENSE`, and publish-ready `package.json` metadata (description, keywords, repository, author, `publishConfig.access: public` for both scoped packages) for an eventual `pnpm publish` of `@portmind/core` and `@portmind/cli`.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- Upgraded all dependencies to latest major versions: `typescript` 7.0.2, `vitest` 5.0.0, `commander` 15.0.0, `better-sqlite3` 13.0.3, `@types/node` 26.4.1, `@types/better-sqlite3` 9.6.0. Added explicit `vite`/`esbuild` devDependencies to satisfy vitest 5's peer requirements. Required adding `"types": ["node"]` to the base tsconfig since TypeScript 7 no longer auto-includes `@types/node`.
|
|
22
|
+
|
|
23
|
+
### Known limitations
|
|
24
|
+
- Docker cross-reference, IANA known-port lookup, local history/"usual" detection, and risk flags are not implemented yet (Phases 3-6 of the build plan) - those fields on `PortEntry` are currently always `null`/empty/`true`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Petri Sandholm
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# portmind
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@portmind/cli)
|
|
4
|
+
[](https://nodejs.org)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
Local-first CLI that scans listening ports on your machine, enriches them with process/Docker/IANA detail, remembers what *normally* runs on each port, and flags what's unusual — no cloud, no telemetry, no background AI calls.
|
|
8
|
+
|
|
9
|
+
> The npm badge above will show "not found" until `@portmind/cli` is actually published. Nothing in this repo has been published yet.
|
|
10
|
+
|
|
11
|
+
## Status
|
|
12
|
+
|
|
13
|
+
**Working today:**
|
|
14
|
+
- `portmind list` runs a real scan (`lsof`-based) of TCP and UDP listening sockets on macOS/Linux
|
|
15
|
+
- Each result is enriched with process command line, working directory, and start time (`ps` + `lsof -d cwd`)
|
|
16
|
+
- `--range <min-max>`, `--docker-only`, `--unusual`, and `--json` filters/output modes
|
|
17
|
+
- Table and JSON output share one `PortEntry` type, defined once in `@portmind/core`
|
|
18
|
+
|
|
19
|
+
**Not implemented yet** (see [Next steps](#next-steps)): Docker cross-reference, IANA known-port descriptions, local history/"usual" detection, risk flags, `watch`/`free`/`explain`/`history`/`ssh`/`web`/`tui`/`config` commands, and the YAML config loader. Until those land, every `PortEntry.docker`, `.knownService`, and `.riskFlags` will be empty, and `.history.usual` is always `true`.
|
|
20
|
+
|
|
21
|
+
See [CHANGELOG.md](./CHANGELOG.md) for a dated record of what shipped when.
|
|
22
|
+
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
- Node.js 22+
|
|
26
|
+
- pnpm 9+
|
|
27
|
+
- macOS or Linux — `lsof` must be on `$PATH` (this is the only scan backend implemented so far; Linux's `ss` is not wired up yet despite being mentioned in the original design)
|
|
28
|
+
- Docker CLI (`docker`) optional — not yet used, but scans are designed to degrade gracefully once Docker cross-referencing is added
|
|
29
|
+
|
|
30
|
+
## Install (development)
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pnpm install
|
|
34
|
+
pnpm build
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
There's no published package yet, so there's nothing to `npm install -g`. Run the CLI directly from this repo:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
node packages/cli/dist/index.js list
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
portmind list # full table, all listening ports on localhost
|
|
47
|
+
portmind list --range 3000-9000 # filter by port range
|
|
48
|
+
portmind list --docker-only # only Docker-backed ports (no-op until Docker cross-reference ships)
|
|
49
|
+
portmind list --unusual # only ports flagged unusual or risky (no-op until history/risk flags ship)
|
|
50
|
+
portmind list --json # machine-readable output for scripting/CI
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Example output:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
PORT PROTO PROCESS PID DOCKER USUAL NOTE
|
|
57
|
+
3000 tcp node 41822 - yes /Users/you/Projects/react-dashboard
|
|
58
|
+
5432 tcp postgres 1758 - yes /opt/homebrew/var/postgresql@14
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Exit codes:** `0` success, `1` scan error, `2` config error.
|
|
62
|
+
|
|
63
|
+
Planned commands not yet implemented: `watch`, `free`, `explain`, `history`, `ssh <host> list|check`, `web`, `tui`, `config show|path`.
|
|
64
|
+
|
|
65
|
+
## Storage
|
|
66
|
+
|
|
67
|
+
History will be stored locally in a SQLite database at `~/.portmind/portmind.db` (configurable) once the history feature (Phase 4) lands. Nothing leaves the machine unless you explicitly run `portmind explain <port>` with AI enabled in config, and even then only an explicit allowlist of fields is sent — never full environment variables or file contents.
|
|
68
|
+
|
|
69
|
+
## Configuration
|
|
70
|
+
|
|
71
|
+
Not implemented yet — there is no config loader, and `~/.portmind/config.yaml` / `.portmind.yaml` are not read. The schema below is the target design (resolution order: built-in defaults → `~/.portmind/config.yaml` → `.portmind.yaml` in the current directory, each layer overriding the previous):
|
|
72
|
+
|
|
73
|
+
```yaml
|
|
74
|
+
scan:
|
|
75
|
+
interval_seconds: 5
|
|
76
|
+
include_udp: true
|
|
77
|
+
docker: true
|
|
78
|
+
ssh_hosts: []
|
|
79
|
+
|
|
80
|
+
known_ports:
|
|
81
|
+
source: iana # iana | custom | both
|
|
82
|
+
custom_db_path: null
|
|
83
|
+
refresh_days: 30
|
|
84
|
+
|
|
85
|
+
history:
|
|
86
|
+
enabled: true
|
|
87
|
+
db_path: ~/.portmind/portmind.db
|
|
88
|
+
retention_days: 180
|
|
89
|
+
|
|
90
|
+
risk_rules:
|
|
91
|
+
flag_bound_all_interfaces: true
|
|
92
|
+
flag_unsigned_binary: true # macOS only, no-op elsewhere
|
|
93
|
+
flag_no_known_service: true
|
|
94
|
+
|
|
95
|
+
ai:
|
|
96
|
+
enabled: false # opt-in, off by default
|
|
97
|
+
provider: anthropic # anthropic | openai | none
|
|
98
|
+
model: claude-sonnet-4-6
|
|
99
|
+
trigger: manual
|
|
100
|
+
fields_sent: # explicit allowlist - nothing outside this list is ever sent
|
|
101
|
+
- process_name
|
|
102
|
+
- cmdline
|
|
103
|
+
- port
|
|
104
|
+
- protocol
|
|
105
|
+
- docker_image
|
|
106
|
+
cmdline_sanitization: true
|
|
107
|
+
|
|
108
|
+
output:
|
|
109
|
+
default_format: table # table | json
|
|
110
|
+
color: auto # auto | always | never
|
|
111
|
+
|
|
112
|
+
logging:
|
|
113
|
+
level: info # debug | info | warn | error
|
|
114
|
+
audit_log: false
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The equivalent TypeScript shape (`PortmindConfig`) and its defaults already exist in [packages/core/src/config.ts](./packages/core/src/config.ts) — only the file-loading/merge logic is missing.
|
|
118
|
+
|
|
119
|
+
## Architecture
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
packages/
|
|
123
|
+
├── core/ # scanning, enrichment, data model, config — no UI, no AI dependency
|
|
124
|
+
├── cli/ # table/JSON output over @portmind/core (implemented)
|
|
125
|
+
├── tui/ # live terminal dashboard — not started
|
|
126
|
+
├── web/ # local HTML dashboard — not started
|
|
127
|
+
└── ai/ # optional AI deep-search plugin — not started
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`core` has zero UI and zero AI dependencies. `cli`, `tui`, and `web` are meant to be thin renderers over the same `PortEntry[]` shape — no duplicated scanning logic between them.
|
|
131
|
+
|
|
132
|
+
## Next steps
|
|
133
|
+
|
|
134
|
+
Remaining phases, in build order:
|
|
135
|
+
|
|
136
|
+
1. **IANA enrichment** — fetch/cache the official IANA Service Name and Port Number Registry, populate `PortEntry.knownService`
|
|
137
|
+
2. **History and "usual" detection** — SQLite `observations`/`port_fingerprints` tables, `history <port>` command
|
|
138
|
+
3. **Docker cross-reference** — match `docker ps` output against scanned ports, populate `PortEntry.docker`
|
|
139
|
+
4. **Risk flags** — `bound_all_interfaces`, `unsigned_binary`, `no_known_service`, each independently configurable
|
|
140
|
+
5. **TUI** (`ink` or `blessed` — undecided) — live table with inline `explain`/`free`
|
|
141
|
+
6. **Web dashboard** — local-only HTTP server, `/api/ports`, static HTML/JS frontend
|
|
142
|
+
7. **AI `explain`** (opt-in) — provider abstraction, explicit field allowlist, response caching
|
|
143
|
+
8. **SSH remote support** — `ssh_hosts` config, same `PortEntry` shape with `host` set to the remote name
|
|
144
|
+
9. **Config system** — YAML loader/merge (defaults → user → project), `config show`/`config path`, audit logging for `free`/`explain`
|
|
145
|
+
10. **Polish** — full `--help` text, packaging for `npm install -g @portmind/cli`
|
|
146
|
+
|
|
147
|
+
Two decisions still open: TUI library (`ink` vs `blessed`), and whether `free` on a Docker-backed port needs anything beyond the interactive stop/kill/cancel prompt already agreed on.
|
|
148
|
+
|
|
149
|
+
## Development
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
pnpm test # run all package test suites (Vitest)
|
|
153
|
+
pnpm lint # Biome check
|
|
154
|
+
pnpm format # Biome format --write
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Tests live in each package's `tests/` directory, not alongside source.
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
[MIT](./LICENSE)
|
package/biome.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
|
3
|
+
"organizeImports": { "enabled": true },
|
|
4
|
+
"linter": {
|
|
5
|
+
"enabled": true,
|
|
6
|
+
"rules": {
|
|
7
|
+
"recommended": true
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"formatter": {
|
|
11
|
+
"enabled": true,
|
|
12
|
+
"indentStyle": "space",
|
|
13
|
+
"indentWidth": 2,
|
|
14
|
+
"lineWidth": 100
|
|
15
|
+
},
|
|
16
|
+
"javascript": {
|
|
17
|
+
"formatter": {
|
|
18
|
+
"quoteStyle": "double",
|
|
19
|
+
"semicolons": "always",
|
|
20
|
+
"trailingCommas": "all"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": {
|
|
24
|
+
"ignore": ["dist/**", "node_modules/**", "data/iana-cache.json"]
|
|
25
|
+
}
|
|
26
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "portmind-monorepo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "portmind - local-first port scanning, enrichment and history tool",
|
|
5
|
+
"packageManager": "pnpm@9.0.0",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "pnpm -r build",
|
|
11
|
+
"test": "pnpm -r test",
|
|
12
|
+
"lint": "biome check .",
|
|
13
|
+
"format": "biome format --write ."
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@portmind/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local-first CLI that scans listening ports, enriches them with process/Docker/IANA detail, and remembers what normally runs where.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cli",
|
|
7
|
+
"port",
|
|
8
|
+
"port-scanner",
|
|
9
|
+
"lsof",
|
|
10
|
+
"docker",
|
|
11
|
+
"devtools",
|
|
12
|
+
"dev-server",
|
|
13
|
+
"port-conflict",
|
|
14
|
+
"eaddrinuse"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/psandis/portmind#readme",
|
|
17
|
+
"bugs": "https://github.com/psandis/portmind/issues",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/psandis/portmind.git",
|
|
21
|
+
"directory": "packages/cli"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": "Petri Sandholm",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"bin": {
|
|
33
|
+
"portmind": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc -p tsconfig.json",
|
|
40
|
+
"test": "vitest run"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@portmind/core": "workspace:*",
|
|
44
|
+
"commander": "^15.0.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^26.4.1",
|
|
48
|
+
"esbuild": "^0.28.2",
|
|
49
|
+
"typescript": "^7.0.2",
|
|
50
|
+
"vite": "^8.2.2",
|
|
51
|
+
"vitest": "^5.0.0"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { DEFAULT_CONFIG, scanPorts, type PortEntry } from "@portmind/core";
|
|
4
|
+
import { renderTable } from "./renderTable.js";
|
|
5
|
+
|
|
6
|
+
const program = new Command();
|
|
7
|
+
|
|
8
|
+
program.name("portmind").description("Local-first port scanning, enrichment and history tool").version("0.1.0");
|
|
9
|
+
|
|
10
|
+
interface ListOptions {
|
|
11
|
+
range?: string;
|
|
12
|
+
dockerOnly?: boolean;
|
|
13
|
+
unusual?: boolean;
|
|
14
|
+
json?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
program
|
|
18
|
+
.command("list")
|
|
19
|
+
.description("List all listening ports on localhost")
|
|
20
|
+
.option("--range <range>", "filter by port range, e.g. 3000-9000")
|
|
21
|
+
.option("--docker-only", "only show Docker-backed ports")
|
|
22
|
+
.option("--unusual", "only show ports flagged unusual or risky")
|
|
23
|
+
.option("--json", "output machine-readable JSON")
|
|
24
|
+
.action(async (options: ListOptions) => {
|
|
25
|
+
try {
|
|
26
|
+
const range = parseRange(options.range);
|
|
27
|
+
let entries = await scanPorts({ includeUdp: DEFAULT_CONFIG.scan.includeUdp });
|
|
28
|
+
|
|
29
|
+
if (range) {
|
|
30
|
+
entries = entries.filter((e) => e.port >= range.min && e.port <= range.max);
|
|
31
|
+
}
|
|
32
|
+
if (options.dockerOnly) {
|
|
33
|
+
entries = entries.filter((e) => e.docker !== null);
|
|
34
|
+
}
|
|
35
|
+
if (options.unusual) {
|
|
36
|
+
entries = entries.filter((e) => !e.history.usual || e.riskFlags.length > 0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
entries.sort((a, b) => a.port - b.port);
|
|
40
|
+
|
|
41
|
+
if (options.json) {
|
|
42
|
+
console.log(JSON.stringify(entries satisfies PortEntry[], null, 2));
|
|
43
|
+
} else {
|
|
44
|
+
console.log(renderTable(entries));
|
|
45
|
+
}
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error(`portmind list: scan failed - ${(error as Error).message}`);
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function parseRange(range: string | undefined): { min: number; max: number } | null {
|
|
53
|
+
if (!range) return null;
|
|
54
|
+
const match = range.match(/^(\d+)-(\d+)$/);
|
|
55
|
+
if (!match) {
|
|
56
|
+
throw new Error(`invalid --range "${range}", expected format like 3000-9000`);
|
|
57
|
+
}
|
|
58
|
+
const min = Number.parseInt(match[1] as string, 10);
|
|
59
|
+
const max = Number.parseInt(match[2] as string, 10);
|
|
60
|
+
if (min > max) {
|
|
61
|
+
throw new Error(`invalid --range "${range}", min must be <= max`);
|
|
62
|
+
}
|
|
63
|
+
return { min, max };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
program.parseAsync(process.argv);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PortEntry } from "@portmind/core";
|
|
2
|
+
|
|
3
|
+
const COLUMNS = ["PORT", "PROTO", "PROCESS", "PID", "DOCKER", "USUAL", "NOTE"] as const;
|
|
4
|
+
|
|
5
|
+
export function renderTable(entries: PortEntry[]): string {
|
|
6
|
+
if (entries.length === 0) {
|
|
7
|
+
return "No listening ports found.";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const rows = entries.map((entry) => [
|
|
11
|
+
String(entry.port),
|
|
12
|
+
entry.protocol,
|
|
13
|
+
entry.processName ?? "-",
|
|
14
|
+
entry.pid !== null ? String(entry.pid) : "-",
|
|
15
|
+
entry.docker?.containerName ?? "-",
|
|
16
|
+
entry.history.usual ? "yes" : "NO",
|
|
17
|
+
noteFor(entry),
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const widths = COLUMNS.map((col, i) =>
|
|
21
|
+
Math.max(col.length, ...rows.map((row) => (row[i] ?? "").length)),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const formatRow = (cells: string[]) =>
|
|
25
|
+
cells.map((cell, i) => cell.padEnd(widths[i] ?? 0)).join(" ").trimEnd();
|
|
26
|
+
|
|
27
|
+
return [formatRow([...COLUMNS]), ...rows.map(formatRow)].join("\n");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function noteFor(entry: PortEntry): string {
|
|
31
|
+
if (entry.knownService) return entry.knownService.description;
|
|
32
|
+
if (entry.cwd) return entry.cwd;
|
|
33
|
+
return "-";
|
|
34
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const cliEntry = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "dist", "index.js");
|
|
7
|
+
|
|
8
|
+
describe("portmind list (real scan, end to end)", () => {
|
|
9
|
+
it("exits 0 and prints a table with a PORT header", () => {
|
|
10
|
+
const output = execFileSync("node", [cliEntry, "list"], { encoding: "utf-8" });
|
|
11
|
+
expect(output).toContain("PORT");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("--json produces valid JSON matching the PortEntry shape", () => {
|
|
15
|
+
const output = execFileSync("node", [cliEntry, "list", "--json"], { encoding: "utf-8" });
|
|
16
|
+
const parsed = JSON.parse(output);
|
|
17
|
+
expect(Array.isArray(parsed)).toBe(true);
|
|
18
|
+
if (parsed.length > 0) {
|
|
19
|
+
expect(parsed[0]).toHaveProperty("port");
|
|
20
|
+
expect(parsed[0]).toHaveProperty("protocol");
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("rejects a malformed --range", () => {
|
|
25
|
+
expect(() => execFileSync("node", [cliEntry, "list", "--range", "bogus"], { stdio: "pipe" })).toThrow();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { PortEntry } from "@portmind/core";
|
|
3
|
+
import { renderTable } from "../src/renderTable.js";
|
|
4
|
+
|
|
5
|
+
function makeEntry(overrides: Partial<PortEntry> = {}): PortEntry {
|
|
6
|
+
return {
|
|
7
|
+
host: "localhost",
|
|
8
|
+
port: 3000,
|
|
9
|
+
protocol: "tcp",
|
|
10
|
+
bindAddress: "127.0.0.1",
|
|
11
|
+
pid: 41822,
|
|
12
|
+
processName: "node",
|
|
13
|
+
cmdline: "node server.js",
|
|
14
|
+
cwd: "~/Projects/react-dashboard",
|
|
15
|
+
startedAt: null,
|
|
16
|
+
docker: null,
|
|
17
|
+
knownService: null,
|
|
18
|
+
history: { usual: true, observationCount: 5, firstSeen: null, usualOccupant: null },
|
|
19
|
+
riskFlags: [],
|
|
20
|
+
aiExplanation: null,
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("renderTable", () => {
|
|
26
|
+
it("reports no listening ports when the list is empty", () => {
|
|
27
|
+
expect(renderTable([])).toBe("No listening ports found.");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("includes the header and each port's core fields", () => {
|
|
31
|
+
const output = renderTable([makeEntry()]);
|
|
32
|
+
expect(output).toContain("PORT");
|
|
33
|
+
expect(output).toContain("3000");
|
|
34
|
+
expect(output).toContain("node");
|
|
35
|
+
expect(output).toContain("41822");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("flags unusual entries as NO instead of yes", () => {
|
|
39
|
+
const output = renderTable([makeEntry({ history: { usual: false, observationCount: 1, firstSeen: null, usualOccupant: "node" } })]);
|
|
40
|
+
expect(output).toContain("NO");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("shows the docker container name when present", () => {
|
|
44
|
+
const output = renderTable([
|
|
45
|
+
makeEntry({ docker: { containerId: "abc123", containerName: "pg-container", image: "postgres:16" } }),
|
|
46
|
+
]);
|
|
47
|
+
expect(output).toContain("pg-container");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@portmind/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "portmind core - scanning, enrichment, data model, config, history. No UI, no AI dependency.",
|
|
5
|
+
"homepage": "https://github.com/psandis/portmind#readme",
|
|
6
|
+
"bugs": "https://github.com/psandis/portmind/issues",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/psandis/portmind.git",
|
|
10
|
+
"directory": "packages/core"
|
|
11
|
+
},
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "Petri Sandholm",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"main": "./dist/index.js",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc -p tsconfig.json",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"test:watch": "vitest"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"better-sqlite3": "^13.0.3"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/better-sqlite3": "^9.6.0",
|
|
39
|
+
"@types/node": "^26.4.1",
|
|
40
|
+
"esbuild": "^0.28.2",
|
|
41
|
+
"typescript": "^7.0.2",
|
|
42
|
+
"vite": "^8.2.2",
|
|
43
|
+
"vitest": "^5.0.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config schema and built-in defaults. Resolution order (lowest to highest
|
|
3
|
+
* precedence): these defaults -> ~/.portmind/config.yaml -> ./.portmind.yaml.
|
|
4
|
+
* Loader/merge logic lands in a later phase; this module only defines the
|
|
5
|
+
* shape so every other module can depend on a stable, typed config.
|
|
6
|
+
*/
|
|
7
|
+
export interface PortmindConfig {
|
|
8
|
+
scan: {
|
|
9
|
+
intervalSeconds: number;
|
|
10
|
+
includeUdp: boolean;
|
|
11
|
+
docker: boolean;
|
|
12
|
+
sshHosts: SshHostConfig[];
|
|
13
|
+
};
|
|
14
|
+
knownPorts: {
|
|
15
|
+
source: "iana" | "custom" | "both";
|
|
16
|
+
customDbPath: string | null;
|
|
17
|
+
refreshDays: number;
|
|
18
|
+
};
|
|
19
|
+
history: {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
dbPath: string;
|
|
22
|
+
retentionDays: number;
|
|
23
|
+
};
|
|
24
|
+
riskRules: {
|
|
25
|
+
flagBoundAllInterfaces: boolean;
|
|
26
|
+
flagUnsignedBinary: boolean;
|
|
27
|
+
flagNoKnownService: boolean;
|
|
28
|
+
};
|
|
29
|
+
ai: {
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
provider: "anthropic" | "openai" | "none";
|
|
32
|
+
model: string;
|
|
33
|
+
trigger: "manual";
|
|
34
|
+
fieldsSent: AiField[];
|
|
35
|
+
cmdlineSanitization: boolean;
|
|
36
|
+
};
|
|
37
|
+
output: {
|
|
38
|
+
defaultFormat: "table" | "json";
|
|
39
|
+
color: "auto" | "always" | "never";
|
|
40
|
+
};
|
|
41
|
+
logging: {
|
|
42
|
+
level: "debug" | "info" | "warn" | "error";
|
|
43
|
+
auditLog: boolean;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SshHostConfig {
|
|
48
|
+
name: string;
|
|
49
|
+
host: string;
|
|
50
|
+
user: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type AiField = "process_name" | "cmdline" | "port" | "protocol" | "docker_image";
|
|
54
|
+
|
|
55
|
+
export const DEFAULT_CONFIG: PortmindConfig = {
|
|
56
|
+
scan: {
|
|
57
|
+
intervalSeconds: 5,
|
|
58
|
+
includeUdp: true,
|
|
59
|
+
docker: true,
|
|
60
|
+
sshHosts: [],
|
|
61
|
+
},
|
|
62
|
+
knownPorts: {
|
|
63
|
+
source: "iana",
|
|
64
|
+
customDbPath: null,
|
|
65
|
+
refreshDays: 30,
|
|
66
|
+
},
|
|
67
|
+
history: {
|
|
68
|
+
enabled: true,
|
|
69
|
+
dbPath: "~/.portmind/portmind.db",
|
|
70
|
+
retentionDays: 180,
|
|
71
|
+
},
|
|
72
|
+
riskRules: {
|
|
73
|
+
flagBoundAllInterfaces: true,
|
|
74
|
+
flagUnsignedBinary: true,
|
|
75
|
+
flagNoKnownService: true,
|
|
76
|
+
},
|
|
77
|
+
ai: {
|
|
78
|
+
enabled: false,
|
|
79
|
+
provider: "anthropic",
|
|
80
|
+
model: "claude-sonnet-4-6",
|
|
81
|
+
trigger: "manual",
|
|
82
|
+
fieldsSent: ["process_name", "cmdline", "port", "protocol", "docker_image"],
|
|
83
|
+
cmdlineSanitization: true,
|
|
84
|
+
},
|
|
85
|
+
output: {
|
|
86
|
+
defaultFormat: "table",
|
|
87
|
+
color: "auto",
|
|
88
|
+
},
|
|
89
|
+
logging: {
|
|
90
|
+
level: "info",
|
|
91
|
+
auditLog: false,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import type { PortEntry } from "../types.js";
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
export interface ProcessDetail {
|
|
8
|
+
cmdline: string;
|
|
9
|
+
startedAt: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Batch-enriches a set of PIDs with command line + process start time (via
|
|
14
|
+
* `ps`) and working directory (via `lsof -d cwd`). Both commands are called
|
|
15
|
+
* once per scan with a comma-separated PID list, not once per PID.
|
|
16
|
+
*
|
|
17
|
+
* Best-effort: a PID that has already exited between the scan and this call,
|
|
18
|
+
* or any command failure, is simply absent from the returned maps - callers
|
|
19
|
+
* must treat missing enrichment as "unknown", never as fatal.
|
|
20
|
+
*/
|
|
21
|
+
export async function enrichProcesses(
|
|
22
|
+
pids: number[],
|
|
23
|
+
): Promise<{ details: Map<number, ProcessDetail>; cwds: Map<number, string> }> {
|
|
24
|
+
const uniquePids = [...new Set(pids)].filter((pid) => pid > 0 && pid <= 4194304);
|
|
25
|
+
if (uniquePids.length === 0) {
|
|
26
|
+
return { details: new Map(), cwds: new Map() };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const [details, cwds] = await Promise.all([fetchProcessDetails(uniquePids), fetchCwds(uniquePids)]);
|
|
30
|
+
return { details, cwds };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function fetchProcessDetails(pids: number[]): Promise<Map<number, ProcessDetail>> {
|
|
34
|
+
const result = new Map<number, ProcessDetail>();
|
|
35
|
+
const output = await run("ps", ["-o", "pid=,lstart=,command=", "-p", pids.join(",")]);
|
|
36
|
+
if (!output) return result;
|
|
37
|
+
|
|
38
|
+
for (const line of output.split("\n")) {
|
|
39
|
+
const trimmed = line.trimStart();
|
|
40
|
+
if (trimmed.length === 0) continue;
|
|
41
|
+
|
|
42
|
+
// Format: "<pid> <weekday> <month> <day> <time> <year> <command...>"
|
|
43
|
+
const match = trimmed.match(/^(\d+)\s+(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(.+)$/);
|
|
44
|
+
if (!match) continue;
|
|
45
|
+
|
|
46
|
+
const [, pidStr, lstart, command] = match;
|
|
47
|
+
const pid = Number.parseInt(pidStr as string, 10);
|
|
48
|
+
const startedAt = parseLstart(lstart as string);
|
|
49
|
+
if (startedAt) {
|
|
50
|
+
result.set(pid, { cmdline: (command as string).trimEnd(), startedAt });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function fetchCwds(pids: number[]): Promise<Map<number, string>> {
|
|
58
|
+
const result = new Map<number, string>();
|
|
59
|
+
const output = await run("lsof", ["-a", "-d", "cwd", "-p", pids.join(","), "-F", "pn"]);
|
|
60
|
+
if (!output) return result;
|
|
61
|
+
|
|
62
|
+
let currentPid: number | null = null;
|
|
63
|
+
for (const line of output.split("\n")) {
|
|
64
|
+
if (line.length === 0) continue;
|
|
65
|
+
const tag = line[0];
|
|
66
|
+
const value = line.slice(1);
|
|
67
|
+
if (tag === "p") {
|
|
68
|
+
currentPid = Number.parseInt(value, 10);
|
|
69
|
+
} else if (tag === "n" && currentPid !== null) {
|
|
70
|
+
result.set(currentPid, value);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Parses ps's `lstart` format ("Mon Aug 31 02:20:42 2026") into an ISO string. */
|
|
78
|
+
function parseLstart(lstart: string): string | null {
|
|
79
|
+
const date = new Date(lstart);
|
|
80
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function run(command: string, args: string[]): Promise<string | null> {
|
|
84
|
+
try {
|
|
85
|
+
const { stdout } = await execFileAsync(command, args, { maxBuffer: 10 * 1024 * 1024 });
|
|
86
|
+
return stdout;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
const err = error as NodeJS.ErrnoException & { stdout?: string };
|
|
89
|
+
return err.stdout ?? null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function applyEnrichment(
|
|
94
|
+
entries: PortEntry[],
|
|
95
|
+
details: Map<number, ProcessDetail>,
|
|
96
|
+
cwds: Map<number, string>,
|
|
97
|
+
): PortEntry[] {
|
|
98
|
+
return entries.map((entry) => {
|
|
99
|
+
if (entry.pid === null) return entry;
|
|
100
|
+
const detail = details.get(entry.pid);
|
|
101
|
+
const cwd = cwds.get(entry.pid);
|
|
102
|
+
return {
|
|
103
|
+
...entry,
|
|
104
|
+
cmdline: detail?.cmdline ?? entry.cmdline,
|
|
105
|
+
startedAt: detail?.startedAt ?? entry.startedAt,
|
|
106
|
+
cwd: cwd ?? entry.cwd,
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { scanLocalPorts, type ScanOptions } from "./scanner.js";
|
|
2
|
+
import { enrichProcesses, applyEnrichment } from "./enrichProcess.js";
|
|
3
|
+
import type { PortEntry } from "../types.js";
|
|
4
|
+
|
|
5
|
+
export type { ScanOptions } from "./scanner.js";
|
|
6
|
+
|
|
7
|
+
/** Full local scan: raw listening sockets + process enrichment (cmdline/cwd/startedAt). */
|
|
8
|
+
export async function scanPorts(options: ScanOptions): Promise<PortEntry[]> {
|
|
9
|
+
const bare = await scanLocalPorts(options);
|
|
10
|
+
const pids = bare.map((entry) => entry.pid).filter((pid): pid is number => pid !== null);
|
|
11
|
+
const { details, cwds } = await enrichProcesses(pids);
|
|
12
|
+
return applyEnrichment(bare, details, cwds);
|
|
13
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses `lsof -F pcn` field output into raw listening-socket records.
|
|
3
|
+
*
|
|
4
|
+
* Field format (one token per line, no separators):
|
|
5
|
+
* p<pid> start of a new process block
|
|
6
|
+
* c<command> command name for the current pid
|
|
7
|
+
* f<fd> start of a new file/socket for the current pid
|
|
8
|
+
* n<address> address for the current file, e.g. "*:3000", "127.0.0.1:5432", "[::1]:5432"
|
|
9
|
+
*
|
|
10
|
+
* A "p" line always precedes its "c" line; a "f" line always precedes its "n" line.
|
|
11
|
+
* Verified against real `lsof -iTCP -sTCP:LISTEN -P -n -F pcn` output on macOS.
|
|
12
|
+
*/
|
|
13
|
+
export interface RawListeningSocket {
|
|
14
|
+
pid: number;
|
|
15
|
+
processName: string;
|
|
16
|
+
bindAddress: string;
|
|
17
|
+
port: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseLsofFieldOutput(output: string): RawListeningSocket[] {
|
|
21
|
+
const sockets: RawListeningSocket[] = [];
|
|
22
|
+
let currentPid: number | null = null;
|
|
23
|
+
let currentCommand: string | null = null;
|
|
24
|
+
|
|
25
|
+
for (const line of output.split("\n")) {
|
|
26
|
+
if (line.length === 0) continue;
|
|
27
|
+
const tag = line[0];
|
|
28
|
+
const value = line.slice(1);
|
|
29
|
+
|
|
30
|
+
if (tag === "p") {
|
|
31
|
+
currentPid = Number.parseInt(value, 10);
|
|
32
|
+
} else if (tag === "c") {
|
|
33
|
+
currentCommand = value;
|
|
34
|
+
} else if (tag === "n" && currentPid !== null && currentCommand !== null) {
|
|
35
|
+
const parsed = parseAddress(value);
|
|
36
|
+
if (parsed) {
|
|
37
|
+
sockets.push({
|
|
38
|
+
pid: currentPid,
|
|
39
|
+
processName: currentCommand,
|
|
40
|
+
bindAddress: parsed.bindAddress,
|
|
41
|
+
port: parsed.port,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return sockets;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Splits an lsof address into bind address + port.
|
|
52
|
+
* Handles "*:3000" (all interfaces v4), "127.0.0.1:5432", "[::1]:5432" (IPv6),
|
|
53
|
+
* and "*:*" / bare "*" (unbound UDP socket, no real port - returns null).
|
|
54
|
+
*/
|
|
55
|
+
function parseAddress(raw: string): { bindAddress: string; port: number } | null {
|
|
56
|
+
const lastColon = raw.lastIndexOf(":");
|
|
57
|
+
if (lastColon === -1) return null;
|
|
58
|
+
|
|
59
|
+
const portStr = raw.slice(lastColon + 1);
|
|
60
|
+
if (portStr === "*" || portStr === "") return null;
|
|
61
|
+
|
|
62
|
+
const port = Number.parseInt(portStr, 10);
|
|
63
|
+
if (Number.isNaN(port)) return null;
|
|
64
|
+
|
|
65
|
+
let bindAddress = raw.slice(0, lastColon);
|
|
66
|
+
if (bindAddress.startsWith("[") && bindAddress.endsWith("]")) {
|
|
67
|
+
bindAddress = bindAddress.slice(1, -1);
|
|
68
|
+
}
|
|
69
|
+
if (bindAddress === "*") {
|
|
70
|
+
bindAddress = "0.0.0.0";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { bindAddress, port };
|
|
74
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { parseLsofFieldOutput, type RawListeningSocket } from "./parseLsof.js";
|
|
4
|
+
import type { PortEntry } from "../types.js";
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
export interface ScanOptions {
|
|
9
|
+
includeUdp: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Runs `lsof` for TCP (and optionally UDP) listening sockets and returns
|
|
14
|
+
* bare PortEntry objects: only host/port/protocol/bindAddress/pid/processName
|
|
15
|
+
* are populated here. Process enrichment (cmdline/cwd/startedAt) is a
|
|
16
|
+
* separate step (see enrichProcess.ts) so scanning still returns partial
|
|
17
|
+
* results if enrichment for one PID fails.
|
|
18
|
+
*
|
|
19
|
+
* Never throws if `lsof` is missing or errors - returns an empty list and
|
|
20
|
+
* lets the caller decide how to report that (CLI exit code 1 per spec).
|
|
21
|
+
*/
|
|
22
|
+
export async function scanLocalPorts(options: ScanOptions): Promise<PortEntry[]> {
|
|
23
|
+
const sockets: (RawListeningSocket & { protocol: "tcp" | "udp" })[] = [];
|
|
24
|
+
|
|
25
|
+
const tcp = await runLsof(["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"]);
|
|
26
|
+
if (tcp) sockets.push(...parseLsofFieldOutput(tcp).map((s) => ({ ...s, protocol: "tcp" as const })));
|
|
27
|
+
|
|
28
|
+
if (options.includeUdp) {
|
|
29
|
+
const udp = await runLsof(["-iUDP", "-P", "-n", "-F", "pcn"]);
|
|
30
|
+
if (udp) sockets.push(...parseLsofFieldOutput(udp).map((s) => ({ ...s, protocol: "udp" as const })));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return dedupeSockets(sockets).map((socket) => toPortEntry(socket));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* lsof reports one line per file descriptor, so the same logical listener
|
|
38
|
+
* (e.g. bound on both IPv4 and IPv6, or via multiple duplicated fds) shows up
|
|
39
|
+
* as several identical-looking rows. Collapse by the fields that actually
|
|
40
|
+
* distinguish one listener from another.
|
|
41
|
+
*/
|
|
42
|
+
export function dedupeSockets(
|
|
43
|
+
sockets: (RawListeningSocket & { protocol: "tcp" | "udp" })[],
|
|
44
|
+
): (RawListeningSocket & { protocol: "tcp" | "udp" })[] {
|
|
45
|
+
const seen = new Map<string, RawListeningSocket & { protocol: "tcp" | "udp" }>();
|
|
46
|
+
for (const socket of sockets) {
|
|
47
|
+
const key = `${socket.protocol}:${socket.bindAddress}:${socket.port}:${socket.pid}`;
|
|
48
|
+
if (!seen.has(key)) seen.set(key, socket);
|
|
49
|
+
}
|
|
50
|
+
return [...seen.values()];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function runLsof(args: string[]): Promise<string | null> {
|
|
54
|
+
try {
|
|
55
|
+
const { stdout } = await execFileAsync("lsof", args, { maxBuffer: 10 * 1024 * 1024 });
|
|
56
|
+
return stdout;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
// lsof exits non-zero when there are simply no matching sockets - that's
|
|
59
|
+
// not a failure. Only treat "binary not found" as a hard miss.
|
|
60
|
+
const err = error as NodeJS.ErrnoException & { stdout?: string };
|
|
61
|
+
if (err.code === "ENOENT") return null;
|
|
62
|
+
return err.stdout ?? null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function toPortEntry(socket: RawListeningSocket & { protocol: "tcp" | "udp" }): PortEntry {
|
|
67
|
+
return {
|
|
68
|
+
host: "localhost",
|
|
69
|
+
port: socket.port,
|
|
70
|
+
protocol: socket.protocol,
|
|
71
|
+
bindAddress: socket.bindAddress,
|
|
72
|
+
pid: socket.pid,
|
|
73
|
+
processName: socket.processName,
|
|
74
|
+
cmdline: null,
|
|
75
|
+
cwd: null,
|
|
76
|
+
startedAt: null,
|
|
77
|
+
docker: null,
|
|
78
|
+
knownService: null,
|
|
79
|
+
history: {
|
|
80
|
+
usual: true,
|
|
81
|
+
observationCount: 0,
|
|
82
|
+
firstSeen: null,
|
|
83
|
+
usualOccupant: null,
|
|
84
|
+
},
|
|
85
|
+
riskFlags: [],
|
|
86
|
+
aiExplanation: null,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core data shape shared by every UI (CLI, TUI, web). Defined once here;
|
|
3
|
+
* cli/tui/web packages must render this type, never define their own shape.
|
|
4
|
+
*/
|
|
5
|
+
export interface PortEntry {
|
|
6
|
+
host: string;
|
|
7
|
+
port: number;
|
|
8
|
+
protocol: "tcp" | "udp";
|
|
9
|
+
bindAddress: string;
|
|
10
|
+
pid: number | null;
|
|
11
|
+
processName: string | null;
|
|
12
|
+
cmdline: string | null;
|
|
13
|
+
cwd: string | null;
|
|
14
|
+
startedAt: string | null;
|
|
15
|
+
docker: {
|
|
16
|
+
containerId: string;
|
|
17
|
+
containerName: string;
|
|
18
|
+
image: string;
|
|
19
|
+
} | null;
|
|
20
|
+
knownService: {
|
|
21
|
+
name: string;
|
|
22
|
+
description: string;
|
|
23
|
+
source: "iana" | "custom";
|
|
24
|
+
} | null;
|
|
25
|
+
history: {
|
|
26
|
+
usual: boolean;
|
|
27
|
+
observationCount: number;
|
|
28
|
+
firstSeen: string | null;
|
|
29
|
+
usualOccupant: string | null;
|
|
30
|
+
};
|
|
31
|
+
riskFlags: RiskFlag[];
|
|
32
|
+
aiExplanation: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type RiskFlag = "bound_all_interfaces" | "unsigned_binary" | "no_known_service";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { DEFAULT_CONFIG } from "../src/config.js";
|
|
3
|
+
|
|
4
|
+
describe("DEFAULT_CONFIG", () => {
|
|
5
|
+
it("ships with AI disabled by default", () => {
|
|
6
|
+
expect(DEFAULT_CONFIG.ai.enabled).toBe(false);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("only allows the documented AI fields", () => {
|
|
10
|
+
const allowed = ["process_name", "cmdline", "port", "protocol", "docker_image"];
|
|
11
|
+
for (const field of DEFAULT_CONFIG.ai.fieldsSent) {
|
|
12
|
+
expect(allowed).toContain(field);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("scans docker and udp by default", () => {
|
|
17
|
+
expect(DEFAULT_CONFIG.scan.docker).toBe(true);
|
|
18
|
+
expect(DEFAULT_CONFIG.scan.includeUdp).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseLsofFieldOutput } from "../src/scan/parseLsof.js";
|
|
3
|
+
|
|
4
|
+
describe("parseLsofFieldOutput", () => {
|
|
5
|
+
it("parses IPv4 wildcard bind addresses", () => {
|
|
6
|
+
const output = "p984\ncrapportd\nf14\nn*:59095\n";
|
|
7
|
+
const result = parseLsofFieldOutput(output);
|
|
8
|
+
expect(result).toEqual([
|
|
9
|
+
{ pid: 984, processName: "rapportd", bindAddress: "0.0.0.0", port: 59095 },
|
|
10
|
+
]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("parses loopback bind addresses", () => {
|
|
14
|
+
const output = "p1758\ncpostgres\nf8\nn127.0.0.1:5432\n";
|
|
15
|
+
const result = parseLsofFieldOutput(output);
|
|
16
|
+
expect(result).toEqual([
|
|
17
|
+
{ pid: 1758, processName: "postgres", bindAddress: "127.0.0.1", port: 5432 },
|
|
18
|
+
]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("parses bracketed IPv6 bind addresses", () => {
|
|
22
|
+
const output = "p1758\ncpostgres\nf7\nn[::1]:5432\n";
|
|
23
|
+
const result = parseLsofFieldOutput(output);
|
|
24
|
+
expect(result).toEqual([{ pid: 1758, processName: "postgres", bindAddress: "::1", port: 5432 }]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("skips unbound sockets with no real port (UDP wildcard)", () => {
|
|
28
|
+
const output = "p996\ncidentityservicesd\nf13\nn*:*\n";
|
|
29
|
+
expect(parseLsofFieldOutput(output)).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("associates multiple sockets with the same process block", () => {
|
|
33
|
+
const output = "p1117\ncControlCenter\nf9\nn*:7000\nf11\nn*:5000\n";
|
|
34
|
+
const result = parseLsofFieldOutput(output);
|
|
35
|
+
expect(result).toHaveLength(2);
|
|
36
|
+
expect(result[0]?.port).toBe(7000);
|
|
37
|
+
expect(result[1]?.port).toBe(5000);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns an empty array for empty output", () => {
|
|
41
|
+
expect(parseLsofFieldOutput("")).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { dedupeSockets } from "../src/scan/scanner.js";
|
|
3
|
+
|
|
4
|
+
describe("dedupeSockets", () => {
|
|
5
|
+
it("collapses duplicate fds for the same protocol/address/port/pid", () => {
|
|
6
|
+
const sockets = [
|
|
7
|
+
{ pid: 1117, processName: "ControlCenter", bindAddress: "0.0.0.0", port: 7000, protocol: "tcp" as const },
|
|
8
|
+
{ pid: 1117, processName: "ControlCenter", bindAddress: "0.0.0.0", port: 7000, protocol: "tcp" as const },
|
|
9
|
+
];
|
|
10
|
+
expect(dedupeSockets(sockets)).toHaveLength(1);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("keeps distinct ports for the same pid separate", () => {
|
|
14
|
+
const sockets = [
|
|
15
|
+
{ pid: 1117, processName: "ControlCenter", bindAddress: "0.0.0.0", port: 7000, protocol: "tcp" as const },
|
|
16
|
+
{ pid: 1117, processName: "ControlCenter", bindAddress: "0.0.0.0", port: 5000, protocol: "tcp" as const },
|
|
17
|
+
];
|
|
18
|
+
expect(dedupeSockets(sockets)).toHaveLength(2);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("keeps tcp and udp on the same port separate", () => {
|
|
22
|
+
const sockets = [
|
|
23
|
+
{ pid: 1605, processName: "Spotify", bindAddress: "0.0.0.0", port: 57621, protocol: "tcp" as const },
|
|
24
|
+
{ pid: 1605, processName: "Spotify", bindAddress: "0.0.0.0", port: 57621, protocol: "udp" as const },
|
|
25
|
+
];
|
|
26
|
+
expect(dedupeSockets(sockets)).toHaveLength(2);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"exactOptionalPropertyTypes": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"forceConsistentCasingInFileNames": true,
|
|
14
|
+
"declaration": true,
|
|
15
|
+
"declarationMap": true,
|
|
16
|
+
"sourceMap": true,
|
|
17
|
+
"isolatedModules": true
|
|
18
|
+
}
|
|
19
|
+
}
|