@murk-security/cli 0.4.0 → 0.5.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/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # @murk-security/cli
2
+
3
+ Run [MURK](https://murk.sh)'s MCP server for your AI agent, and scan your
4
+ locally-running apps with MURK's **hosted** runner over a secure **outbound** tunnel.
5
+
6
+ - **No local runner, no model key** — scanning happens on MURK's infrastructure.
7
+ - **Nothing inbound** to your network. You choose exactly which `localhost` URLs are
8
+ exposed; this process forwards **only** those, **read-only** (GET/HEAD), and refuses
9
+ everything else. The allowlist is enforced on your machine — it's the trust boundary.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ npm i -g @murk-security/cli
15
+ # or run without installing:
16
+ npx @murk-security/cli --help
17
+ ```
18
+
19
+ Requires Node ≥ 20.
20
+
21
+ ## Quickstart
22
+
23
+ ```sh
24
+ murk login # browser sign-in; authorizes this machine
25
+ murk add murk-security/northwind-store # add an app from a connected GitHub repo
26
+ murk apps # list your applications + ids
27
+ murk scan http://localhost:3000 # scan a locally-running app (full depth)
28
+ ```
29
+
30
+ ## Use it from an AI agent (MCP)
31
+
32
+ **Hosted** (recommended) — no local process to keep running:
33
+
34
+ ```sh
35
+ claude mcp add --transport http murk https://api.murk.sh/mcp
36
+ ```
37
+
38
+ **Local** — also bridges any `--expose`'d local apps so the hosted runner can reach them:
39
+
40
+ ```sh
41
+ murk mcp --expose http://localhost:3000
42
+ ```
43
+
44
+ Either way the agent gets these tools:
45
+
46
+ | Tool | What it does |
47
+ | --- | --- |
48
+ | `murk_list_applications` | List your apps (targets). |
49
+ | `murk_add_application` | Add an app from a connected GitHub repo. |
50
+ | `murk_scan` | Start a scan. Accepts an app **id or name**; `scanProfile: pr\|full`. |
51
+ | `murk_retest` | Re-verify known findings; fixed ones are closed. |
52
+ | `murk_create_patch` | Generate a code patch for a finding and open a PR. |
53
+ | `murk_list_findings` | List findings, filtered by app / status / severity. |
54
+ | `murk_get_finding` | One finding, with its reproduction recipe and any patch. |
55
+ | `murk_get_run` | A run and its findings — poll this for scan progress. |
56
+
57
+ Scans and patches run **asynchronously** — the start tools return a run id; poll
58
+ `murk_get_run` / `murk_get_finding` for results.
59
+
60
+ ## Commands
61
+
62
+ ```
63
+ murk login browser sign-in; authorizes this machine
64
+ murk logout revoke this machine's credential
65
+ murk whoami show login status
66
+ murk apps list your applications (id, name, source)
67
+ murk add <owner/repo> [--name NAME] add an app from a connected GitHub repo
68
+ murk scan <http://localhost:PORT> [--pr|--full] one-shot: connect, scan, disconnect
69
+ murk mcp [--expose <http://localhost:PORT> ...] run the MCP server for your agent
70
+ murk connect --expose <http://localhost:PORT> open the tunnel only (no MCP)
71
+ ```
72
+
73
+ ### Scan depth
74
+
75
+ - `--full` — deep whole-repo audit with the model council (~45 min). **Default.**
76
+ - `--pr` — fast, cheaper scan, no council (~10 min).
77
+
78
+ ## GitHub apps
79
+
80
+ `murk add owner/repo` requires the **MURK GitHub App** to be installed on that repository
81
+ (and granted access). Connect it from the MURK dashboard, then the repo becomes available
82
+ to `murk add` and `murk_add_application`.
83
+
84
+ ## Environment
85
+
86
+ | Variable | Default | Purpose |
87
+ | --- | --- | --- |
88
+ | `MURK_API_URL` | `https://api.murk.sh` | API origin (set to `http://localhost:8788` for dev). |
89
+ | `MURK_DASHBOARD_URL` | `https://app.murk.sh` | Dashboard origin for login. |
90
+ | `MURK_API_TOKEN` | — | Org API token (`murk_…`) for CI; overrides the stored login. |
91
+ | `MURK_NO_BROWSER` | — | Set to print the login URL instead of opening a browser (headless/SSH). |
92
+
93
+ The login credential is stored at `~/.murk/credentials.json` (mode `600`).
package/mcp-server.mjs CHANGED
@@ -33,17 +33,52 @@ const fail = (error) => {
33
33
  };
34
34
  };
35
35
 
36
+ /** Resolve an application by id, or by exact name (case-insensitive), or a unique name
37
+ * substring — so callers can pass a friendly name instead of an opaque id. Exported so the
38
+ * CLI and the MCP tools share one resolution rule. Throws a clear, actionable error. */
39
+ export async function resolveApplication(api, idOrName) {
40
+ const want = String(idOrName ?? '').trim();
41
+ if (!want) throw new Error('no application id or name given');
42
+ const apps = await api('GET', '/api/applications');
43
+ const list = Array.isArray(apps) ? apps : (apps.applications ?? []);
44
+ let app = list.find((a) => a.id === want)
45
+ || list.find((a) => String(a.name).toLowerCase() === want.toLowerCase());
46
+ if (!app) {
47
+ const matches = list.filter((a) => String(a.name).toLowerCase().includes(want.toLowerCase()));
48
+ if (matches.length === 1) [app] = matches;
49
+ else if (matches.length > 1) {
50
+ throw new Error(`"${idOrName}" matches ${matches.length} applications (${matches.map((m) => m.name).join(', ')}); use the exact name or id.`);
51
+ }
52
+ }
53
+ if (!app) throw new Error(`no application matches "${idOrName}". List them with murk_list_applications (or \`murk apps\`).`);
54
+ return app;
55
+ }
56
+
57
+ /** Add an application from a connected GitHub repo (owner/name, grey-box source form).
58
+ * Exported so the CLI's `murk add` and the MCP `murk_add_application` tool share one path. */
59
+ export async function addGithubApplication(api, { githubRepo, name }) {
60
+ const want = String(githubRepo).trim().toLowerCase();
61
+ const conns = await api('GET', '/api/connections');
62
+ const active = (Array.isArray(conns) ? conns : conns.connections ?? []).filter((c) => c.status === 'active');
63
+ let match;
64
+ for (const c of active) {
65
+ const repos = await api('GET', `/api/connections/${encodeURIComponent(c.id)}/repositories`).catch(() => []);
66
+ const r = (Array.isArray(repos) ? repos : repos.repositories ?? []).find((x) => String(x.fullName).toLowerCase() === want);
67
+ if (r) { match = { connectionId: c.id, repositoryId: r.id, fullName: r.fullName }; break; }
68
+ }
69
+ if (!match) throw new Error(`No connected GitHub repository matches "${githubRepo}". Install the MURK GitHub app on that repo (and grant it access), then retry.`);
70
+ const asset = await api('POST', '/api/assets/github', { connectionId: match.connectionId, repositoryId: match.repositoryId });
71
+ return api('POST', '/api/applications', {
72
+ name: name || match.fullName, description: '', context: '',
73
+ sourceAssetId: asset.id, environments: [], identities: [],
74
+ });
75
+ }
76
+
36
77
  /** Build the MURK MCP server with all tools, backed by the given authenticated `api`. */
37
78
  export function buildMurkMcpServer(api, opts = {}) {
38
79
  const RUNNER_MODE = opts.runnerMode ?? 'basic-harness';
39
80
 
40
- async function getApplication(applicationId) {
41
- const apps = await api('GET', '/api/applications');
42
- const list = Array.isArray(apps) ? apps : (apps.applications ?? []);
43
- const app = list.find((a) => a.id === applicationId);
44
- if (!app) throw new Error(`application ${applicationId} not found`);
45
- return app;
46
- }
81
+ const getApplication = (idOrName) => resolveApplication(api, idOrName);
47
82
 
48
83
  // Create -> preflight -> launch an assessment of the given kind (the flow the dashboard
49
84
  // uses), returning the started run.
@@ -61,6 +96,7 @@ export function buildMurkMcpServer(api, opts = {}) {
61
96
  policy: { profile: 'standard', destructiveActions: false, maxConcurrency: 2, maxDurationMinutes: 20 },
62
97
  packs: ['surface', 'sast', 'intel', 'scan', 'authz'],
63
98
  kind: o.kind,
99
+ ...(o.scanProfile ? { scanProfile: o.scanProfile } : {}),
64
100
  ...(o.remediateFindingId ? { remediateFindingId: o.remediateFindingId } : {}),
65
101
  };
66
102
  const created = await api('POST', '/api/assessments', config);
@@ -82,11 +118,23 @@ export function buildMurkMcpServer(api, opts = {}) {
82
118
  },
83
119
  );
84
120
 
121
+ server.registerTool(
122
+ 'murk_add_application',
123
+ {
124
+ title: 'Add an application',
125
+ description: 'Add an application from a connected GitHub repository (source-only, grey-box — the form factor for PR scans and patches). The MURK GitHub App must already be installed on the repo. Returns the created application.',
126
+ inputSchema: { githubRepo: z.string().describe('owner/name, e.g. murk-security/northwind-store'), name: z.string().optional() },
127
+ },
128
+ async ({ githubRepo, name }) => {
129
+ try { return ok(await addGithubApplication(api, { githubRepo, name })); } catch (e) { return fail(e); }
130
+ },
131
+ );
132
+
85
133
  server.registerTool(
86
134
  'murk_list_findings',
87
135
  {
88
136
  title: 'List findings',
89
- description: 'List findings, optionally filtered by application, status (open/fixed/regressed/accepted) or severity.',
137
+ description: 'List findings, optionally filtered by application, status (open/in_progress/ready_for_retest/fixed/regressed/accepted) or severity.',
90
138
  inputSchema: { applicationId: z.string().optional(), status: z.string().optional(), severity: z.string().optional() },
91
139
  },
92
140
  async ({ applicationId, status, severity }) => {
@@ -134,15 +182,32 @@ export function buildMurkMcpServer(api, opts = {}) {
134
182
 
135
183
  server.registerTool(
136
184
  'murk_scan',
137
- { title: 'Scan an application', description: 'Start a security assessment of an application (public or locally-connected). Returns the started run.', inputSchema: { applicationId: z.string() } },
138
- async ({ applicationId }) => {
139
- try { return ok(await launchAssessment(await getApplication(applicationId), { kind: 'assess', name: 'Scan (via MCP)' })); } catch (e) { return fail(e); }
185
+ {
186
+ title: 'Scan an application',
187
+ description: 'Start a security assessment of an application (public or locally-connected). Returns the started run; the scan runs asynchronously — poll murk_get_run with the returned run id for status and findings.',
188
+ inputSchema: {
189
+ applicationId: z.string().describe('Application id OR name (e.g. "northwind-store"); names are resolved for you.'),
190
+ scanProfile: z.enum(['pr', 'full']).optional().describe(
191
+ "Scan depth. 'full' (default): deep whole-repo audit with the model council (~45 min). " +
192
+ "'pr': fast, cheaper scan with no council (~10 min) — scoped to a pull-request diff when one " +
193
+ 'is available, otherwise a light whole-repo pass.',
194
+ ),
195
+ },
196
+ },
197
+ async ({ applicationId, scanProfile }) => {
198
+ try {
199
+ return ok(await launchAssessment(await getApplication(applicationId), {
200
+ kind: 'assess',
201
+ name: `Scan (via MCP${scanProfile ? `, ${scanProfile}` : ''})`,
202
+ scanProfile,
203
+ }));
204
+ } catch (e) { return fail(e); }
140
205
  },
141
206
  );
142
207
 
143
208
  server.registerTool(
144
209
  'murk_retest',
145
- { title: 'Retest findings', description: 'Re-verify an application’s known findings; findings that no longer reproduce are marked fixed.', inputSchema: { applicationId: z.string() } },
210
+ { title: 'Retest findings', description: 'Re-verify an application’s known findings; findings that no longer reproduce are marked fixed.', inputSchema: { applicationId: z.string().describe('Application id OR name.') } },
146
211
  async ({ applicationId }) => {
147
212
  try { return ok(await launchAssessment(await getApplication(applicationId), { kind: 'retest', name: 'Retest (via MCP)' })); } catch (e) { return fail(e); }
148
213
  },
@@ -150,7 +215,7 @@ export function buildMurkMcpServer(api, opts = {}) {
150
215
 
151
216
  server.registerTool(
152
217
  'murk_create_patch',
153
- { title: 'Create a patch', description: 'Generate a code patch for one finding (requires a connected source repo) and open a PR.', inputSchema: { findingId: z.string(), applicationId: z.string() } },
218
+ { title: 'Create a patch', description: 'Generate a code patch for one finding (requires a connected source repo) and open a PR. The patch is generated asynchronously — poll murk_get_finding to see the diff and PR link when ready.', inputSchema: { findingId: z.string(), applicationId: z.string().describe('Application id OR name.') } },
154
219
  async ({ findingId, applicationId }) => {
155
220
  try {
156
221
  return ok(await launchAssessment(await getApplication(applicationId), { kind: 'remediate', name: 'Patch (via MCP)', remediateFindingId: findingId }));
package/murk.mjs CHANGED
@@ -8,8 +8,10 @@
8
8
  * murk login # browser sign-in; authorizes this machine
9
9
  * murk logout # revoke this machine's credential
10
10
  * murk whoami # show login status
11
+ * murk apps # list your applications
12
+ * murk add owner/repo [--name NAME] # add an app from a connected GitHub repo
11
13
  * murk mcp --expose http://localhost:3000 # MCP server for your agent + tunnel
12
- * murk scan http://localhost:3000 # one-shot: connect, scan, disconnect
14
+ * murk scan http://localhost:3000 [--pr|--full] # one-shot: connect, scan, disconnect
13
15
  * murk connect --expose http://localhost:3000 # tunnel only (no MCP)
14
16
  *
15
17
  * Env:
@@ -173,7 +175,7 @@ async function cmdConnect(exposed) {
173
175
  process.on('SIGTERM', stop);
174
176
  }
175
177
 
176
- async function launchScan(applicationId) {
178
+ async function launchScan(applicationId, scanProfile) {
177
179
  const apps = await api('GET', '/api/applications');
178
180
  const list = Array.isArray(apps) ? apps : (apps.applications ?? []);
179
181
  const app = list.find((a) => a.id === applicationId);
@@ -184,6 +186,7 @@ async function launchScan(applicationId) {
184
186
  target: { environmentAssetIds: assetIds }, scope: {}, identities: app.identities ?? [],
185
187
  policy: { profile: 'standard', destructiveActions: false, maxConcurrency: 1, maxDurationMinutes: 15 },
186
188
  packs: ['surface', 'sast', 'intel', 'scan', 'authz'], kind: 'assess',
189
+ ...(scanProfile ? { scanProfile } : {}),
187
190
  };
188
191
  const created = await api('POST', '/api/assessments', config);
189
192
  const pre = await api('POST', `/api/assessments/${created.id}/preflight`, { runnerMode: 'basic-harness' });
@@ -192,7 +195,7 @@ async function launchScan(applicationId) {
192
195
  return run.runId ?? run.id ?? run.run?.id;
193
196
  }
194
197
 
195
- async function cmdScan(url) {
198
+ async function cmdScan(url, scanProfile) {
196
199
  await ensureAuth();
197
200
  preflightChecks([url]);
198
201
  await ensureCanLaunch(); // fail fast + note the free trial before opening the tunnel
@@ -200,8 +203,8 @@ async function cmdScan(url) {
200
203
  const ws = openTunnel([url], {
201
204
  onReady: async (_u, applicationId) => {
202
205
  try {
203
- log('starting hosted scan ...');
204
- const runId = await launchScan(applicationId);
206
+ log(`starting hosted ${scanProfile === 'pr' ? 'PR (fast)' : 'full'} scan ...`);
207
+ const runId = await launchScan(applicationId, scanProfile);
205
208
  log(`run ${runId} started; watching ...`);
206
209
  for (;;) {
207
210
  await new Promise((r) => setTimeout(r, 4000));
@@ -345,6 +348,44 @@ async function cmdWhoami() {
345
348
  } catch (e) { log(`could not reach ${API_URL}: ${e instanceof Error ? e.message : e}`); }
346
349
  }
347
350
 
351
+ // Add a GitHub-connected application from the CLI (mirrors the MCP murk_add_application
352
+ // tool; shares addGithubApplication so the two never diverge). Logs go to stderr; the new
353
+ // application id is printed to stdout so it can be piped into scripts.
354
+ async function cmdAdd(repo, name) {
355
+ await ensureAuth();
356
+ if (!repo) die('usage: murk add <owner/repo> [--name NAME]');
357
+ const { addGithubApplication } = await import('./mcp-server.mjs');
358
+ const app = await addGithubApplication(api, { githubRepo: repo, name });
359
+ log(`added application "${app.name}" (${app.id})`);
360
+ process.stdout.write(`${app.id}\n`);
361
+ }
362
+
363
+ // List the organization's applications (id, name, source kind) for discovery.
364
+ async function cmdApps() {
365
+ await ensureAuth();
366
+ const apps = await api('GET', '/api/applications');
367
+ const list = Array.isArray(apps) ? apps : (apps.applications ?? []);
368
+ if (!list.length) { log('no applications yet. Add one: murk add <owner/repo>'); return; }
369
+ for (const a of list) {
370
+ const src = a.sourceAssetId ? 'github' : ((a.environments ?? []).length ? 'env' : '-');
371
+ process.stdout.write(`${a.id} ${a.name} [${src}]\n`);
372
+ }
373
+ }
374
+
375
+ // First positional arg that isn't a flag or a flag's value (for `murk add <repo>`).
376
+ function firstPositional(rest, valueFlags = ['--name']) {
377
+ for (let i = 0; i < rest.length; i++) {
378
+ if (valueFlags.includes(rest[i])) { i++; continue; }
379
+ if (!rest[i].startsWith('-')) return rest[i];
380
+ }
381
+ return undefined;
382
+ }
383
+
384
+ function parseFlagValue(rest, flag) {
385
+ const i = rest.indexOf(flag);
386
+ return i >= 0 && rest[i + 1] ? rest[i + 1] : undefined;
387
+ }
388
+
348
389
  function parseExposed(rest) {
349
390
  const exposed = [];
350
391
  for (let i = 0; i < rest.length; i++) {
@@ -354,6 +395,43 @@ function parseExposed(rest) {
354
395
  return exposed;
355
396
  }
356
397
 
398
+ // Scan depth from flags: --pr / --full, or --profile pr|full. Default (undefined) => 'full'
399
+ // server-side. 'pr' is the fast, cheaper, no-council scan.
400
+ function parseScanProfile(rest) {
401
+ for (let i = 0; i < rest.length; i++) {
402
+ if (rest[i] === '--pr') return 'pr';
403
+ if (rest[i] === '--full') return 'full';
404
+ if (rest[i] === '--profile' && (rest[i + 1] === 'pr' || rest[i + 1] === 'full')) return rest[i + 1];
405
+ }
406
+ return undefined;
407
+ }
408
+
409
+ const USAGE =
410
+ 'murk — MCP server for your AI agent + scan your local apps with MURK’s hosted runner.\n' +
411
+ '\n' +
412
+ 'usage:\n' +
413
+ ' murk login browser sign-in; authorizes this machine\n' +
414
+ ' murk logout revoke this machine’s credential\n' +
415
+ ' murk whoami show login status\n' +
416
+ ' murk apps list your applications (id, name, source)\n' +
417
+ ' murk add <owner/repo> [--name NAME] add an app from a connected GitHub repo\n' +
418
+ ' murk scan <http://localhost:PORT> [--pr|--full] one-shot: connect, scan, disconnect (default: full)\n' +
419
+ ' murk mcp [--expose <http://localhost:PORT> ...] run the MCP server for your agent; exposes local apps\n' +
420
+ ' murk connect --expose <http://localhost:PORT> open the tunnel only (no MCP)\n' +
421
+ '\n' +
422
+ 'scan depth:\n' +
423
+ ' --full deep whole-repo audit with the model council (~45 min) [default]\n' +
424
+ ' --pr fast, cheaper scan, no council (~10 min)\n' +
425
+ '\n' +
426
+ 'env:\n' +
427
+ ' MURK_API_URL API origin (default https://api.murk.sh)\n' +
428
+ ' MURK_DASHBOARD_URL dashboard origin for login (default https://app.murk.sh)\n' +
429
+ ' MURK_API_TOKEN org API token (murk_…) for CI; overrides the stored login\n' +
430
+ ' MURK_NO_BROWSER set to print the login URL instead of opening a browser\n' +
431
+ '\n' +
432
+ 'agents: add the hosted MCP with claude mcp add --transport http murk https://api.murk.sh/mcp\n' +
433
+ ' or run it locally with murk mcp\n';
434
+
357
435
  function main() {
358
436
  const [cmd, ...rest] = process.argv.slice(2);
359
437
  const fatal = (e) => die(e instanceof Error ? e.message : String(e));
@@ -363,23 +441,21 @@ function main() {
363
441
  void cmdLogout().catch(fatal);
364
442
  } else if (cmd === 'whoami') {
365
443
  void cmdWhoami().catch(fatal);
444
+ } else if (cmd === 'add') {
445
+ void cmdAdd(firstPositional(rest), parseFlagValue(rest, '--name')).catch(fatal);
446
+ } else if (cmd === 'apps' || cmd === 'ls') {
447
+ void cmdApps().catch(fatal);
366
448
  } else if (cmd === 'mcp') {
367
449
  void cmdMcp(parseExposed(rest)).catch(fatal);
368
450
  } else if (cmd === 'connect') {
369
451
  void cmdConnect(parseExposed(rest)).catch(fatal);
370
452
  } else if (cmd === 'scan') {
371
- void cmdScan(rest.find((a) => a.startsWith('http'))).catch(fatal);
453
+ void cmdScan(rest.find((a) => a.startsWith('http')), parseScanProfile(rest)).catch(fatal);
372
454
  } else {
373
- process.stdout.write(
374
- 'usage:\n' +
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' +
378
- ' murk mcp [--expose http://localhost:3000 ...] # MCP server for your agent; exposes local apps\n' +
379
- ' murk scan http://localhost:3000 # one-shot: connect, scan, disconnect\n' +
380
- ' murk connect --expose http://localhost:3000 # tunnel only (no MCP)\n',
381
- );
382
- process.exit(cmd ? 1 : 0);
455
+ const unknown = cmd && cmd !== 'help' && cmd !== '-h' && cmd !== '--help';
456
+ if (unknown) process.stderr.write(`murk: unknown command "${cmd}"\n\n`);
457
+ process.stdout.write(USAGE);
458
+ process.exit(unknown ? 1 : 0);
383
459
  }
384
460
  }
385
461
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murk-security/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.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": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "files": [
10
10
  "murk.mjs",
11
- "mcp-server.mjs"
11
+ "mcp-server.mjs",
12
+ "README.md"
12
13
  ],
13
14
  "keywords": [
14
15
  "murk",