@murk-security/cli 0.3.1 → 0.4.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/mcp-server.mjs +18 -4
- package/murk.mjs +83 -5
- package/package.json +1 -1
package/mcp-server.mjs
CHANGED
|
@@ -14,10 +14,24 @@ import { z } from 'zod';
|
|
|
14
14
|
const ok = (data) => ({
|
|
15
15
|
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
16
16
|
});
|
|
17
|
-
const fail = (error) =>
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
const fail = (error) => {
|
|
18
|
+
// Free-pentest gate (402): give the agent a clear, actionable message + link so
|
|
19
|
+
// it tells the user to subscribe rather than reporting a generic error.
|
|
20
|
+
if (error && error.upgrade) {
|
|
21
|
+
const url = error.upgradeUrl || 'the MURK billing page in the dashboard';
|
|
22
|
+
return {
|
|
23
|
+
content: [{
|
|
24
|
+
type: 'text',
|
|
25
|
+
text: `This MURK organization has used its one free pentest. To run more pentests the user needs to subscribe. Tell the user to open ${url} and choose a plan, then retry.`,
|
|
26
|
+
}],
|
|
27
|
+
isError: true,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
32
|
+
isError: true,
|
|
33
|
+
};
|
|
34
|
+
};
|
|
21
35
|
|
|
22
36
|
/** Build the MURK MCP server with all tools, backed by the given authenticated `api`. */
|
|
23
37
|
export function buildMurkMcpServer(api, opts = {}) {
|
package/murk.mjs
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* those, read-only, and refuses everything else.
|
|
7
7
|
*
|
|
8
8
|
* murk login # browser sign-in; authorizes this machine
|
|
9
|
+
* murk logout # revoke this machine's credential
|
|
10
|
+
* murk whoami # show login status
|
|
9
11
|
* murk mcp --expose http://localhost:3000 # MCP server for your agent + tunnel
|
|
10
12
|
* murk scan http://localhost:3000 # one-shot: connect, scan, disconnect
|
|
11
13
|
* murk connect --expose http://localhost:3000 # tunnel only (no MCP)
|
|
@@ -21,8 +23,8 @@ import os from 'node:os';
|
|
|
21
23
|
import path from 'node:path';
|
|
22
24
|
import { randomBytes } from 'node:crypto';
|
|
23
25
|
import { spawn } from 'node:child_process';
|
|
24
|
-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
25
|
-
import {
|
|
26
|
+
import { mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
26
28
|
|
|
27
29
|
// Default to the hosted MURK service; devs point these at a local control-plane/dashboard.
|
|
28
30
|
const API_URL = (process.env.MURK_API_URL ?? 'https://api.murk.sh').replace(/\/$/, '');
|
|
@@ -79,10 +81,36 @@ async function api(method, path, body) {
|
|
|
79
81
|
});
|
|
80
82
|
const text = await res.text();
|
|
81
83
|
let data; try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; }
|
|
82
|
-
if (!res.ok)
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
const err = new Error(data.error ?? `HTTP ${res.status}`);
|
|
86
|
+
err.status = res.status;
|
|
87
|
+
err.upgrade = Boolean(data.upgrade); // free-pentest gate (402)
|
|
88
|
+
err.upgradeUrl = data.upgradeUrl;
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
83
91
|
return data;
|
|
84
92
|
}
|
|
85
93
|
|
|
94
|
+
/** Free-tier gate, surfaced early and clearly. Returns before scanning when the
|
|
95
|
+
* org has spent its free pentest; notes the free run on the first scan. Billing
|
|
96
|
+
* being off (self-host) or unreachable never blocks a scan — the launch stays
|
|
97
|
+
* the authoritative gate. */
|
|
98
|
+
async function ensureCanLaunch() {
|
|
99
|
+
let s;
|
|
100
|
+
try { s = await api('GET', '/api/billing/status'); }
|
|
101
|
+
catch (e) { if (e.status === 503 || e.status === 409) return; throw e; }
|
|
102
|
+
const ent = s.entitlement;
|
|
103
|
+
if (!ent || !ent.gated) return;
|
|
104
|
+
if (!ent.canLaunch) {
|
|
105
|
+
log('you’ve used your free pentest. Subscribe to run more:');
|
|
106
|
+
log(` ${s.billingUrl || API_URL}`);
|
|
107
|
+
process.exit(2);
|
|
108
|
+
}
|
|
109
|
+
if (!ent.subscribed && !ent.freePentestUsed) {
|
|
110
|
+
log(`free trial: this is your 1 free pentest (up to ${ent.freeMaxMinutes} min). Subscribe any time for unlimited: ${s.billingUrl || ''}`.trimEnd());
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
86
114
|
/** Open the tunnel for a set of exposed origins. `onReady(url, appId)` fires as each target
|
|
87
115
|
* is registered server-side. The scope allowlist is enforced HERE — this process is the
|
|
88
116
|
* trust boundary on the customer side. */
|
|
@@ -167,6 +195,7 @@ async function launchScan(applicationId) {
|
|
|
167
195
|
async function cmdScan(url) {
|
|
168
196
|
await ensureAuth();
|
|
169
197
|
preflightChecks([url]);
|
|
198
|
+
await ensureCanLaunch(); // fail fast + note the free trial before opening the tunnel
|
|
170
199
|
let done = false;
|
|
171
200
|
const ws = openTunnel([url], {
|
|
172
201
|
onReady: async (_u, applicationId) => {
|
|
@@ -183,7 +212,14 @@ async function cmdScan(url) {
|
|
|
183
212
|
break;
|
|
184
213
|
}
|
|
185
214
|
}
|
|
186
|
-
} catch (e) {
|
|
215
|
+
} catch (e) {
|
|
216
|
+
if (e && e.upgrade) {
|
|
217
|
+
log('you’ve used your free pentest. Subscribe to run more:');
|
|
218
|
+
log(` ${e.upgradeUrl || API_URL}`);
|
|
219
|
+
} else {
|
|
220
|
+
log(`scan failed: ${e instanceof Error ? e.message : e}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
187
223
|
finally { done = true; try { ws.close(); } catch { /* noop */ } process.exit(0); }
|
|
188
224
|
},
|
|
189
225
|
onClose: () => { if (!done) die('tunnel closed before the scan finished.'); },
|
|
@@ -280,6 +316,35 @@ async function ensureAuth() {
|
|
|
280
316
|
await cmdLogin();
|
|
281
317
|
}
|
|
282
318
|
|
|
319
|
+
// Revoke the stored credential server-side and remove it locally. Env-provided tokens
|
|
320
|
+
// (MURK_API_TOKEN, for CI) are left alone — unset the variable to "log out" of those.
|
|
321
|
+
async function cmdLogout() {
|
|
322
|
+
if (process.env.MURK_API_TOKEN) {
|
|
323
|
+
log('MURK_API_TOKEN is set in your environment — unset it to log out. (No stored credential was changed.)');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const stored = readCredential();
|
|
327
|
+
if (!stored) { log('not logged in.'); return; }
|
|
328
|
+
try {
|
|
329
|
+
await fetch(`${API_URL}/api/cli/auth/logout`, { method: 'POST', headers: { Authorization: `Bearer ${stored}` } });
|
|
330
|
+
} catch { /* offline — still clear the local credential below */ }
|
|
331
|
+
try { rmSync(CRED_PATH, { force: true }); } catch { /* noop */ }
|
|
332
|
+
TOKEN = '';
|
|
333
|
+
log('logged out — credential revoked and removed.');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Report whether this machine is authenticated, and validate the credential against the API.
|
|
337
|
+
async function cmdWhoami() {
|
|
338
|
+
if (!TOKEN) { log('not logged in. Run: murk login'); return; }
|
|
339
|
+
const src = process.env.MURK_API_TOKEN ? 'MURK_API_TOKEN env' : '~/.murk/credentials.json';
|
|
340
|
+
try {
|
|
341
|
+
const res = await fetch(`${API_URL}/api/applications`, { headers: { Authorization: `Bearer ${TOKEN}` } });
|
|
342
|
+
if (res.ok) log(`logged in to ${API_URL} (credential: ${src})`);
|
|
343
|
+
else if (res.status === 401) log('stored credential is invalid or revoked — run: murk login');
|
|
344
|
+
else log(`logged in (${src}), but ${API_URL} returned HTTP ${res.status}`);
|
|
345
|
+
} catch (e) { log(`could not reach ${API_URL}: ${e instanceof Error ? e.message : e}`); }
|
|
346
|
+
}
|
|
347
|
+
|
|
283
348
|
function parseExposed(rest) {
|
|
284
349
|
const exposed = [];
|
|
285
350
|
for (let i = 0; i < rest.length; i++) {
|
|
@@ -294,6 +359,10 @@ function main() {
|
|
|
294
359
|
const fatal = (e) => die(e instanceof Error ? e.message : String(e));
|
|
295
360
|
if (cmd === 'login') {
|
|
296
361
|
void cmdLogin().catch(fatal);
|
|
362
|
+
} else if (cmd === 'logout') {
|
|
363
|
+
void cmdLogout().catch(fatal);
|
|
364
|
+
} else if (cmd === 'whoami') {
|
|
365
|
+
void cmdWhoami().catch(fatal);
|
|
297
366
|
} else if (cmd === 'mcp') {
|
|
298
367
|
void cmdMcp(parseExposed(rest)).catch(fatal);
|
|
299
368
|
} else if (cmd === 'connect') {
|
|
@@ -304,6 +373,8 @@ function main() {
|
|
|
304
373
|
process.stdout.write(
|
|
305
374
|
'usage:\n' +
|
|
306
375
|
' murk login # browser sign-in; authorizes this machine\n' +
|
|
376
|
+
' murk logout # revoke this machine’s credential\n' +
|
|
377
|
+
' murk whoami # show login status\n' +
|
|
307
378
|
' murk mcp [--expose http://localhost:3000 ...] # MCP server for your agent; exposes local apps\n' +
|
|
308
379
|
' murk scan http://localhost:3000 # one-shot: connect, scan, disconnect\n' +
|
|
309
380
|
' murk connect --expose http://localhost:3000 # tunnel only (no MCP)\n',
|
|
@@ -313,4 +384,11 @@ function main() {
|
|
|
313
384
|
}
|
|
314
385
|
|
|
315
386
|
// Run the CLI only when executed directly, not when imported (e.g. by the test suite).
|
|
316
|
-
|
|
387
|
+
// Compare realpaths so a bin symlink (node_modules/.bin/murk -> murk.mjs, as npx installs it)
|
|
388
|
+
// still counts as direct execution — a plain href compare misses the symlink and no-ops.
|
|
389
|
+
function isEntrypoint() {
|
|
390
|
+
const arg = process.argv[1];
|
|
391
|
+
if (!arg) return false;
|
|
392
|
+
try { return realpathSync(arg) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
|
|
393
|
+
}
|
|
394
|
+
if (isEntrypoint()) main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@murk-security/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Run MURK's MCP server for your AI agent and scan locally-running apps with MURK's hosted runner over a secure outbound tunnel — no local runner, no model key.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|