@murk-security/cli 0.3.2 → 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 +93 -0
- package/mcp-server.mjs +96 -17
- package/murk.mjs +164 -17
- package/package.json +3 -2
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
|
@@ -14,22 +14,71 @@ 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
|
+
};
|
|
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
|
+
}
|
|
21
76
|
|
|
22
77
|
/** Build the MURK MCP server with all tools, backed by the given authenticated `api`. */
|
|
23
78
|
export function buildMurkMcpServer(api, opts = {}) {
|
|
24
79
|
const RUNNER_MODE = opts.runnerMode ?? 'basic-harness';
|
|
25
80
|
|
|
26
|
-
|
|
27
|
-
const apps = await api('GET', '/api/applications');
|
|
28
|
-
const list = Array.isArray(apps) ? apps : (apps.applications ?? []);
|
|
29
|
-
const app = list.find((a) => a.id === applicationId);
|
|
30
|
-
if (!app) throw new Error(`application ${applicationId} not found`);
|
|
31
|
-
return app;
|
|
32
|
-
}
|
|
81
|
+
const getApplication = (idOrName) => resolveApplication(api, idOrName);
|
|
33
82
|
|
|
34
83
|
// Create -> preflight -> launch an assessment of the given kind (the flow the dashboard
|
|
35
84
|
// uses), returning the started run.
|
|
@@ -47,6 +96,7 @@ export function buildMurkMcpServer(api, opts = {}) {
|
|
|
47
96
|
policy: { profile: 'standard', destructiveActions: false, maxConcurrency: 2, maxDurationMinutes: 20 },
|
|
48
97
|
packs: ['surface', 'sast', 'intel', 'scan', 'authz'],
|
|
49
98
|
kind: o.kind,
|
|
99
|
+
...(o.scanProfile ? { scanProfile: o.scanProfile } : {}),
|
|
50
100
|
...(o.remediateFindingId ? { remediateFindingId: o.remediateFindingId } : {}),
|
|
51
101
|
};
|
|
52
102
|
const created = await api('POST', '/api/assessments', config);
|
|
@@ -68,11 +118,23 @@ export function buildMurkMcpServer(api, opts = {}) {
|
|
|
68
118
|
},
|
|
69
119
|
);
|
|
70
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
|
+
|
|
71
133
|
server.registerTool(
|
|
72
134
|
'murk_list_findings',
|
|
73
135
|
{
|
|
74
136
|
title: 'List findings',
|
|
75
|
-
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.',
|
|
76
138
|
inputSchema: { applicationId: z.string().optional(), status: z.string().optional(), severity: z.string().optional() },
|
|
77
139
|
},
|
|
78
140
|
async ({ applicationId, status, severity }) => {
|
|
@@ -120,15 +182,32 @@ export function buildMurkMcpServer(api, opts = {}) {
|
|
|
120
182
|
|
|
121
183
|
server.registerTool(
|
|
122
184
|
'murk_scan',
|
|
123
|
-
{
|
|
124
|
-
|
|
125
|
-
|
|
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); }
|
|
126
205
|
},
|
|
127
206
|
);
|
|
128
207
|
|
|
129
208
|
server.registerTool(
|
|
130
209
|
'murk_retest',
|
|
131
|
-
{ 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.') } },
|
|
132
211
|
async ({ applicationId }) => {
|
|
133
212
|
try { return ok(await launchAssessment(await getApplication(applicationId), { kind: 'retest', name: 'Retest (via MCP)' })); } catch (e) { return fail(e); }
|
|
134
213
|
},
|
|
@@ -136,7 +215,7 @@ export function buildMurkMcpServer(api, opts = {}) {
|
|
|
136
215
|
|
|
137
216
|
server.registerTool(
|
|
138
217
|
'murk_create_patch',
|
|
139
|
-
{ 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.') } },
|
|
140
219
|
async ({ findingId, applicationId }) => {
|
|
141
220
|
try {
|
|
142
221
|
return ok(await launchAssessment(await getApplication(applicationId), { kind: 'remediate', name: 'Patch (via MCP)', remediateFindingId: findingId }));
|
package/murk.mjs
CHANGED
|
@@ -6,8 +6,12 @@
|
|
|
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
|
|
11
|
+
* murk apps # list your applications
|
|
12
|
+
* murk add owner/repo [--name NAME] # add an app from a connected GitHub repo
|
|
9
13
|
* murk mcp --expose http://localhost:3000 # MCP server for your agent + tunnel
|
|
10
|
-
* murk scan http://localhost:3000
|
|
14
|
+
* murk scan http://localhost:3000 [--pr|--full] # one-shot: connect, scan, disconnect
|
|
11
15
|
* murk connect --expose http://localhost:3000 # tunnel only (no MCP)
|
|
12
16
|
*
|
|
13
17
|
* Env:
|
|
@@ -21,7 +25,7 @@ import os from 'node:os';
|
|
|
21
25
|
import path from 'node:path';
|
|
22
26
|
import { randomBytes } from 'node:crypto';
|
|
23
27
|
import { spawn } from 'node:child_process';
|
|
24
|
-
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
25
29
|
import { fileURLToPath } from 'node:url';
|
|
26
30
|
|
|
27
31
|
// Default to the hosted MURK service; devs point these at a local control-plane/dashboard.
|
|
@@ -79,10 +83,36 @@ async function api(method, path, body) {
|
|
|
79
83
|
});
|
|
80
84
|
const text = await res.text();
|
|
81
85
|
let data; try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; }
|
|
82
|
-
if (!res.ok)
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
const err = new Error(data.error ?? `HTTP ${res.status}`);
|
|
88
|
+
err.status = res.status;
|
|
89
|
+
err.upgrade = Boolean(data.upgrade); // free-pentest gate (402)
|
|
90
|
+
err.upgradeUrl = data.upgradeUrl;
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
83
93
|
return data;
|
|
84
94
|
}
|
|
85
95
|
|
|
96
|
+
/** Free-tier gate, surfaced early and clearly. Returns before scanning when the
|
|
97
|
+
* org has spent its free pentest; notes the free run on the first scan. Billing
|
|
98
|
+
* being off (self-host) or unreachable never blocks a scan — the launch stays
|
|
99
|
+
* the authoritative gate. */
|
|
100
|
+
async function ensureCanLaunch() {
|
|
101
|
+
let s;
|
|
102
|
+
try { s = await api('GET', '/api/billing/status'); }
|
|
103
|
+
catch (e) { if (e.status === 503 || e.status === 409) return; throw e; }
|
|
104
|
+
const ent = s.entitlement;
|
|
105
|
+
if (!ent || !ent.gated) return;
|
|
106
|
+
if (!ent.canLaunch) {
|
|
107
|
+
log('you’ve used your free pentest. Subscribe to run more:');
|
|
108
|
+
log(` ${s.billingUrl || API_URL}`);
|
|
109
|
+
process.exit(2);
|
|
110
|
+
}
|
|
111
|
+
if (!ent.subscribed && !ent.freePentestUsed) {
|
|
112
|
+
log(`free trial: this is your 1 free pentest (up to ${ent.freeMaxMinutes} min). Subscribe any time for unlimited: ${s.billingUrl || ''}`.trimEnd());
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
86
116
|
/** Open the tunnel for a set of exposed origins. `onReady(url, appId)` fires as each target
|
|
87
117
|
* is registered server-side. The scope allowlist is enforced HERE — this process is the
|
|
88
118
|
* trust boundary on the customer side. */
|
|
@@ -145,7 +175,7 @@ async function cmdConnect(exposed) {
|
|
|
145
175
|
process.on('SIGTERM', stop);
|
|
146
176
|
}
|
|
147
177
|
|
|
148
|
-
async function launchScan(applicationId) {
|
|
178
|
+
async function launchScan(applicationId, scanProfile) {
|
|
149
179
|
const apps = await api('GET', '/api/applications');
|
|
150
180
|
const list = Array.isArray(apps) ? apps : (apps.applications ?? []);
|
|
151
181
|
const app = list.find((a) => a.id === applicationId);
|
|
@@ -156,6 +186,7 @@ async function launchScan(applicationId) {
|
|
|
156
186
|
target: { environmentAssetIds: assetIds }, scope: {}, identities: app.identities ?? [],
|
|
157
187
|
policy: { profile: 'standard', destructiveActions: false, maxConcurrency: 1, maxDurationMinutes: 15 },
|
|
158
188
|
packs: ['surface', 'sast', 'intel', 'scan', 'authz'], kind: 'assess',
|
|
189
|
+
...(scanProfile ? { scanProfile } : {}),
|
|
159
190
|
};
|
|
160
191
|
const created = await api('POST', '/api/assessments', config);
|
|
161
192
|
const pre = await api('POST', `/api/assessments/${created.id}/preflight`, { runnerMode: 'basic-harness' });
|
|
@@ -164,15 +195,16 @@ async function launchScan(applicationId) {
|
|
|
164
195
|
return run.runId ?? run.id ?? run.run?.id;
|
|
165
196
|
}
|
|
166
197
|
|
|
167
|
-
async function cmdScan(url) {
|
|
198
|
+
async function cmdScan(url, scanProfile) {
|
|
168
199
|
await ensureAuth();
|
|
169
200
|
preflightChecks([url]);
|
|
201
|
+
await ensureCanLaunch(); // fail fast + note the free trial before opening the tunnel
|
|
170
202
|
let done = false;
|
|
171
203
|
const ws = openTunnel([url], {
|
|
172
204
|
onReady: async (_u, applicationId) => {
|
|
173
205
|
try {
|
|
174
|
-
log(
|
|
175
|
-
const runId = await launchScan(applicationId);
|
|
206
|
+
log(`starting hosted ${scanProfile === 'pr' ? 'PR (fast)' : 'full'} scan ...`);
|
|
207
|
+
const runId = await launchScan(applicationId, scanProfile);
|
|
176
208
|
log(`run ${runId} started; watching ...`);
|
|
177
209
|
for (;;) {
|
|
178
210
|
await new Promise((r) => setTimeout(r, 4000));
|
|
@@ -183,7 +215,14 @@ async function cmdScan(url) {
|
|
|
183
215
|
break;
|
|
184
216
|
}
|
|
185
217
|
}
|
|
186
|
-
} catch (e) {
|
|
218
|
+
} catch (e) {
|
|
219
|
+
if (e && e.upgrade) {
|
|
220
|
+
log('you’ve used your free pentest. Subscribe to run more:');
|
|
221
|
+
log(` ${e.upgradeUrl || API_URL}`);
|
|
222
|
+
} else {
|
|
223
|
+
log(`scan failed: ${e instanceof Error ? e.message : e}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
187
226
|
finally { done = true; try { ws.close(); } catch { /* noop */ } process.exit(0); }
|
|
188
227
|
},
|
|
189
228
|
onClose: () => { if (!done) die('tunnel closed before the scan finished.'); },
|
|
@@ -280,6 +319,73 @@ async function ensureAuth() {
|
|
|
280
319
|
await cmdLogin();
|
|
281
320
|
}
|
|
282
321
|
|
|
322
|
+
// Revoke the stored credential server-side and remove it locally. Env-provided tokens
|
|
323
|
+
// (MURK_API_TOKEN, for CI) are left alone — unset the variable to "log out" of those.
|
|
324
|
+
async function cmdLogout() {
|
|
325
|
+
if (process.env.MURK_API_TOKEN) {
|
|
326
|
+
log('MURK_API_TOKEN is set in your environment — unset it to log out. (No stored credential was changed.)');
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const stored = readCredential();
|
|
330
|
+
if (!stored) { log('not logged in.'); return; }
|
|
331
|
+
try {
|
|
332
|
+
await fetch(`${API_URL}/api/cli/auth/logout`, { method: 'POST', headers: { Authorization: `Bearer ${stored}` } });
|
|
333
|
+
} catch { /* offline — still clear the local credential below */ }
|
|
334
|
+
try { rmSync(CRED_PATH, { force: true }); } catch { /* noop */ }
|
|
335
|
+
TOKEN = '';
|
|
336
|
+
log('logged out — credential revoked and removed.');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Report whether this machine is authenticated, and validate the credential against the API.
|
|
340
|
+
async function cmdWhoami() {
|
|
341
|
+
if (!TOKEN) { log('not logged in. Run: murk login'); return; }
|
|
342
|
+
const src = process.env.MURK_API_TOKEN ? 'MURK_API_TOKEN env' : '~/.murk/credentials.json';
|
|
343
|
+
try {
|
|
344
|
+
const res = await fetch(`${API_URL}/api/applications`, { headers: { Authorization: `Bearer ${TOKEN}` } });
|
|
345
|
+
if (res.ok) log(`logged in to ${API_URL} (credential: ${src})`);
|
|
346
|
+
else if (res.status === 401) log('stored credential is invalid or revoked — run: murk login');
|
|
347
|
+
else log(`logged in (${src}), but ${API_URL} returned HTTP ${res.status}`);
|
|
348
|
+
} catch (e) { log(`could not reach ${API_URL}: ${e instanceof Error ? e.message : e}`); }
|
|
349
|
+
}
|
|
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
|
+
|
|
283
389
|
function parseExposed(rest) {
|
|
284
390
|
const exposed = [];
|
|
285
391
|
for (let i = 0; i < rest.length; i++) {
|
|
@@ -289,26 +395,67 @@ function parseExposed(rest) {
|
|
|
289
395
|
return exposed;
|
|
290
396
|
}
|
|
291
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
|
+
|
|
292
435
|
function main() {
|
|
293
436
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
294
437
|
const fatal = (e) => die(e instanceof Error ? e.message : String(e));
|
|
295
438
|
if (cmd === 'login') {
|
|
296
439
|
void cmdLogin().catch(fatal);
|
|
440
|
+
} else if (cmd === 'logout') {
|
|
441
|
+
void cmdLogout().catch(fatal);
|
|
442
|
+
} else if (cmd === 'whoami') {
|
|
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);
|
|
297
448
|
} else if (cmd === 'mcp') {
|
|
298
449
|
void cmdMcp(parseExposed(rest)).catch(fatal);
|
|
299
450
|
} else if (cmd === 'connect') {
|
|
300
451
|
void cmdConnect(parseExposed(rest)).catch(fatal);
|
|
301
452
|
} else if (cmd === 'scan') {
|
|
302
|
-
void cmdScan(rest.find((a) => a.startsWith('http'))).catch(fatal);
|
|
453
|
+
void cmdScan(rest.find((a) => a.startsWith('http')), parseScanProfile(rest)).catch(fatal);
|
|
303
454
|
} else {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
' murk scan http://localhost:3000 # one-shot: connect, scan, disconnect\n' +
|
|
309
|
-
' murk connect --expose http://localhost:3000 # tunnel only (no MCP)\n',
|
|
310
|
-
);
|
|
311
|
-
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);
|
|
312
459
|
}
|
|
313
460
|
}
|
|
314
461
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@murk-security/cli",
|
|
3
|
-
"version": "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",
|