@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.
Files changed (3) hide show
  1. package/mcp-server.mjs +18 -4
  2. package/murk.mjs +83 -5
  3. 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
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
19
- isError: true,
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 { pathToFileURL } from 'node:url';
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) throw new Error(data.error ?? `HTTP ${res.status}`);
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) { log(`scan failed: ${e instanceof Error ? e.message : 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
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
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.1",
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": {