portmind-monorepo 0.3.0 → 0.3.1
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 +13 -0
- package/README.md +19 -10
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/index.ts +44 -2
- package/packages/core/package.json +1 -1
- package/packages/core/src/ai/allowlist.ts +38 -0
- package/packages/core/src/ai/cache.ts +54 -0
- package/packages/core/src/ai/explain.ts +47 -0
- package/packages/core/src/ai/provider.ts +91 -0
- package/packages/core/src/ai/sanitize.ts +19 -0
- package/packages/core/src/index.ts +4 -0
- package/packages/core/tests/aiCache.test.ts +38 -0
- package/packages/core/tests/allowlist.test.ts +63 -0
- package/packages/core/tests/explain.test.ts +119 -0
- package/packages/core/tests/provider.test.ts +72 -0
- package/packages/core/tests/sanitize.test.ts +25 -0
- package/packages/web/package.json +1 -1
- package/packages/web/src/server.ts +38 -4
- package/packages/web/static/index.html +108 -22
- package/packages/web/tests/server.test.ts +11 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.1] - 2026-09-06
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `@portmind/core`: AI `explain` feature - provider abstraction for Anthropic (`/v1/messages`) and OpenAI (`/v1/chat/completions`), selected via `ai.provider` config with no code changes needed to switch; explicit field allowlist enforcement (`buildExplainPayload`, only sends fields named in `ai.fields_sent`); cmdline secret/token redaction (`sanitizeCmdline`) before anything is sent; SQLite response cache (`ai_explanations` table) keyed by `(process_name, port)` so repeat queries don't re-call the API.
|
|
13
|
+
- `@portmind/cli`: `portmind explain <port>` - opt-in, requires `ai.enabled: true` and an API key in `ANTHROPIC_API_KEY`/`OPENAI_API_KEY`; fails with a clear config error (exit code `2`) rather than doing nothing if unconfigured.
|
|
14
|
+
- `@portmind/web`: `POST /api/explain` endpoint; the dashboard's "Explain with AI" button now makes a real request instead of showing a disabled placeholder.
|
|
15
|
+
- Web dashboard UX fixes: sticky header (title, filter toolbar, and column labels stay pinned while scrolling a long port list) and the detail panel is now a fixed slide-in panel from the right edge with a dimmed overlay, appearing instantly regardless of scroll position, instead of a block at the bottom of the page requiring a scroll to find. The panel now shows "Service name" and "Description" as two separate labeled fields.
|
|
16
|
+
- Vitest coverage: cmdline sanitization patterns, allowlist field selection, provider request-shape verification (mocked `fetch`, exact URL/headers/body per each provider's documented API), cache get/set/overwrite, and the full `explainPort` orchestration (disabled, missing key, cache hit, cache miss - all mocked).
|
|
17
|
+
|
|
18
|
+
### Known limitations
|
|
19
|
+
- **AI explain has not been tested against a real API key.** Every test mocks the HTTP layer; the request shapes match each provider's documented API as of this writing, but end-to-end correctness against the actual Anthropic/OpenAI services is unverified.
|
|
20
|
+
- The table's DESCRIPTION column and detail panel's "Description" field only populate for ports IANA has actually registered - most macOS system/app processes (e.g. rapportd, ControlCenter, Spotify) aren't registered services, so they correctly show "-" or, for genuinely coincidental port-number matches, an unrelated IANA description for that number.
|
|
21
|
+
|
|
9
22
|
## [0.3.0] - 2026-09-06
|
|
10
23
|
|
|
11
24
|
### Added
|
package/README.md
CHANGED
|
@@ -19,8 +19,9 @@ Local-first CLI + web dashboard that scans listening ports on your machine, enri
|
|
|
19
19
|
- Real known-port descriptions via the official IANA Service Name and Port Number Registry (11,394 entries bundled, fetched and verified from `iana.org` — e.g. port 5432 shows "PostgreSQL Database", not a placeholder)
|
|
20
20
|
- A real, working config file loader — `~/.portmind/config.yaml` (user) and `.portmind.yaml` (project) are both read and merged over the defaults; `portmind config show`/`config path` expose the resolved result and file locations
|
|
21
21
|
- `known_ports.source: custom` or `both` in config lets you point at your own JSON file to override or add service descriptions IANA doesn't have (internal services, dev conventions like Redis/MongoDB that aren't officially IANA-registered)
|
|
22
|
+
- AI `explain` (opt-in, off by default) — `portmind explain <port>` and the web dashboard's "Explain with AI" button both make a real call to Anthropic or OpenAI (your choice, via `ai.provider`), sending only the explicit field allowlist (never full env vars or file contents), with cmdline secrets/tokens redacted first and results cached in SQLite so repeat queries don't re-call the API. **Not tested against a live API key** — the request-construction logic (allowlist, sanitization, exact payload shape) is unit-tested with a mocked HTTP layer, but no real call to Anthropic/OpenAI has been made to confirm end-to-end correctness.
|
|
22
23
|
|
|
23
|
-
**Not implemented yet** (see [Next steps](#next-steps)): Docker cross-reference, local history/"usual" detection, risk flags, `watch`/`free`/`
|
|
24
|
+
**Not implemented yet** (see [Next steps](#next-steps)): Docker cross-reference, local history/"usual" detection, risk flags, `watch`/`free`/`history`/`ssh`/`tui` commands. Until those land, every `PortEntry.docker` and `.riskFlags` will be empty, and `.history.usual` is always `true` — so the web dashboard's Docker/unusual filters currently have nothing to filter.
|
|
24
25
|
|
|
25
26
|
See [CHANGELOG.md](./CHANGELOG.md) for a dated record of what shipped when.
|
|
26
27
|
|
|
@@ -72,7 +73,15 @@ portmind web --port 4401 # use a different port for the dashboard itself
|
|
|
72
73
|
portmind web --no-open # don't open the browser automatically
|
|
73
74
|
```
|
|
74
75
|
|
|
75
|
-
Why a plain server + vanilla JS instead of a framework: the dashboard is
|
|
76
|
+
Why a plain server + vanilla JS instead of a framework: the dashboard is three routes (the page, `/api/ports`, `/api/explain`), it never leaves your machine except for the explicit opt-in AI call, and there's no build pipeline to maintain — consistent with the "local-first, no telemetry" design of the rest of the tool. The page polls `/api/ports` on load and on manual refresh (or a 5-second auto-refresh you opt into). Clicking a row opens a fixed panel from the right edge of the screen (with a dimmed overlay behind it) showing full detail — no scrolling required regardless of where in the table you clicked. Its "Explain with AI" button only fires `/api/explain` when clicked, never automatically.
|
|
77
|
+
|
|
78
|
+
### AI explain
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
portmind explain 5432 # runs AI deep-search for whatever is currently on port 5432
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Requires `ai.enabled: true` in config and an API key in the matching environment variable (`ANTHROPIC_API_KEY` or `OPENAI_API_KEY` depending on `ai.provider` — never put the key in the config file itself). Disabled by default; both the CLI command and the web button fail with a clear config error (exit code `2` for the CLI) rather than silently doing nothing if it's not configured. Results are cached in SQLite keyed by `(process_name, port)`, so asking about the same shape of thing twice doesn't re-call the API.
|
|
76
85
|
|
|
77
86
|
### Config
|
|
78
87
|
|
|
@@ -83,11 +92,11 @@ portmind config path # print ~/.portmind/config.yaml and ./.portmind.yaml, and
|
|
|
83
92
|
|
|
84
93
|
Copy [portmind.config.example.yaml](./portmind.config.example.yaml) to `~/.portmind/config.yaml` or `./.portmind.yaml` to change behavior — only the keys you include override the defaults, anything omitted falls back. Malformed YAML in a config file that exists is a hard error (exit code `2`), not silently ignored.
|
|
85
94
|
|
|
86
|
-
Planned commands not yet implemented: `watch`, `free`, `
|
|
95
|
+
Planned commands not yet implemented: `watch`, `free`, `history`, `ssh <host> list|check`, `tui`.
|
|
87
96
|
|
|
88
97
|
## Storage
|
|
89
98
|
|
|
90
|
-
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.
|
|
99
|
+
History will be stored locally in a SQLite database at `~/.portmind/portmind.db` (configurable) once the history feature (Phase 4) lands - the same db file already holds the `ai_explanations` cache table today. Nothing leaves the machine unless you explicitly run `portmind explain <port>` (or click "Explain with AI") with AI enabled in config, and even then only an explicit allowlist of fields is sent — never full environment variables or file contents, and cmdline values are scanned for secret/token-shaped substrings and redacted before sending.
|
|
91
100
|
|
|
92
101
|
## Configuration
|
|
93
102
|
|
|
@@ -160,13 +169,13 @@ Remaining phases, in build order:
|
|
|
160
169
|
2. **Docker cross-reference** — match `docker ps` output against scanned ports, populate `PortEntry.docker`
|
|
161
170
|
3. **Risk flags** — `bound_all_interfaces`, `unsigned_binary`, `no_known_service`, each independently configurable
|
|
162
171
|
4. **TUI** (`ink` or `blessed` — undecided) — live table with inline `explain`/`free`
|
|
163
|
-
5. **
|
|
164
|
-
6. **
|
|
165
|
-
7. **
|
|
166
|
-
8. **
|
|
167
|
-
9. **
|
|
172
|
+
5. **SSH remote support** — `ssh_hosts` config, same `PortEntry` shape with `host` set to the remote name
|
|
173
|
+
6. **Audit logging** — log every `free`/`explain` action per `logging.audit_log`
|
|
174
|
+
7. **IANA cache auto-refresh** — automate re-fetching the CSV on `known_ports.refresh_days`, rather than the current bundled-once snapshot
|
|
175
|
+
8. **Polish** — full `--help` text, packaging for `npm install -g @portmind/cli`
|
|
176
|
+
9. **AI explain: real end-to-end verification** — the feature is built and unit-tested with a mocked HTTP layer, but has never actually been called against Anthropic or OpenAI with a real API key. Verifying that happens whenever a key becomes available to test with.
|
|
168
177
|
|
|
169
|
-
Web dashboard, IANA enrichment,
|
|
178
|
+
Web dashboard, IANA enrichment, the config file loader, and AI `explain` (untested end-to-end - see above) are done — see [Status](#status).
|
|
170
179
|
|
|
171
180
|
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.
|
|
172
181
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portmind-monorepo",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Local-first CLI + web dashboard that scans listening ports, enriches them with process/Docker/IANA detail, and remembers what normally runs where.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
ConfigError,
|
|
6
|
+
explainPort,
|
|
7
|
+
loadConfig,
|
|
8
|
+
resolveConfigPaths,
|
|
9
|
+
scanPorts,
|
|
10
|
+
type PortEntry,
|
|
11
|
+
} from "@portmind/core";
|
|
5
12
|
import { createWebServer } from "@portmind/web";
|
|
6
13
|
import { renderTable } from "./renderTable.js";
|
|
7
14
|
|
|
8
15
|
const program = new Command();
|
|
9
16
|
|
|
10
|
-
program.name("portmind").description("Local-first port scanning, enrichment and history tool").version("0.3.
|
|
17
|
+
program.name("portmind").description("Local-first port scanning, enrichment and history tool").version("0.3.1");
|
|
11
18
|
|
|
12
19
|
interface ListOptions {
|
|
13
20
|
range?: string;
|
|
@@ -89,6 +96,41 @@ program
|
|
|
89
96
|
}
|
|
90
97
|
});
|
|
91
98
|
|
|
99
|
+
program
|
|
100
|
+
.command("explain")
|
|
101
|
+
.description("Run AI deep-search for a specific port (opt-in, requires ai.enabled: true)")
|
|
102
|
+
.argument("<port>", "port number to explain")
|
|
103
|
+
.action(async (portArg: string) => {
|
|
104
|
+
const port = Number.parseInt(portArg, 10);
|
|
105
|
+
if (Number.isNaN(port)) {
|
|
106
|
+
console.error(`portmind explain: invalid port "${portArg}"`);
|
|
107
|
+
process.exitCode = 2;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const resolvedConfig = await loadConfig();
|
|
113
|
+
const entries = await scanPorts({ includeUdp: resolvedConfig.scan.includeUdp }, resolvedConfig.knownPorts);
|
|
114
|
+
const entry = entries.find((e) => e.port === port);
|
|
115
|
+
if (!entry) {
|
|
116
|
+
console.error(`portmind explain: nothing is currently listening on port ${port}`);
|
|
117
|
+
process.exitCode = 1;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const explanation = await explainPort(entry, resolvedConfig);
|
|
122
|
+
console.log(explanation);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (error instanceof ConfigError) {
|
|
125
|
+
console.error(`portmind explain: ${error.message}`);
|
|
126
|
+
process.exitCode = 2;
|
|
127
|
+
} else {
|
|
128
|
+
console.error(`portmind explain: failed - ${(error as Error).message}`);
|
|
129
|
+
process.exitCode = 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
92
134
|
const config = program.command("config").description("Inspect resolved portmind configuration");
|
|
93
135
|
|
|
94
136
|
config
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@portmind/core",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "portmind core - scanning, enrichment, data model, config, history. No UI, no AI dependency.",
|
|
5
5
|
"homepage": "https://github.com/psandis/portmind#readme",
|
|
6
6
|
"bugs": "https://github.com/psandis/portmind/issues",
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { PortEntry } from "../types.js";
|
|
2
|
+
import type { AiField } from "../config.js";
|
|
3
|
+
import { sanitizeCmdline } from "./sanitize.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds the exact payload sent to an AI provider - only fields named in
|
|
7
|
+
* `fieldsSent` are included, nothing else on PortEntry is ever touched.
|
|
8
|
+
* This is the sole place field selection happens, so it's the one function
|
|
9
|
+
* that needs auditing to know what leaves the machine.
|
|
10
|
+
*/
|
|
11
|
+
export function buildExplainPayload(
|
|
12
|
+
entry: PortEntry,
|
|
13
|
+
fieldsSent: AiField[],
|
|
14
|
+
): Record<string, string | number | null> {
|
|
15
|
+
const payload: Record<string, string | number | null> = {};
|
|
16
|
+
|
|
17
|
+
for (const field of fieldsSent) {
|
|
18
|
+
switch (field) {
|
|
19
|
+
case "process_name":
|
|
20
|
+
payload.process_name = entry.processName;
|
|
21
|
+
break;
|
|
22
|
+
case "cmdline":
|
|
23
|
+
payload.cmdline = entry.cmdline ? sanitizeCmdline(entry.cmdline) : null;
|
|
24
|
+
break;
|
|
25
|
+
case "port":
|
|
26
|
+
payload.port = entry.port;
|
|
27
|
+
break;
|
|
28
|
+
case "protocol":
|
|
29
|
+
payload.protocol = entry.protocol;
|
|
30
|
+
break;
|
|
31
|
+
case "docker_image":
|
|
32
|
+
payload.docker_image = entry.docker?.image ?? null;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return payload;
|
|
38
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
/** Expands a leading ~ to the user's home directory, same convention as the rest of the config. */
|
|
7
|
+
function expandHome(filePath: string): string {
|
|
8
|
+
return filePath.startsWith("~") ? path.join(os.homedir(), filePath.slice(1)) : filePath;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Caches AI explanations keyed by (process_name, port), per the spec, so
|
|
13
|
+
* repeat `explain` calls for the same shape of thing don't re-call a paid
|
|
14
|
+
* API. Lazily creates the db file and table on first use.
|
|
15
|
+
*/
|
|
16
|
+
export class AiExplanationCache {
|
|
17
|
+
private db: Database.Database;
|
|
18
|
+
|
|
19
|
+
constructor(dbPath: string) {
|
|
20
|
+
const resolved = expandHome(dbPath);
|
|
21
|
+
mkdirSync(path.dirname(resolved), { recursive: true });
|
|
22
|
+
this.db = new Database(resolved);
|
|
23
|
+
this.db.exec(`
|
|
24
|
+
CREATE TABLE IF NOT EXISTS ai_explanations (
|
|
25
|
+
process_name TEXT NOT NULL,
|
|
26
|
+
port INTEGER NOT NULL,
|
|
27
|
+
explanation TEXT NOT NULL,
|
|
28
|
+
created_at TEXT NOT NULL,
|
|
29
|
+
PRIMARY KEY (process_name, port)
|
|
30
|
+
)
|
|
31
|
+
`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get(processName: string, port: number): string | null {
|
|
35
|
+
const row = this.db
|
|
36
|
+
.prepare("SELECT explanation FROM ai_explanations WHERE process_name = ? AND port = ?")
|
|
37
|
+
.get(processName, port) as { explanation: string } | undefined;
|
|
38
|
+
return row?.explanation ?? null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
set(processName: string, port: number, explanation: string): void {
|
|
42
|
+
this.db
|
|
43
|
+
.prepare(
|
|
44
|
+
`INSERT INTO ai_explanations (process_name, port, explanation, created_at)
|
|
45
|
+
VALUES (?, ?, ?, ?)
|
|
46
|
+
ON CONFLICT (process_name, port) DO UPDATE SET explanation = excluded.explanation, created_at = excluded.created_at`,
|
|
47
|
+
)
|
|
48
|
+
.run(processName, port, explanation, new Date().toISOString());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
close(): void {
|
|
52
|
+
this.db.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { PortEntry } from "../types.js";
|
|
2
|
+
import type { PortmindConfig } from "../config.js";
|
|
3
|
+
import { buildExplainPayload } from "./allowlist.js";
|
|
4
|
+
import { createAnthropicProvider, createOpenAiProvider, type AiProvider, type FetchLike } from "./provider.js";
|
|
5
|
+
import { AiExplanationCache } from "./cache.js";
|
|
6
|
+
import { ConfigError } from "../configLoader.js";
|
|
7
|
+
|
|
8
|
+
export interface ExplainDeps {
|
|
9
|
+
fetchImpl?: FetchLike;
|
|
10
|
+
cache?: AiExplanationCache;
|
|
11
|
+
env?: Record<string, string | undefined>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Orchestrates AI explain: disabled/misconfigured -> ConfigError (CLI exit
|
|
16
|
+
* code 2, since this is a config problem, not a scan failure); cache hit ->
|
|
17
|
+
* returned without any network call; cache miss -> allowlisted+sanitized
|
|
18
|
+
* payload sent to the configured provider, result cached for next time.
|
|
19
|
+
*/
|
|
20
|
+
export async function explainPort(entry: PortEntry, config: PortmindConfig, deps: ExplainDeps = {}): Promise<string> {
|
|
21
|
+
if (!config.ai.enabled) {
|
|
22
|
+
throw new ConfigError("AI is disabled. Set ai.enabled: true in your config to use `explain`.");
|
|
23
|
+
}
|
|
24
|
+
if (config.ai.provider === "none") {
|
|
25
|
+
throw new ConfigError('ai.provider is "none". Set it to "anthropic" or "openai" to use `explain`.');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const processKey = entry.processName ?? "unknown";
|
|
29
|
+
const cache = deps.cache ?? new AiExplanationCache(config.history.dbPath);
|
|
30
|
+
const cached = cache.get(processKey, entry.port);
|
|
31
|
+
if (cached !== null) return cached;
|
|
32
|
+
|
|
33
|
+
const env = deps.env ?? process.env;
|
|
34
|
+
const apiKey = config.ai.provider === "anthropic" ? env.ANTHROPIC_API_KEY : env.OPENAI_API_KEY;
|
|
35
|
+
if (!apiKey) {
|
|
36
|
+
const envVar = config.ai.provider === "anthropic" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
|
|
37
|
+
throw new ConfigError(`${envVar} is not set. AI explain requires an API key in that environment variable.`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const payload = buildExplainPayload(entry, config.ai.fieldsSent);
|
|
41
|
+
const provider: AiProvider =
|
|
42
|
+
config.ai.provider === "anthropic" ? createAnthropicProvider(deps.fetchImpl) : createOpenAiProvider(deps.fetchImpl);
|
|
43
|
+
|
|
44
|
+
const explanation = await provider.explain(payload, apiKey, config.ai.model);
|
|
45
|
+
cache.set(processKey, entry.port, explanation);
|
|
46
|
+
return explanation;
|
|
47
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider abstraction for AI `explain`. Each provider takes the already
|
|
3
|
+
* allowlisted+sanitized payload (see allowlist.ts) and returns a plain-
|
|
4
|
+
* English explanation string.
|
|
5
|
+
*
|
|
6
|
+
* `fetchImpl` is injectable so tests can verify the exact request shape
|
|
7
|
+
* (URL, headers, body) without making a real network call - there is no
|
|
8
|
+
* API key available in this environment to test a live call end to end,
|
|
9
|
+
* so correctness here rests on matching each provider's documented API
|
|
10
|
+
* shape exactly, not on having actually exercised it against the real
|
|
11
|
+
* service.
|
|
12
|
+
*/
|
|
13
|
+
export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
|
|
14
|
+
|
|
15
|
+
export interface AiProvider {
|
|
16
|
+
explain(payload: Record<string, unknown>, apiKey: string, model: string): Promise<string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class AiProviderError extends Error {}
|
|
20
|
+
|
|
21
|
+
function buildPrompt(payload: Record<string, unknown>): string {
|
|
22
|
+
return [
|
|
23
|
+
"You are helping a developer understand what is running on a network port on their machine.",
|
|
24
|
+
"Given this data about a listening port, explain in 2-3 plain-English sentences what this process/service most likely is and why it might be running:",
|
|
25
|
+
JSON.stringify(payload, null, 2),
|
|
26
|
+
].join("\n\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createAnthropicProvider(fetchImpl: FetchLike = fetch): AiProvider {
|
|
30
|
+
return {
|
|
31
|
+
async explain(payload, apiKey, model) {
|
|
32
|
+
const res = await fetchImpl("https://api.anthropic.com/v1/messages", {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: {
|
|
35
|
+
"content-type": "application/json",
|
|
36
|
+
"x-api-key": apiKey,
|
|
37
|
+
"anthropic-version": "2023-06-01",
|
|
38
|
+
},
|
|
39
|
+
body: JSON.stringify({
|
|
40
|
+
model,
|
|
41
|
+
max_tokens: 300,
|
|
42
|
+
messages: [{ role: "user", content: buildPrompt(payload) }],
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
throw new AiProviderError(`Anthropic API error: ${res.status} ${await safeText(res)}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const data = (await res.json()) as { content?: Array<{ type: string; text?: string }> };
|
|
51
|
+
const text = data.content?.find((block) => block.type === "text")?.text;
|
|
52
|
+
if (!text) throw new AiProviderError("Anthropic API returned no text content");
|
|
53
|
+
return text.trim();
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createOpenAiProvider(fetchImpl: FetchLike = fetch): AiProvider {
|
|
59
|
+
return {
|
|
60
|
+
async explain(payload, apiKey, model) {
|
|
61
|
+
const res = await fetchImpl("https://api.openai.com/v1/chat/completions", {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: {
|
|
64
|
+
"content-type": "application/json",
|
|
65
|
+
authorization: `Bearer ${apiKey}`,
|
|
66
|
+
},
|
|
67
|
+
body: JSON.stringify({
|
|
68
|
+
model,
|
|
69
|
+
messages: [{ role: "user", content: buildPrompt(payload) }],
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
throw new AiProviderError(`OpenAI API error: ${res.status} ${await safeText(res)}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const data = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> };
|
|
78
|
+
const text = data.choices?.[0]?.message?.content;
|
|
79
|
+
if (!text) throw new AiProviderError("OpenAI API returned no message content");
|
|
80
|
+
return text.trim();
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function safeText(res: Response): Promise<string> {
|
|
86
|
+
try {
|
|
87
|
+
return await res.text();
|
|
88
|
+
} catch {
|
|
89
|
+
return "";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heuristic redaction of secret/token-shaped substrings in a command line,
|
|
3
|
+
* per the spec's "strip anything that looks like a secret/token pattern
|
|
4
|
+
* before sending" requirement. This is best-effort, not a guarantee - it
|
|
5
|
+
* catches common flag/env patterns, not every possible way a secret could
|
|
6
|
+
* appear in a command line.
|
|
7
|
+
*/
|
|
8
|
+
const SECRET_FLAG_PATTERN =
|
|
9
|
+
/(--?(?:password|passwd|pwd|token|secret|api[-_]?key|apikey|auth|access[-_]?key)[=\s]+)(\S+)/gi;
|
|
10
|
+
const BEARER_PATTERN = /\bBearer\s+\S+/gi;
|
|
11
|
+
const ENV_ASSIGNMENT_PATTERN =
|
|
12
|
+
/\b([A-Z0-9_]*(?:PASSWORD|SECRET|TOKEN|API_?KEY|ACCESS_?KEY)[A-Z0-9_]*)=(\S+)/g;
|
|
13
|
+
|
|
14
|
+
export function sanitizeCmdline(cmdline: string): string {
|
|
15
|
+
return cmdline
|
|
16
|
+
.replace(SECRET_FLAG_PATTERN, "$1[REDACTED]")
|
|
17
|
+
.replace(BEARER_PATTERN, "Bearer [REDACTED]")
|
|
18
|
+
.replace(ENV_ASSIGNMENT_PATTERN, "$1=[REDACTED]");
|
|
19
|
+
}
|
|
@@ -11,3 +11,7 @@ export {
|
|
|
11
11
|
type LoadedConfigPaths,
|
|
12
12
|
} from "./configLoader.js";
|
|
13
13
|
export type { KnownPortsConfig, KnownPortsIndex } from "./knownPorts/lookup.js";
|
|
14
|
+
export { explainPort, type ExplainDeps } from "./ai/explain.js";
|
|
15
|
+
export { AiProviderError } from "./ai/provider.js";
|
|
16
|
+
export { sanitizeCmdline } from "./ai/sanitize.js";
|
|
17
|
+
export { buildExplainPayload } from "./ai/allowlist.js";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { AiExplanationCache } from "../src/ai/cache.js";
|
|
6
|
+
|
|
7
|
+
let tmpDir: string | null = null;
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
|
10
|
+
tmpDir = null;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("AiExplanationCache", () => {
|
|
14
|
+
it("returns null for a key that was never set", () => {
|
|
15
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "portmind-ai-cache-"));
|
|
16
|
+
const cache = new AiExplanationCache(path.join(tmpDir, "test.db"));
|
|
17
|
+
expect(cache.get("node", 3000)).toBeNull();
|
|
18
|
+
cache.close();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("stores and retrieves an explanation keyed by (process_name, port)", () => {
|
|
22
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "portmind-ai-cache-"));
|
|
23
|
+
const cache = new AiExplanationCache(path.join(tmpDir, "test.db"));
|
|
24
|
+
cache.set("postgres", 5432, "This is a PostgreSQL database.");
|
|
25
|
+
expect(cache.get("postgres", 5432)).toBe("This is a PostgreSQL database.");
|
|
26
|
+
expect(cache.get("postgres", 9999)).toBeNull();
|
|
27
|
+
cache.close();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("overwrites an existing entry for the same key", () => {
|
|
31
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "portmind-ai-cache-"));
|
|
32
|
+
const cache = new AiExplanationCache(path.join(tmpDir, "test.db"));
|
|
33
|
+
cache.set("node", 3000, "First explanation.");
|
|
34
|
+
cache.set("node", 3000, "Updated explanation.");
|
|
35
|
+
expect(cache.get("node", 3000)).toBe("Updated explanation.");
|
|
36
|
+
cache.close();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildExplainPayload } from "../src/ai/allowlist.js";
|
|
3
|
+
import type { PortEntry } from "../src/types.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: 123,
|
|
12
|
+
processName: "node",
|
|
13
|
+
cmdline: "node server.js --token secret123",
|
|
14
|
+
cwd: "/Users/dev/app",
|
|
15
|
+
startedAt: null,
|
|
16
|
+
docker: { containerId: "abc", containerName: "my-app", image: "node:22" },
|
|
17
|
+
knownService: null,
|
|
18
|
+
history: { usual: true, observationCount: 1, firstSeen: null, usualOccupant: null },
|
|
19
|
+
riskFlags: [],
|
|
20
|
+
aiExplanation: null,
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("buildExplainPayload", () => {
|
|
26
|
+
it("only includes fields named in fieldsSent", () => {
|
|
27
|
+
const payload = buildExplainPayload(makeEntry(), ["port", "process_name"]);
|
|
28
|
+
expect(Object.keys(payload).sort()).toEqual(["port", "process_name"]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("never includes cwd, host, pid, or riskFlags even if entry has them", () => {
|
|
32
|
+
const payload = buildExplainPayload(makeEntry(), [
|
|
33
|
+
"process_name",
|
|
34
|
+
"cmdline",
|
|
35
|
+
"port",
|
|
36
|
+
"protocol",
|
|
37
|
+
"docker_image",
|
|
38
|
+
]);
|
|
39
|
+
expect(payload).not.toHaveProperty("cwd");
|
|
40
|
+
expect(payload).not.toHaveProperty("host");
|
|
41
|
+
expect(payload).not.toHaveProperty("pid");
|
|
42
|
+
expect(payload).not.toHaveProperty("riskFlags");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("sanitizes cmdline before including it", () => {
|
|
46
|
+
const payload = buildExplainPayload(makeEntry(), ["cmdline"]);
|
|
47
|
+
expect(payload.cmdline).toBe("node server.js --token [REDACTED]");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("extracts docker_image from the docker object, not the whole object", () => {
|
|
51
|
+
const payload = buildExplainPayload(makeEntry(), ["docker_image"]);
|
|
52
|
+
expect(payload.docker_image).toBe("node:22");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns null for docker_image when not docker-backed", () => {
|
|
56
|
+
const payload = buildExplainPayload(makeEntry({ docker: null }), ["docker_image"]);
|
|
57
|
+
expect(payload.docker_image).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns an empty object for an empty fieldsSent list", () => {
|
|
61
|
+
expect(buildExplainPayload(makeEntry(), [])).toEqual({});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, expect, it, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { explainPort } from "../src/ai/explain.js";
|
|
6
|
+
import { AiExplanationCache } from "../src/ai/cache.js";
|
|
7
|
+
import { ConfigError } from "../src/configLoader.js";
|
|
8
|
+
import { DEFAULT_CONFIG } from "../src/config.js";
|
|
9
|
+
import type { PortEntry } from "../src/types.js";
|
|
10
|
+
|
|
11
|
+
function makeEntry(overrides: Partial<PortEntry> = {}): PortEntry {
|
|
12
|
+
return {
|
|
13
|
+
host: "localhost",
|
|
14
|
+
port: 5432,
|
|
15
|
+
protocol: "tcp",
|
|
16
|
+
bindAddress: "127.0.0.1",
|
|
17
|
+
pid: 1,
|
|
18
|
+
processName: "postgres",
|
|
19
|
+
cmdline: "postgres -D /data",
|
|
20
|
+
cwd: "/data",
|
|
21
|
+
startedAt: null,
|
|
22
|
+
docker: null,
|
|
23
|
+
knownService: null,
|
|
24
|
+
history: { usual: true, observationCount: 1, firstSeen: null, usualOccupant: null },
|
|
25
|
+
riskFlags: [],
|
|
26
|
+
aiExplanation: null,
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let tmpDir: string | null = null;
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
|
34
|
+
tmpDir = null;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function tmpDbPath(): string {
|
|
38
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "portmind-explain-"));
|
|
39
|
+
return path.join(tmpDir, "test.db");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("explainPort", () => {
|
|
43
|
+
it("throws ConfigError when ai.enabled is false", async () => {
|
|
44
|
+
const config = { ...DEFAULT_CONFIG, ai: { ...DEFAULT_CONFIG.ai, enabled: false } };
|
|
45
|
+
await expect(explainPort(makeEntry(), config)).rejects.toThrow(ConfigError);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("throws ConfigError when provider is none", async () => {
|
|
49
|
+
const config = { ...DEFAULT_CONFIG, ai: { ...DEFAULT_CONFIG.ai, enabled: true, provider: "none" as const } };
|
|
50
|
+
await expect(explainPort(makeEntry(), config)).rejects.toThrow(ConfigError);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("throws ConfigError when the API key env var is missing", async () => {
|
|
54
|
+
const dbPath = tmpDbPath();
|
|
55
|
+
const config = {
|
|
56
|
+
...DEFAULT_CONFIG,
|
|
57
|
+
ai: { ...DEFAULT_CONFIG.ai, enabled: true, provider: "anthropic" as const },
|
|
58
|
+
history: { ...DEFAULT_CONFIG.history, dbPath },
|
|
59
|
+
};
|
|
60
|
+
await expect(explainPort(makeEntry(), config, { env: {} })).rejects.toThrow(/ANTHROPIC_API_KEY/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns a cached explanation without calling fetch", async () => {
|
|
64
|
+
const dbPath = tmpDbPath();
|
|
65
|
+
const cache = new AiExplanationCache(dbPath);
|
|
66
|
+
cache.set("postgres", 5432, "Cached explanation.");
|
|
67
|
+
|
|
68
|
+
const config = {
|
|
69
|
+
...DEFAULT_CONFIG,
|
|
70
|
+
ai: { ...DEFAULT_CONFIG.ai, enabled: true, provider: "anthropic" as const },
|
|
71
|
+
history: { ...DEFAULT_CONFIG.history, dbPath },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
let fetchCalled = false;
|
|
75
|
+
const fetchImpl = async () => {
|
|
76
|
+
fetchCalled = true;
|
|
77
|
+
throw new Error("should not be called");
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const result = await explainPort(makeEntry(), config, {
|
|
81
|
+
cache,
|
|
82
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
83
|
+
env: { ANTHROPIC_API_KEY: "test-key" },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(result).toBe("Cached explanation.");
|
|
87
|
+
expect(fetchCalled).toBe(false);
|
|
88
|
+
cache.close();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("calls the provider and caches the result on a cache miss", async () => {
|
|
92
|
+
const dbPath = tmpDbPath();
|
|
93
|
+
const cache = new AiExplanationCache(dbPath);
|
|
94
|
+
|
|
95
|
+
const config = {
|
|
96
|
+
...DEFAULT_CONFIG,
|
|
97
|
+
ai: { ...DEFAULT_CONFIG.ai, enabled: true, provider: "anthropic" as const },
|
|
98
|
+
history: { ...DEFAULT_CONFIG.history, dbPath },
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const fetchImpl = async () =>
|
|
102
|
+
({
|
|
103
|
+
ok: true,
|
|
104
|
+
status: 200,
|
|
105
|
+
json: async () => ({ content: [{ type: "text", text: "It is a database." }] }),
|
|
106
|
+
text: async () => "",
|
|
107
|
+
}) as Response;
|
|
108
|
+
|
|
109
|
+
const result = await explainPort(makeEntry(), config, {
|
|
110
|
+
cache,
|
|
111
|
+
fetchImpl,
|
|
112
|
+
env: { ANTHROPIC_API_KEY: "test-key" },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(result).toBe("It is a database.");
|
|
116
|
+
expect(cache.get("postgres", 5432)).toBe("It is a database.");
|
|
117
|
+
cache.close();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createAnthropicProvider, createOpenAiProvider, AiProviderError } from "../src/ai/provider.js";
|
|
3
|
+
|
|
4
|
+
function jsonResponse(body: unknown, ok = true, status = 200): Response {
|
|
5
|
+
return {
|
|
6
|
+
ok,
|
|
7
|
+
status,
|
|
8
|
+
json: async () => body,
|
|
9
|
+
text: async () => JSON.stringify(body),
|
|
10
|
+
} as Response;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("createAnthropicProvider", () => {
|
|
14
|
+
it("sends the documented Anthropic Messages API request shape", async () => {
|
|
15
|
+
const fetchMock = vi.fn().mockResolvedValue(
|
|
16
|
+
jsonResponse({ content: [{ type: "text", text: "This is postgres, a database." }] }),
|
|
17
|
+
);
|
|
18
|
+
const provider = createAnthropicProvider(fetchMock);
|
|
19
|
+
|
|
20
|
+
const result = await provider.explain({ port: 5432 }, "test-key", "claude-sonnet-4-6");
|
|
21
|
+
|
|
22
|
+
expect(result).toBe("This is postgres, a database.");
|
|
23
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
24
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
25
|
+
expect(url).toBe("https://api.anthropic.com/v1/messages");
|
|
26
|
+
expect(init.method).toBe("POST");
|
|
27
|
+
const headers = init.headers as Record<string, string>;
|
|
28
|
+
expect(headers["x-api-key"]).toBe("test-key");
|
|
29
|
+
expect(headers["anthropic-version"]).toBe("2023-06-01");
|
|
30
|
+
const body = JSON.parse(init.body as string);
|
|
31
|
+
expect(body.model).toBe("claude-sonnet-4-6");
|
|
32
|
+
expect(body.messages[0].role).toBe("user");
|
|
33
|
+
expect(body.messages[0].content).toContain('"port": 5432');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("throws AiProviderError on a non-ok response", async () => {
|
|
37
|
+
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ error: "bad key" }, false, 401));
|
|
38
|
+
const provider = createAnthropicProvider(fetchMock);
|
|
39
|
+
await expect(provider.explain({}, "bad-key", "claude-sonnet-4-6")).rejects.toThrow(AiProviderError);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("throws AiProviderError when the response has no text content", async () => {
|
|
43
|
+
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ content: [] }));
|
|
44
|
+
const provider = createAnthropicProvider(fetchMock);
|
|
45
|
+
await expect(provider.explain({}, "key", "model")).rejects.toThrow(AiProviderError);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("createOpenAiProvider", () => {
|
|
50
|
+
it("sends the documented OpenAI Chat Completions request shape", async () => {
|
|
51
|
+
const fetchMock = vi.fn().mockResolvedValue(
|
|
52
|
+
jsonResponse({ choices: [{ message: { content: "This is mysql." } }] }),
|
|
53
|
+
);
|
|
54
|
+
const provider = createOpenAiProvider(fetchMock);
|
|
55
|
+
|
|
56
|
+
const result = await provider.explain({ port: 3306 }, "test-key", "gpt-4o");
|
|
57
|
+
|
|
58
|
+
expect(result).toBe("This is mysql.");
|
|
59
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
60
|
+
expect(url).toBe("https://api.openai.com/v1/chat/completions");
|
|
61
|
+
const headers = init.headers as Record<string, string>;
|
|
62
|
+
expect(headers.authorization).toBe("Bearer test-key");
|
|
63
|
+
const body = JSON.parse(init.body as string);
|
|
64
|
+
expect(body.model).toBe("gpt-4o");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("throws AiProviderError on a non-ok response", async () => {
|
|
68
|
+
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({}, false, 500));
|
|
69
|
+
const provider = createOpenAiProvider(fetchMock);
|
|
70
|
+
await expect(provider.explain({}, "key", "gpt-4o")).rejects.toThrow(AiProviderError);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { sanitizeCmdline } from "../src/ai/sanitize.js";
|
|
3
|
+
|
|
4
|
+
describe("sanitizeCmdline", () => {
|
|
5
|
+
it("redacts --password=X style flags", () => {
|
|
6
|
+
expect(sanitizeCmdline("mysqld --password=hunter2")).toBe("mysqld --password=[REDACTED]");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("redacts --token flags with a space separator", () => {
|
|
10
|
+
expect(sanitizeCmdline("mycli --token abc123xyz")).toBe("mycli --token [REDACTED]");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("redacts Bearer tokens", () => {
|
|
14
|
+
expect(sanitizeCmdline("curl -H Authorization:Bearer sk-abc123")).toContain("Bearer [REDACTED]");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("redacts SECRET/TOKEN/KEY environment-style assignments", () => {
|
|
18
|
+
expect(sanitizeCmdline("node server.js API_KEY=sk-live-abc123")).toBe("node server.js API_KEY=[REDACTED]");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("leaves ordinary command lines untouched", () => {
|
|
22
|
+
const cmd = "node server.js --port 3000 --host 0.0.0.0";
|
|
23
|
+
expect(sanitizeCmdline(cmd)).toBe(cmd);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@portmind/web",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Local-only web dashboard for portmind - static HTML/JS over @portmind/core's /api/ports.",
|
|
5
5
|
"homepage": "https://github.com/psandis/portmind#readme",
|
|
6
6
|
"bugs": "https://github.com/psandis/portmind/issues",
|
|
@@ -2,7 +2,7 @@ import { createServer as createHttpServer, type Server } from "node:http";
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { scanPorts, loadConfig } from "@portmind/core";
|
|
5
|
+
import { scanPorts, loadConfig, explainPort, ConfigError, type PortEntry } from "@portmind/core";
|
|
6
6
|
|
|
7
7
|
const packageRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
8
|
const indexHtmlPath = path.join(packageRoot, "static", "index.html");
|
|
@@ -15,9 +15,11 @@ export interface WebServerOptions {
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Local-only dashboard server. Binds to 127.0.0.1 by default (per spec:
|
|
18
|
-
* "no external network exposure by default").
|
|
19
|
-
* page,
|
|
20
|
-
* `portmind list --json
|
|
18
|
+
* "no external network exposure by default"). Three routes: the static
|
|
19
|
+
* page, a JSON API returning the same PortEntry[] shape as
|
|
20
|
+
* `portmind list --json`, and an AI explain endpoint that only ever fires
|
|
21
|
+
* on an explicit client request (the page's "Explain with AI" button),
|
|
22
|
+
* never automatically.
|
|
21
23
|
*/
|
|
22
24
|
export function createWebServer(options: WebServerOptions): Server {
|
|
23
25
|
const host = options.host ?? "127.0.0.1";
|
|
@@ -34,6 +36,21 @@ export function createWebServer(options: WebServerOptions): Server {
|
|
|
34
36
|
return;
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
if (req.method === "POST" && url.pathname === "/api/explain") {
|
|
40
|
+
const entry = (await readJsonBody(req)) as PortEntry;
|
|
41
|
+
const config = await loadConfig();
|
|
42
|
+
try {
|
|
43
|
+
const explanation = await explainPort(entry, config);
|
|
44
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
45
|
+
res.end(JSON.stringify({ explanation }));
|
|
46
|
+
} catch (error) {
|
|
47
|
+
const status = error instanceof ConfigError ? 400 : 502;
|
|
48
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
49
|
+
res.end(JSON.stringify({ error: (error as Error).message }));
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
37
54
|
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
|
|
38
55
|
const html = await readFile(indexHtmlPath, "utf-8");
|
|
39
56
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
@@ -49,3 +66,20 @@ export function createWebServer(options: WebServerOptions): Server {
|
|
|
49
66
|
}
|
|
50
67
|
}).listen(options.port, host);
|
|
51
68
|
}
|
|
69
|
+
|
|
70
|
+
function readJsonBody(req: import("node:http").IncomingMessage): Promise<unknown> {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
let data = "";
|
|
73
|
+
req.on("data", (chunk) => {
|
|
74
|
+
data += chunk;
|
|
75
|
+
});
|
|
76
|
+
req.on("end", () => {
|
|
77
|
+
try {
|
|
78
|
+
resolve(JSON.parse(data));
|
|
79
|
+
} catch (error) {
|
|
80
|
+
reject(error);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
req.on("error", reject);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -37,16 +37,24 @@
|
|
|
37
37
|
background: var(--bg);
|
|
38
38
|
color: var(--fg);
|
|
39
39
|
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
40
|
-
padding: 24px;
|
|
40
|
+
padding: 0 24px 24px;
|
|
41
41
|
}
|
|
42
42
|
h1 { font-size: 18px; margin: 0 0 4px; }
|
|
43
|
-
.sub { color: var(--muted); margin: 0 0
|
|
43
|
+
.sub { color: var(--muted); margin: 0 0 12px; font-size: 13px; }
|
|
44
|
+
.sticky-header {
|
|
45
|
+
position: sticky;
|
|
46
|
+
top: 0;
|
|
47
|
+
z-index: 20;
|
|
48
|
+
background: var(--bg);
|
|
49
|
+
padding-top: 20px;
|
|
50
|
+
padding-bottom: 12px;
|
|
51
|
+
border-bottom: 1px solid var(--border);
|
|
52
|
+
}
|
|
44
53
|
.toolbar {
|
|
45
54
|
display: flex;
|
|
46
55
|
gap: 12px;
|
|
47
56
|
align-items: center;
|
|
48
57
|
flex-wrap: wrap;
|
|
49
|
-
margin-bottom: 16px;
|
|
50
58
|
}
|
|
51
59
|
.toolbar input[type="text"] {
|
|
52
60
|
padding: 6px 10px;
|
|
@@ -77,38 +85,71 @@
|
|
|
77
85
|
button:hover { background: var(--row-hover); }
|
|
78
86
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
79
87
|
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
|
80
|
-
th {
|
|
88
|
+
th {
|
|
89
|
+
position: sticky;
|
|
90
|
+
top: var(--sticky-header-h, 84px);
|
|
91
|
+
z-index: 15;
|
|
92
|
+
background: var(--bg);
|
|
93
|
+
color: var(--muted);
|
|
94
|
+
font-weight: 600;
|
|
95
|
+
cursor: pointer;
|
|
96
|
+
user-select: none;
|
|
97
|
+
white-space: nowrap;
|
|
98
|
+
box-shadow: 0 1px 0 var(--border);
|
|
99
|
+
}
|
|
81
100
|
th:hover { color: var(--fg); }
|
|
82
101
|
tbody tr { cursor: pointer; }
|
|
83
102
|
tbody tr:hover { background: var(--row-hover); }
|
|
84
103
|
.mono { font-family: var(--mono); }
|
|
85
104
|
.unusual { color: var(--danger); font-weight: 600; }
|
|
86
105
|
.empty { color: var(--muted); padding: 24px 0; text-align: center; }
|
|
106
|
+
.overlay {
|
|
107
|
+
display: none;
|
|
108
|
+
position: fixed;
|
|
109
|
+
inset: 0;
|
|
110
|
+
background: rgba(0, 0, 0, 0.35);
|
|
111
|
+
z-index: 90;
|
|
112
|
+
}
|
|
113
|
+
.overlay.open { display: block; }
|
|
87
114
|
.detail {
|
|
88
|
-
margin-top: 16px;
|
|
89
|
-
padding: 16px;
|
|
90
|
-
border: 1px solid var(--border);
|
|
91
|
-
border-radius: 8px;
|
|
92
|
-
background: var(--panel-bg);
|
|
93
115
|
display: none;
|
|
116
|
+
position: fixed;
|
|
117
|
+
top: 0;
|
|
118
|
+
right: 0;
|
|
119
|
+
bottom: 0;
|
|
120
|
+
width: min(420px, 100vw);
|
|
121
|
+
padding: 20px;
|
|
122
|
+
border-left: 1px solid var(--border);
|
|
123
|
+
background: var(--panel-bg);
|
|
124
|
+
z-index: 91;
|
|
125
|
+
overflow-y: auto;
|
|
126
|
+
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.15);
|
|
94
127
|
}
|
|
95
128
|
.detail.open { display: block; }
|
|
96
|
-
.detail
|
|
129
|
+
.detail-close {
|
|
130
|
+
position: absolute;
|
|
131
|
+
top: 12px;
|
|
132
|
+
right: 12px;
|
|
133
|
+
}
|
|
134
|
+
.detail h2 { font-size: 15px; margin: 0 0 16px; padding-right: 32px; }
|
|
135
|
+
.detail dl { display: grid; grid-template-columns: 120px 1fr; gap: 8px 12px; margin: 0; }
|
|
97
136
|
.detail dt { color: var(--muted); }
|
|
98
137
|
.detail dd { margin: 0; word-break: break-all; }
|
|
99
138
|
.status { font-size: 12px; color: var(--muted); margin-top: 12px; }
|
|
100
139
|
</style>
|
|
101
140
|
</head>
|
|
102
141
|
<body>
|
|
103
|
-
<
|
|
104
|
-
|
|
142
|
+
<div class="sticky-header" id="stickyHeader">
|
|
143
|
+
<h1>portmind</h1>
|
|
144
|
+
<p class="sub">Listening ports on <span id="host">localhost</span></p>
|
|
105
145
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
146
|
+
<div class="toolbar">
|
|
147
|
+
<button id="refresh">Refresh</button>
|
|
148
|
+
<label><input type="checkbox" id="autoRefresh" /> Auto-refresh (5s)</label>
|
|
149
|
+
<input type="text" id="rangeFilter" placeholder="Range e.g. 3000-9000" />
|
|
150
|
+
<label><input type="checkbox" id="dockerOnly" /> Docker only</label>
|
|
151
|
+
<label><input type="checkbox" id="unusualOnly" /> Unusual only</label>
|
|
152
|
+
</div>
|
|
112
153
|
</div>
|
|
113
154
|
|
|
114
155
|
<table>
|
|
@@ -120,13 +161,14 @@
|
|
|
120
161
|
<th data-key="pid">PID</th>
|
|
121
162
|
<th data-key="docker">DOCKER</th>
|
|
122
163
|
<th data-key="usual">USUAL</th>
|
|
123
|
-
<th data-key="note">
|
|
164
|
+
<th data-key="note">DESCRIPTION</th>
|
|
124
165
|
</tr>
|
|
125
166
|
</thead>
|
|
126
167
|
<tbody id="rows"></tbody>
|
|
127
168
|
</table>
|
|
128
169
|
<div id="empty" class="empty" hidden>No listening ports found.</div>
|
|
129
170
|
|
|
171
|
+
<div id="overlay" class="overlay"></div>
|
|
130
172
|
<div id="detail" class="detail"></div>
|
|
131
173
|
<div class="status" id="status"></div>
|
|
132
174
|
|
|
@@ -136,6 +178,7 @@
|
|
|
136
178
|
var rowsEl = document.getElementById("rows");
|
|
137
179
|
var emptyEl = document.getElementById("empty");
|
|
138
180
|
var detailEl = document.getElementById("detail");
|
|
181
|
+
var overlayEl = document.getElementById("overlay");
|
|
139
182
|
var statusEl = document.getElementById("status");
|
|
140
183
|
var autoRefreshTimer = null;
|
|
141
184
|
|
|
@@ -201,8 +244,11 @@
|
|
|
201
244
|
function showDetail(entry) {
|
|
202
245
|
state.selected = entry;
|
|
203
246
|
detailEl.classList.add("open");
|
|
247
|
+
overlayEl.classList.add("open");
|
|
204
248
|
var explainBtn = "<button id='explainBtn'>Explain with AI</button>";
|
|
205
249
|
detailEl.innerHTML =
|
|
250
|
+
"<button class='detail-close' id='detailClose'>Close</button>" +
|
|
251
|
+
"<h2>Port " + entry.port + "/" + entry.protocol + "</h2>" +
|
|
206
252
|
"<dl>" +
|
|
207
253
|
"<dt>Host</dt><dd>" + entry.host + "</dd>" +
|
|
208
254
|
"<dt>Port</dt><dd>" + entry.port + "/" + entry.protocol + "</dd>" +
|
|
@@ -212,17 +258,45 @@
|
|
|
212
258
|
"<dt>Working dir</dt><dd>" + (entry.cwd || "-") + "</dd>" +
|
|
213
259
|
"<dt>Started</dt><dd>" + (entry.startedAt || "-") + "</dd>" +
|
|
214
260
|
"<dt>Docker</dt><dd>" + (entry.docker ? entry.docker.containerName + " (" + entry.docker.image + ")" : "-") + "</dd>" +
|
|
215
|
-
"<dt>
|
|
261
|
+
"<dt>Service name</dt><dd>" + (entry.knownService ? entry.knownService.name : "-") + "</dd>" +
|
|
262
|
+
"<dt>Description</dt><dd>" + (entry.knownService ? entry.knownService.description : "-") + "</dd>" +
|
|
216
263
|
"<dt>Risk flags</dt><dd>" + (entry.riskFlags.length ? entry.riskFlags.join(", ") : "none") + "</dd>" +
|
|
217
|
-
"<dt>AI explanation</dt><dd>" + (entry.aiExplanation ||
|
|
264
|
+
"<dt>AI explanation</dt><dd id='aiExplanation'>" + (entry.aiExplanation || explainBtn) +
|
|
265
|
+
" <span id='aiError' style='color:var(--danger)'></span></dd>" +
|
|
218
266
|
"</dl>";
|
|
219
267
|
var btn = document.getElementById("explainBtn");
|
|
220
268
|
if (btn) {
|
|
221
269
|
btn.addEventListener("click", function () {
|
|
222
270
|
btn.disabled = true;
|
|
223
|
-
btn.textContent = "AI
|
|
271
|
+
btn.textContent = "Asking AI...";
|
|
272
|
+
fetch("/api/explain", {
|
|
273
|
+
method: "POST",
|
|
274
|
+
headers: { "content-type": "application/json" },
|
|
275
|
+
body: JSON.stringify(entry),
|
|
276
|
+
})
|
|
277
|
+
.then(function (r) {
|
|
278
|
+
return r.json().then(function (body) {
|
|
279
|
+
if (!r.ok) throw new Error(body.error || "request failed");
|
|
280
|
+
return body;
|
|
281
|
+
});
|
|
282
|
+
})
|
|
283
|
+
.then(function (body) {
|
|
284
|
+
btn.replaceWith(document.createTextNode(body.explanation));
|
|
285
|
+
})
|
|
286
|
+
.catch(function (err) {
|
|
287
|
+
btn.disabled = false;
|
|
288
|
+
btn.textContent = "Explain with AI";
|
|
289
|
+
document.getElementById("aiError").textContent = err.message;
|
|
290
|
+
});
|
|
224
291
|
});
|
|
225
292
|
}
|
|
293
|
+
document.getElementById("detailClose").addEventListener("click", closeDetail);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function closeDetail() {
|
|
297
|
+
detailEl.classList.remove("open");
|
|
298
|
+
overlayEl.classList.remove("open");
|
|
299
|
+
state.selected = null;
|
|
226
300
|
}
|
|
227
301
|
|
|
228
302
|
function load() {
|
|
@@ -261,6 +335,18 @@
|
|
|
261
335
|
}
|
|
262
336
|
});
|
|
263
337
|
|
|
338
|
+
overlayEl.addEventListener("click", closeDetail);
|
|
339
|
+
document.addEventListener("keydown", function (e) {
|
|
340
|
+
if (e.key === "Escape") closeDetail();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
function updateStickyOffset() {
|
|
344
|
+
var header = document.getElementById("stickyHeader");
|
|
345
|
+
document.documentElement.style.setProperty("--sticky-header-h", header.offsetHeight + "px");
|
|
346
|
+
}
|
|
347
|
+
window.addEventListener("resize", updateStickyOffset);
|
|
348
|
+
updateStickyOffset();
|
|
349
|
+
|
|
264
350
|
load();
|
|
265
351
|
})();
|
|
266
352
|
</script>
|
|
@@ -34,4 +34,15 @@ describe("web server", () => {
|
|
|
34
34
|
const res = await fetch(`${BASE_URL}/nope`);
|
|
35
35
|
expect(res.status).toBe(404);
|
|
36
36
|
});
|
|
37
|
+
|
|
38
|
+
it("POST /api/explain returns 400 when AI is disabled (the default)", async () => {
|
|
39
|
+
const res = await fetch(`${BASE_URL}/api/explain`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "content-type": "application/json" },
|
|
42
|
+
body: JSON.stringify({ port: 3000, processName: "node", protocol: "tcp" }),
|
|
43
|
+
});
|
|
44
|
+
expect(res.status).toBe(400);
|
|
45
|
+
const body = await res.json();
|
|
46
|
+
expect(body.error).toMatch(/AI is disabled/);
|
|
47
|
+
});
|
|
37
48
|
});
|