toolmoat 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +302 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +80 -0
- package/dist/clients/configs.d.ts +124 -0
- package/dist/clients/configs.js +403 -0
- package/dist/commands/approve.d.ts +15 -0
- package/dist/commands/approve.js +128 -0
- package/dist/commands/diff.d.ts +6 -0
- package/dist/commands/diff.js +114 -0
- package/dist/commands/pins.d.ts +18 -0
- package/dist/commands/pins.js +88 -0
- package/dist/commands/proxy.d.ts +24 -0
- package/dist/commands/proxy.js +144 -0
- package/dist/commands/rules.d.ts +15 -0
- package/dist/commands/rules.js +173 -0
- package/dist/commands/scan.d.ts +57 -0
- package/dist/commands/scan.js +323 -0
- package/dist/commands/unwrap.d.ts +5 -0
- package/dist/commands/unwrap.js +69 -0
- package/dist/commands/wrap.d.ts +131 -0
- package/dist/commands/wrap.js +518 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +13 -0
- package/dist/patterns/indicators.d.ts +41 -0
- package/dist/patterns/indicators.js +85 -0
- package/dist/patterns/secrets.d.ts +20 -0
- package/dist/patterns/secrets.js +70 -0
- package/dist/proxy/audit.d.ts +53 -0
- package/dist/proxy/audit.js +79 -0
- package/dist/proxy/canonical.d.ts +13 -0
- package/dist/proxy/canonical.js +34 -0
- package/dist/proxy/diff.d.ts +22 -0
- package/dist/proxy/diff.js +124 -0
- package/dist/proxy/fingerprint.d.ts +34 -0
- package/dist/proxy/fingerprint.js +140 -0
- package/dist/proxy/firewall.d.ts +132 -0
- package/dist/proxy/firewall.js +665 -0
- package/dist/proxy/framing.d.ts +45 -0
- package/dist/proxy/framing.js +86 -0
- package/dist/proxy/lockfile.d.ts +58 -0
- package/dist/proxy/lockfile.js +258 -0
- package/dist/proxy/names.d.ts +27 -0
- package/dist/proxy/names.js +175 -0
- package/dist/proxy/policy.d.ts +72 -0
- package/dist/proxy/policy.js +86 -0
- package/dist/proxy/proxy.d.ts +18 -0
- package/dist/proxy/proxy.js +237 -0
- package/dist/proxy/spawn.d.ts +51 -0
- package/dist/proxy/spawn.js +110 -0
- package/dist/report/index.d.ts +35 -0
- package/dist/report/index.js +25 -0
- package/dist/report/json.d.ts +22 -0
- package/dist/report/json.js +40 -0
- package/dist/report/markdown.d.ts +12 -0
- package/dist/report/markdown.js +87 -0
- package/dist/report/sarif.d.ts +89 -0
- package/dist/report/sarif.js +142 -0
- package/dist/report/text.d.ts +16 -0
- package/dist/report/text.js +206 -0
- package/dist/rules/code.d.ts +15 -0
- package/dist/rules/code.js +729 -0
- package/dist/rules/config.d.ts +3 -0
- package/dist/rules/config.js +2164 -0
- package/dist/rules/index.d.ts +3 -0
- package/dist/rules/index.js +8 -0
- package/dist/rules/secrets.d.ts +2 -0
- package/dist/rules/secrets.js +108 -0
- package/dist/rules/text.d.ts +2 -0
- package/dist/rules/text.js +1698 -0
- package/dist/scan/discover.d.ts +37 -0
- package/dist/scan/discover.js +525 -0
- package/dist/scan/engine.d.ts +24 -0
- package/dist/scan/engine.js +111 -0
- package/dist/scan/ignore.d.ts +28 -0
- package/dist/scan/ignore.js +46 -0
- package/dist/types.d.ts +98 -0
- package/dist/types.js +25 -0
- package/dist/util/colors.d.ts +12 -0
- package/dist/util/colors.js +24 -0
- package/dist/util/frontmatter.d.ts +20 -0
- package/dist/util/frontmatter.js +136 -0
- package/dist/util/glob.d.ts +7 -0
- package/dist/util/glob.js +44 -0
- package/dist/util/hash.d.ts +3 -0
- package/dist/util/hash.js +24 -0
- package/dist/util/jsonc.d.ts +14 -0
- package/dist/util/jsonc.js +113 -0
- package/dist/util/paths.d.ts +9 -0
- package/dist/util/paths.js +37 -0
- package/dist/util/text.d.ts +29 -0
- package/dist/util/text.js +96 -0
- package/dist/util/toml-lite.d.ts +40 -0
- package/dist/util/toml-lite.js +507 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/package.json +57 -4
|
@@ -0,0 +1,2164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structure-aware rules for MCP client configs, agent harness settings and
|
|
3
|
+
* hooks, plugin manifests, package manifests and skill frontmatter.
|
|
4
|
+
*
|
|
5
|
+
* Each file is parsed once (JSONC or TOML) into a small model: the MCP
|
|
6
|
+
* servers it declares, the hook commands it registers and its permission
|
|
7
|
+
* grants. Rules read that model and point findings at the exact key, using a
|
|
8
|
+
* position map built from the raw text. Command strings found inside configs
|
|
9
|
+
* (hook commands, inline server scripts, install scripts) are also run
|
|
10
|
+
* through the dangerous-code rules as synthetic 'script' files, and hits are
|
|
11
|
+
* reported under this module's rule ids so the config file gets the finding.
|
|
12
|
+
*
|
|
13
|
+
* Malformed input never throws: a file that does not parse gives no matches.
|
|
14
|
+
*/
|
|
15
|
+
import { Buffer } from 'node:buffer';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { findSecrets, redactSecrets } from '../patterns/secrets.js';
|
|
18
|
+
import { SEVERITY_RANK } from '../types.js';
|
|
19
|
+
import { asList, parseFrontmatter } from '../util/frontmatter.js';
|
|
20
|
+
import { indexOfJsonString, isRecord, parseJsonc } from '../util/jsonc.js';
|
|
21
|
+
import { homeDir } from '../util/paths.js';
|
|
22
|
+
import { findAll, lineStartIndex, printable, truncate } from '../util/text.js';
|
|
23
|
+
import { joinKeyPath, parseToml } from '../util/toml-lite.js';
|
|
24
|
+
import { codeRules } from './code.js';
|
|
25
|
+
const SERVER_KINDS = ['mcp-config', 'plugin-manifest', 'agent-settings'];
|
|
26
|
+
const HOOK_KINDS = ['agent-settings', 'plugin-manifest', 'mcp-config'];
|
|
27
|
+
const LAUNCH_KINDS = ['mcp-config', 'plugin-manifest', 'agent-settings'];
|
|
28
|
+
const MAX_SERVERS = 500;
|
|
29
|
+
const MAX_HOOKS = 500;
|
|
30
|
+
const MAX_SCRIPT = 20_000;
|
|
31
|
+
const MAX_WORDS = 400;
|
|
32
|
+
const MAX_INNER_HITS = 10;
|
|
33
|
+
const MAX_JSON_DEPTH = 10;
|
|
34
|
+
/** One-entry memo: every rule in this module parses the same file in turn. */
|
|
35
|
+
let lastDoc;
|
|
36
|
+
function loadDoc(file) {
|
|
37
|
+
const prev = lastDoc;
|
|
38
|
+
if (prev &&
|
|
39
|
+
prev.file.path === file.path &&
|
|
40
|
+
prev.file.displayPath === file.displayPath &&
|
|
41
|
+
prev.file.kind === file.kind &&
|
|
42
|
+
prev.file.content === file.content) {
|
|
43
|
+
return prev;
|
|
44
|
+
}
|
|
45
|
+
const doc = isTomlFile(file) ? tomlDoc(file) : jsonDoc(file);
|
|
46
|
+
lastDoc = doc;
|
|
47
|
+
return doc;
|
|
48
|
+
}
|
|
49
|
+
function isTomlFile(file) {
|
|
50
|
+
return /\.toml$/i.test(file.path) || /\.toml$/i.test(file.displayPath);
|
|
51
|
+
}
|
|
52
|
+
function jsonDoc(file) {
|
|
53
|
+
const parsed = file.content.trim() ? parseJsonc(file.content) : undefined;
|
|
54
|
+
const data = isRecord(parsed) ? parsed : undefined;
|
|
55
|
+
let offsets;
|
|
56
|
+
return {
|
|
57
|
+
file,
|
|
58
|
+
format: data ? 'json' : 'text',
|
|
59
|
+
data,
|
|
60
|
+
where(segments) {
|
|
61
|
+
offsets ??= locateJson(file.content);
|
|
62
|
+
for (let n = segments.length; n > 0; n--) {
|
|
63
|
+
const hit = offsets.get(pathKey(segments.slice(0, n)));
|
|
64
|
+
if (hit !== undefined)
|
|
65
|
+
return { index: hit };
|
|
66
|
+
}
|
|
67
|
+
const last = segments[segments.length - 1];
|
|
68
|
+
const guess = typeof last === 'string' ? indexOfJsonString(file.content, last) : -1;
|
|
69
|
+
return { index: Math.max(0, guess) };
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function tomlDoc(file) {
|
|
74
|
+
const { data, lines } = parseToml(file.content);
|
|
75
|
+
return {
|
|
76
|
+
file,
|
|
77
|
+
format: 'toml',
|
|
78
|
+
data,
|
|
79
|
+
where(segments) {
|
|
80
|
+
for (let n = segments.length; n > 0; n--) {
|
|
81
|
+
const line = lines.get(joinKeyPath(segments.slice(0, n)));
|
|
82
|
+
if (line !== undefined)
|
|
83
|
+
return { line };
|
|
84
|
+
}
|
|
85
|
+
return { line: 1 };
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const PATH_SEP = '\u0000';
|
|
90
|
+
function pathKey(segments) {
|
|
91
|
+
return segments.join(PATH_SEP);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Maps the path of every object key and array element in JSONC text to its
|
|
95
|
+
* offset (the key's opening quote, or the element's first character). Single
|
|
96
|
+
* linear pass, tolerant of comments and trailing commas; stops quietly on
|
|
97
|
+
* anything malformed. Paths deeper than MAX_JSON_DEPTH are not recorded.
|
|
98
|
+
*/
|
|
99
|
+
function locateJson(text) {
|
|
100
|
+
const out = new Map();
|
|
101
|
+
const stack = [];
|
|
102
|
+
const n = text.length;
|
|
103
|
+
let i = 0;
|
|
104
|
+
const childPrefix = () => {
|
|
105
|
+
const top = stack[stack.length - 1];
|
|
106
|
+
if (!top)
|
|
107
|
+
return '';
|
|
108
|
+
if (stack.length > MAX_JSON_DEPTH)
|
|
109
|
+
return undefined;
|
|
110
|
+
if (top.array) {
|
|
111
|
+
const key = top.prefix + String(top.index);
|
|
112
|
+
if (!top.elementSeen) {
|
|
113
|
+
top.elementSeen = true;
|
|
114
|
+
out.set(key, i);
|
|
115
|
+
}
|
|
116
|
+
return key + PATH_SEP;
|
|
117
|
+
}
|
|
118
|
+
return top.prefix + top.pendingKey + PATH_SEP;
|
|
119
|
+
};
|
|
120
|
+
while (i < n) {
|
|
121
|
+
const ch = text[i];
|
|
122
|
+
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\ufeff' || ch === ':') {
|
|
123
|
+
i++;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
127
|
+
const nl = text.indexOf('\n', i);
|
|
128
|
+
i = nl === -1 ? n : nl;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
132
|
+
const end = text.indexOf('*/', i + 2);
|
|
133
|
+
i = end === -1 ? n : end + 2;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const top = stack[stack.length - 1];
|
|
137
|
+
if (ch === '"') {
|
|
138
|
+
const end = endOfJsonString(text, i);
|
|
139
|
+
if (end === -1)
|
|
140
|
+
break;
|
|
141
|
+
if (top && !top.array && top.expectKey) {
|
|
142
|
+
let key;
|
|
143
|
+
try {
|
|
144
|
+
key = JSON.parse(text.slice(i, end));
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
top.pendingKey = String(key);
|
|
150
|
+
top.expectKey = false;
|
|
151
|
+
if (stack.length <= MAX_JSON_DEPTH)
|
|
152
|
+
out.set(top.prefix + top.pendingKey, i);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
childPrefix();
|
|
156
|
+
}
|
|
157
|
+
i = end;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (ch === '{' || ch === '[') {
|
|
161
|
+
const prefix = childPrefix();
|
|
162
|
+
stack.push({ array: ch === '[', prefix: prefix ?? `${PATH_SEP}deep${PATH_SEP}`, index: 0, elementSeen: false, expectKey: ch === '{', pendingKey: '' });
|
|
163
|
+
i++;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (ch === '}' || ch === ']') {
|
|
167
|
+
stack.pop();
|
|
168
|
+
i++;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (ch === ',') {
|
|
172
|
+
if (top?.array) {
|
|
173
|
+
top.index++;
|
|
174
|
+
top.elementSeen = false;
|
|
175
|
+
}
|
|
176
|
+
else if (top) {
|
|
177
|
+
top.expectKey = true;
|
|
178
|
+
}
|
|
179
|
+
i++;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
childPrefix();
|
|
183
|
+
while (i < n && !' \t\r\n,:{}[]"/'.includes(text[i]))
|
|
184
|
+
i++;
|
|
185
|
+
if (i < n && text[i] === '/' && text[i + 1] !== '/' && text[i + 1] !== '*')
|
|
186
|
+
i++;
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
/** Index just past the closing quote of the JSON string starting at `start`, or -1. */
|
|
191
|
+
function endOfJsonString(text, start) {
|
|
192
|
+
for (let j = start + 1; j < text.length; j++) {
|
|
193
|
+
const ch = text[j];
|
|
194
|
+
if (ch === '\\')
|
|
195
|
+
j++;
|
|
196
|
+
else if (ch === '"')
|
|
197
|
+
return j + 1;
|
|
198
|
+
else if (ch === '\n')
|
|
199
|
+
return -1;
|
|
200
|
+
}
|
|
201
|
+
return -1;
|
|
202
|
+
}
|
|
203
|
+
/** Every MCP server the file declares, in any of the shapes the major clients use. */
|
|
204
|
+
function servers(doc) {
|
|
205
|
+
if (doc.servers)
|
|
206
|
+
return doc.servers;
|
|
207
|
+
const out = [];
|
|
208
|
+
const data = doc.data;
|
|
209
|
+
if (data) {
|
|
210
|
+
addServerMap(out, data.mcpServers, ['mcpServers']);
|
|
211
|
+
addServerMap(out, data.servers, ['servers']);
|
|
212
|
+
addServerMap(out, data.mcp_servers, ['mcp_servers']);
|
|
213
|
+
addServerMap(out, data.context_servers, ['context_servers']);
|
|
214
|
+
if (isRecord(data.mcp))
|
|
215
|
+
addServerMap(out, data.mcp.servers, ['mcp', 'servers']);
|
|
216
|
+
if (isRecord(data.projects)) {
|
|
217
|
+
for (const [project, settings] of Object.entries(data.projects)) {
|
|
218
|
+
if (isRecord(settings))
|
|
219
|
+
addServerMap(out, settings.mcpServers, ['projects', project, 'mcpServers']);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (Array.isArray(data.plugins)) {
|
|
223
|
+
data.plugins.forEach((plugin, i) => {
|
|
224
|
+
if (isRecord(plugin))
|
|
225
|
+
addServerMap(out, plugin.mcpServers, ['plugins', i, 'mcpServers']);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
doc.servers = out;
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
function activeServers(doc) {
|
|
233
|
+
return servers(doc).filter((s) => !s.disabled);
|
|
234
|
+
}
|
|
235
|
+
function addServerMap(out, map, base) {
|
|
236
|
+
if (!isRecord(map))
|
|
237
|
+
return;
|
|
238
|
+
for (const [name, raw] of Object.entries(map)) {
|
|
239
|
+
if (out.length >= MAX_SERVERS)
|
|
240
|
+
return;
|
|
241
|
+
if (!isRecord(raw))
|
|
242
|
+
continue;
|
|
243
|
+
const server = toServer(name, raw, [...base, name]);
|
|
244
|
+
if (server)
|
|
245
|
+
out.push(server);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function toServer(name, raw, at) {
|
|
249
|
+
let command;
|
|
250
|
+
let args = [];
|
|
251
|
+
let env;
|
|
252
|
+
if (typeof raw.command === 'string') {
|
|
253
|
+
command = { value: raw.command, path: [...at, 'command'] };
|
|
254
|
+
}
|
|
255
|
+
else if (isRecord(raw.command) && typeof raw.command.path === 'string') {
|
|
256
|
+
// Zed: "command": { "path": ..., "args": [...], "env": {...} }
|
|
257
|
+
const base = [...at, 'command'];
|
|
258
|
+
command = { value: raw.command.path, path: [...base, 'path'] };
|
|
259
|
+
args = stringArgs(raw.command.args, [...base, 'args']);
|
|
260
|
+
env = keyed(raw.command.env, [...base, 'env']);
|
|
261
|
+
}
|
|
262
|
+
if (Array.isArray(raw.args))
|
|
263
|
+
args = stringArgs(raw.args, [...at, 'args']);
|
|
264
|
+
env ??= keyed(raw.env, [...at, 'env']);
|
|
265
|
+
const headers = [keyed(raw.headers, [...at, 'headers']), keyed(raw.http_headers, [...at, 'http_headers'])].filter((h) => h !== undefined);
|
|
266
|
+
if (typeof raw.bearer_token === 'string')
|
|
267
|
+
headers.push({ path: at, values: { bearer_token: raw.bearer_token } });
|
|
268
|
+
const urls = [];
|
|
269
|
+
for (const key of ['url', 'serverUrl', 'httpUrl']) {
|
|
270
|
+
const value = raw[key];
|
|
271
|
+
if (typeof value === 'string')
|
|
272
|
+
urls.push({ value, path: [...at, key] });
|
|
273
|
+
}
|
|
274
|
+
if (!command && urls.length === 0)
|
|
275
|
+
return undefined;
|
|
276
|
+
const disabled = raw.disabled === true || raw.enabled === false;
|
|
277
|
+
return { name, path: at, raw, args, headers, urls, disabled, ...(command ? { command } : {}), ...(env ? { env } : {}) };
|
|
278
|
+
}
|
|
279
|
+
function stringArgs(value, base) {
|
|
280
|
+
if (!Array.isArray(value))
|
|
281
|
+
return [];
|
|
282
|
+
const out = [];
|
|
283
|
+
value.forEach((arg, i) => {
|
|
284
|
+
if (typeof arg === 'string')
|
|
285
|
+
out.push({ value: arg, path: [...base, i] });
|
|
286
|
+
});
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
function keyed(value, at) {
|
|
290
|
+
return isRecord(value) ? { path: at, values: value } : undefined;
|
|
291
|
+
}
|
|
292
|
+
/** Claude Code settings whose string value is a shell command the harness runs on its own, like a hook. */
|
|
293
|
+
const COMMAND_SETTINGS = ['apiKeyHelper', 'awsAuthRefresh', 'awsCredentialExport', 'otelHeadersHelper'];
|
|
294
|
+
/**
|
|
295
|
+
* Hook commands from settings.json ("hooks": {...}), plugin hooks.json (wrapped
|
|
296
|
+
* or not) and marketplace entries, plus the statusLine and helper commands of
|
|
297
|
+
* Claude Code settings, which also run automatically.
|
|
298
|
+
*/
|
|
299
|
+
function hooks(doc) {
|
|
300
|
+
if (doc.hooks)
|
|
301
|
+
return doc.hooks;
|
|
302
|
+
const out = [];
|
|
303
|
+
const data = doc.data;
|
|
304
|
+
if (data) {
|
|
305
|
+
addHookMap(out, data.hooks, ['hooks']);
|
|
306
|
+
addHookMap(out, data, []);
|
|
307
|
+
if (Array.isArray(data.plugins)) {
|
|
308
|
+
data.plugins.forEach((plugin, i) => {
|
|
309
|
+
if (isRecord(plugin))
|
|
310
|
+
addHookMap(out, plugin.hooks, ['plugins', i, 'hooks']);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
if (doc.file.kind === 'agent-settings')
|
|
314
|
+
addCommandSettings(out, data);
|
|
315
|
+
}
|
|
316
|
+
doc.hooks = out;
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
function addCommandSettings(out, data) {
|
|
320
|
+
const status = data.statusLine;
|
|
321
|
+
if (isRecord(status) && (status.type === undefined || status.type === 'command') && typeof status.command === 'string' && status.command.trim()) {
|
|
322
|
+
out.push({ event: 'statusLine', setting: true, command: status.command, path: ['statusLine', 'command'] });
|
|
323
|
+
}
|
|
324
|
+
for (const key of COMMAND_SETTINGS) {
|
|
325
|
+
const command = data[key];
|
|
326
|
+
if (typeof command === 'string' && command.trim())
|
|
327
|
+
out.push({ event: key, setting: true, command, path: [key] });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
function addHookMap(out, map, base) {
|
|
331
|
+
if (!isRecord(map))
|
|
332
|
+
return;
|
|
333
|
+
for (const [event, groups] of Object.entries(map)) {
|
|
334
|
+
if (!Array.isArray(groups))
|
|
335
|
+
continue;
|
|
336
|
+
groups.forEach((group, i) => {
|
|
337
|
+
if (!isRecord(group) || !Array.isArray(group.hooks))
|
|
338
|
+
return;
|
|
339
|
+
const matcher = typeof group.matcher === 'string' ? group.matcher : undefined;
|
|
340
|
+
group.hooks.forEach((hook, j) => {
|
|
341
|
+
if (out.length >= MAX_HOOKS || !isRecord(hook) || typeof hook.command !== 'string')
|
|
342
|
+
return;
|
|
343
|
+
if (hook.type !== undefined && hook.type !== 'command')
|
|
344
|
+
return;
|
|
345
|
+
out.push({ event, command: hook.command, path: [...base, event, i, 'hooks', j, 'command'], ...(matcher !== undefined ? { matcher } : {}) });
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function hookLabel(hook) {
|
|
351
|
+
const event = show(hook.event, 40);
|
|
352
|
+
if (hook.setting)
|
|
353
|
+
return `${event} setting`;
|
|
354
|
+
return hook.matcher ? `${event} hook (matcher '${show(hook.matcher, 60)}')` : `${event} hook`;
|
|
355
|
+
}
|
|
356
|
+
function serverLabel(server) {
|
|
357
|
+
return `Server '${show(server.name, 60)}'`;
|
|
358
|
+
}
|
|
359
|
+
const ESCAPABLE = ' \t"\'\\$`|&;()<>';
|
|
360
|
+
/**
|
|
361
|
+
* Splits a shell command into words and operators (|, &&, ;, $(, `, >, ...).
|
|
362
|
+
* Handles quotes and backslash escapes, but keeps a backslash that does not
|
|
363
|
+
* escape anything, so Windows paths survive. Bounded in input and output.
|
|
364
|
+
*/
|
|
365
|
+
function shellWords(command) {
|
|
366
|
+
const text = command.length > MAX_SCRIPT ? command.slice(0, MAX_SCRIPT) : command;
|
|
367
|
+
const words = [];
|
|
368
|
+
let word = '';
|
|
369
|
+
let inWord = false;
|
|
370
|
+
let quote = '';
|
|
371
|
+
const flush = () => {
|
|
372
|
+
if (inWord)
|
|
373
|
+
words.push(word);
|
|
374
|
+
word = '';
|
|
375
|
+
inWord = false;
|
|
376
|
+
};
|
|
377
|
+
for (let i = 0; i < text.length && words.length < MAX_WORDS; i++) {
|
|
378
|
+
const ch = text[i];
|
|
379
|
+
const next = text[i + 1] ?? '';
|
|
380
|
+
if (quote) {
|
|
381
|
+
if (ch === quote)
|
|
382
|
+
quote = '';
|
|
383
|
+
else if (ch === '\\' && quote === '"' && next !== '' && '"\\$`'.includes(next)) {
|
|
384
|
+
word += next;
|
|
385
|
+
i++;
|
|
386
|
+
}
|
|
387
|
+
else
|
|
388
|
+
word += ch;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (ch === '"' || ch === "'") {
|
|
392
|
+
quote = ch;
|
|
393
|
+
inWord = true;
|
|
394
|
+
}
|
|
395
|
+
else if (ch === '\\' && next !== '' && ESCAPABLE.includes(next)) {
|
|
396
|
+
word += next;
|
|
397
|
+
inWord = true;
|
|
398
|
+
i++;
|
|
399
|
+
}
|
|
400
|
+
else if (ch === ' ' || ch === '\t' || ch === '\r') {
|
|
401
|
+
flush();
|
|
402
|
+
}
|
|
403
|
+
else if (ch === '\n') {
|
|
404
|
+
flush();
|
|
405
|
+
words.push(';');
|
|
406
|
+
}
|
|
407
|
+
else if (ch === '$' && next === '(') {
|
|
408
|
+
flush();
|
|
409
|
+
words.push('$(');
|
|
410
|
+
i++;
|
|
411
|
+
}
|
|
412
|
+
else if ('|&;()<>`'.includes(ch)) {
|
|
413
|
+
flush();
|
|
414
|
+
const pair = ch + next;
|
|
415
|
+
if (pair === '||' || pair === '&&' || pair === '>>' || pair === '|&' || pair === '<(') {
|
|
416
|
+
words.push(pair);
|
|
417
|
+
i++;
|
|
418
|
+
}
|
|
419
|
+
else
|
|
420
|
+
words.push(ch);
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
word += ch;
|
|
424
|
+
inWord = true;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
flush();
|
|
428
|
+
return words;
|
|
429
|
+
}
|
|
430
|
+
function splitTok(tok) {
|
|
431
|
+
return shellWords(tok.text).map((text) => ({ text, origin: tok.origin }));
|
|
432
|
+
}
|
|
433
|
+
/** Lowercased executable name without directory or Windows extension: "C:\\nodejs\\npx.cmd" -> "npx". */
|
|
434
|
+
function programName(token) {
|
|
435
|
+
const slash = Math.max(token.lastIndexOf('/'), token.lastIndexOf('\\'));
|
|
436
|
+
return token
|
|
437
|
+
.slice(slash + 1)
|
|
438
|
+
.toLowerCase()
|
|
439
|
+
.replace(/\.(?:exe|cmd|bat|com|ps1)$/, '');
|
|
440
|
+
}
|
|
441
|
+
const POSIX_SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish', 'ash']);
|
|
442
|
+
const PS_VALUE_FLAGS = new Set([
|
|
443
|
+
'executionpolicy', 'ex', 'ep', 'windowstyle', 'w', 'workingdirectory', 'wd', 'configurationname', 'config',
|
|
444
|
+
'settingsfile', 'settings', 'inputformat', 'inp', 'if', 'outputformat', 'o', 'of', 'version', 'v', 'psconsolefile', 'custompipename',
|
|
445
|
+
]);
|
|
446
|
+
/** A path to a Windows executable that contains spaces, used as a whole command. */
|
|
447
|
+
const WINDOWS_EXE_PATH = /^[A-Za-z]:[\\/][^"'\n]{0,500}\.(?:exe|cmd|bat|com)$/i;
|
|
448
|
+
/** Recognises `bash -c '...'`, `cmd /c ...`, `powershell -Command ...` and `-EncodedCommand`. */
|
|
449
|
+
function inlineScript(toks) {
|
|
450
|
+
const head = toks[0];
|
|
451
|
+
if (!head)
|
|
452
|
+
return undefined;
|
|
453
|
+
const shell = programName(head.text);
|
|
454
|
+
if (POSIX_SHELLS.has(shell))
|
|
455
|
+
return posixInline(shell, toks);
|
|
456
|
+
if (shell === 'cmd')
|
|
457
|
+
return cmdInline(toks);
|
|
458
|
+
if (shell === 'powershell' || shell === 'pwsh')
|
|
459
|
+
return powershellInline(shell, toks);
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
function posixInline(shell, toks) {
|
|
463
|
+
for (let i = 1; i < toks.length; i++) {
|
|
464
|
+
const t = toks[i].text;
|
|
465
|
+
if (t.startsWith('--'))
|
|
466
|
+
continue;
|
|
467
|
+
if (!t.startsWith('-') && !t.startsWith('+'))
|
|
468
|
+
return undefined;
|
|
469
|
+
if (/^-[A-Za-z]{0,10}c[A-Za-z]{0,10}$/.test(t)) {
|
|
470
|
+
const body = toks[i + 1];
|
|
471
|
+
return body ? { shell, script: body.text, origin: body.origin, encoded: false, words: splitTok(body) } : undefined;
|
|
472
|
+
}
|
|
473
|
+
if (t === '-o' || t === '+o')
|
|
474
|
+
i++;
|
|
475
|
+
}
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
function cmdInline(toks) {
|
|
479
|
+
for (let i = 1; i < toks.length; i++) {
|
|
480
|
+
const t = toks[i].text;
|
|
481
|
+
if (/^\/[ck]$/i.test(t)) {
|
|
482
|
+
const rest = toks.slice(i + 1);
|
|
483
|
+
const first = rest[0];
|
|
484
|
+
if (!first)
|
|
485
|
+
return undefined;
|
|
486
|
+
return { shell: 'cmd', script: rest.map((r) => r.text).join(' '), origin: first.origin, encoded: false, words: rest.flatMap(splitTok) };
|
|
487
|
+
}
|
|
488
|
+
if (!t.startsWith('/'))
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
return undefined;
|
|
492
|
+
}
|
|
493
|
+
function powershellInline(shell, toks) {
|
|
494
|
+
for (let i = 1; i < toks.length; i++) {
|
|
495
|
+
const t = toks[i].text;
|
|
496
|
+
if (!/^[-/]/.test(t))
|
|
497
|
+
return undefined;
|
|
498
|
+
const name = t.slice(1).toLowerCase();
|
|
499
|
+
if (name === 'e' || name === 'ec' || (name.length >= 3 && 'encodedcommand'.startsWith(name))) {
|
|
500
|
+
const body = toks[i + 1];
|
|
501
|
+
if (!body)
|
|
502
|
+
return undefined;
|
|
503
|
+
const decoded = decodePowerShell(body.text);
|
|
504
|
+
return {
|
|
505
|
+
shell,
|
|
506
|
+
script: decoded ?? body.text,
|
|
507
|
+
origin: body.origin,
|
|
508
|
+
encoded: true,
|
|
509
|
+
words: decoded ? shellWords(decoded).map((text) => ({ text, origin: body.origin })) : [],
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
if (name === 'c' || (name.length >= 3 && 'command'.startsWith(name))) {
|
|
513
|
+
const rest = toks.slice(i + 1);
|
|
514
|
+
const first = rest[0];
|
|
515
|
+
if (!first)
|
|
516
|
+
return undefined;
|
|
517
|
+
return { shell, script: rest.map((r) => r.text).join(' '), origin: first.origin, encoded: false, words: rest.flatMap(splitTok) };
|
|
518
|
+
}
|
|
519
|
+
if (name === 'f' || (name.length >= 2 && 'file'.startsWith(name)))
|
|
520
|
+
return undefined;
|
|
521
|
+
if (PS_VALUE_FLAGS.has(name))
|
|
522
|
+
i++;
|
|
523
|
+
}
|
|
524
|
+
return undefined;
|
|
525
|
+
}
|
|
526
|
+
/** PowerShell -EncodedCommand is base64 of UTF-16LE text. */
|
|
527
|
+
function decodePowerShell(b64) {
|
|
528
|
+
if (b64.length > MAX_SCRIPT * 3 || !/^[A-Za-z0-9+/]{8,}={0,2}$/.test(b64))
|
|
529
|
+
return undefined;
|
|
530
|
+
const decoded = Buffer.from(b64, 'base64').toString('utf16le');
|
|
531
|
+
return decoded.includes('\u0000') || !decoded.trim() ? undefined : decoded;
|
|
532
|
+
}
|
|
533
|
+
/** True when an inline script is a single plain command with no pipes, chaining, redirection or substitution. */
|
|
534
|
+
function isSimpleScript(script) {
|
|
535
|
+
return !/[|&;<>`\n\r]|\$\(/.test(script);
|
|
536
|
+
}
|
|
537
|
+
/** The server's command line as words. Splits "command": "npx -y pkg" when there are no args. */
|
|
538
|
+
function baseTokens(server) {
|
|
539
|
+
if (!server.command)
|
|
540
|
+
return [];
|
|
541
|
+
const command = server.command.value;
|
|
542
|
+
if (server.args.length === 0 && /\s/.test(command.trim()) && !WINDOWS_EXE_PATH.test(command.trim())) {
|
|
543
|
+
return shellWords(command).map((text) => ({ text, origin: -1 }));
|
|
544
|
+
}
|
|
545
|
+
return [{ text: command, origin: -1 }, ...server.args.map((a, i) => ({ text: a.value, origin: i }))];
|
|
546
|
+
}
|
|
547
|
+
const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]{0,100}=/;
|
|
548
|
+
/** Drops a leading `env [-i] [VAR=value ...]` so the real program comes first. */
|
|
549
|
+
function stripEnv(toks) {
|
|
550
|
+
if (programName(toks[0]?.text ?? '') !== 'env')
|
|
551
|
+
return toks;
|
|
552
|
+
let i = 1;
|
|
553
|
+
while (i < toks.length && (toks[i].text.startsWith('-') || ENV_ASSIGN.test(toks[i].text)))
|
|
554
|
+
i++;
|
|
555
|
+
return toks.slice(i);
|
|
556
|
+
}
|
|
557
|
+
/** Strips wrappers that just launch another program: env VAR=x, cmd /c, and simple `sh -c` one-liners. */
|
|
558
|
+
function unwrap(toks, depth = 0) {
|
|
559
|
+
if (toks.length === 0 || depth > 3)
|
|
560
|
+
return toks;
|
|
561
|
+
const stripped = stripEnv(toks);
|
|
562
|
+
if (stripped !== toks)
|
|
563
|
+
return unwrap(stripped, depth + 1);
|
|
564
|
+
const inline = inlineScript(toks);
|
|
565
|
+
if (inline && !inline.encoded && isSimpleScript(inline.script) && inline.words.length > 0)
|
|
566
|
+
return unwrap(inline.words, depth + 1);
|
|
567
|
+
return toks;
|
|
568
|
+
}
|
|
569
|
+
/** The effective launch command, plus the words of any non-trivial inline script it runs. */
|
|
570
|
+
function launchLines(server) {
|
|
571
|
+
const base = unwrap(baseTokens(server));
|
|
572
|
+
const lines = [base];
|
|
573
|
+
const inline = inlineScript(base);
|
|
574
|
+
if (inline && inline.words.length > 0)
|
|
575
|
+
lines.push(inline.words);
|
|
576
|
+
return lines;
|
|
577
|
+
}
|
|
578
|
+
function tokPath(server, tok) {
|
|
579
|
+
if (tok.origin < 0)
|
|
580
|
+
return server.command?.path ?? server.path;
|
|
581
|
+
return server.args[tok.origin]?.path ?? server.path;
|
|
582
|
+
}
|
|
583
|
+
const SEPARATORS = new Set(['|', '||', '&', '&&', ';', '(', ')', '$(', '`', '<(', '|&']);
|
|
584
|
+
const REDIRECTS = new Set(['>', '>>', '<']);
|
|
585
|
+
const PREFIX_COMMANDS = new Set(['sudo', 'exec', 'nohup', 'time', 'env', 'command', 'xargs', 'nice', 'doas', 'start']);
|
|
586
|
+
const INTERPRETERS = new Set([
|
|
587
|
+
'bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'ash', 'python', 'python3', 'python2', 'py', 'pythonw', 'node', 'nodejs', 'deno', 'bun',
|
|
588
|
+
'tsx', 'ts-node', 'ruby', 'perl', 'php', 'pwsh', 'powershell', 'source', '.', 'osascript', 'cscript', 'wscript', 'java',
|
|
589
|
+
]);
|
|
590
|
+
const INLINE_CODE_FLAGS = new Set(['-c', '-e', '-m', '--eval', '-p', '--print', '-r', '--require']);
|
|
591
|
+
/** Words that name a program or script being executed: the head of each command, and the script handed to an interpreter. */
|
|
592
|
+
function executedWords(words) {
|
|
593
|
+
const out = [];
|
|
594
|
+
let expectCommand = true;
|
|
595
|
+
let interpreter = '';
|
|
596
|
+
for (let i = 0; i < words.length; i++) {
|
|
597
|
+
const w = words[i];
|
|
598
|
+
const t = w.text;
|
|
599
|
+
if (SEPARATORS.has(t)) {
|
|
600
|
+
expectCommand = true;
|
|
601
|
+
interpreter = '';
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
if (REDIRECTS.has(t)) {
|
|
605
|
+
i++;
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
if (expectCommand) {
|
|
609
|
+
if (PREFIX_COMMANDS.has(t) || ENV_ASSIGN.test(t))
|
|
610
|
+
continue;
|
|
611
|
+
out.push(w);
|
|
612
|
+
expectCommand = false;
|
|
613
|
+
const prog = programName(t);
|
|
614
|
+
interpreter = INTERPRETERS.has(prog) ? prog : '';
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
if (!interpreter)
|
|
618
|
+
continue;
|
|
619
|
+
if (interpreter === 'pwsh' || interpreter === 'powershell') {
|
|
620
|
+
if (/^[-/]f(?:ile)?$/i.test(t)) {
|
|
621
|
+
const next = words[i + 1];
|
|
622
|
+
if (next)
|
|
623
|
+
out.push(next);
|
|
624
|
+
interpreter = '';
|
|
625
|
+
i++;
|
|
626
|
+
}
|
|
627
|
+
else if (/^[-/](?:c|com|comm|comma|comman|command|e|ec|enc[a-z]{0,11})$/i.test(t)) {
|
|
628
|
+
interpreter = '';
|
|
629
|
+
}
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
if (t === 'run' && (interpreter === 'deno' || interpreter === 'bun'))
|
|
633
|
+
continue;
|
|
634
|
+
if (INLINE_CODE_FLAGS.has(t)) {
|
|
635
|
+
interpreter = '';
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
if (t.startsWith('-'))
|
|
639
|
+
continue;
|
|
640
|
+
out.push(w);
|
|
641
|
+
interpreter = '';
|
|
642
|
+
}
|
|
643
|
+
return out;
|
|
644
|
+
}
|
|
645
|
+
/** Finds packages fetched by npx, pnpm/yarn dlx, bunx, npm exec, uvx, pipx run and docker run. */
|
|
646
|
+
function packageRuns(toks) {
|
|
647
|
+
const head = toks[0];
|
|
648
|
+
if (!head)
|
|
649
|
+
return [];
|
|
650
|
+
const prog = programName(head.text);
|
|
651
|
+
const rest = toks.slice(1);
|
|
652
|
+
const sub = rest[0]?.text;
|
|
653
|
+
switch (prog) {
|
|
654
|
+
case 'npx':
|
|
655
|
+
return npmLike('npx', rest);
|
|
656
|
+
case 'pnpx':
|
|
657
|
+
return npmLike('pnpm dlx', rest);
|
|
658
|
+
case 'bunx':
|
|
659
|
+
return npmLike('bunx', rest);
|
|
660
|
+
case 'pnpm':
|
|
661
|
+
return sub === 'dlx' ? npmLike('pnpm dlx', rest.slice(1)) : [];
|
|
662
|
+
case 'yarn':
|
|
663
|
+
return sub === 'dlx' ? npmLike('yarn dlx', rest.slice(1)) : [];
|
|
664
|
+
case 'bun':
|
|
665
|
+
return sub === 'x' ? npmLike('bunx', rest.slice(1)) : [];
|
|
666
|
+
case 'npm':
|
|
667
|
+
return sub === 'exec' || sub === 'x' ? npmLike('npm exec', rest.slice(1)) : [];
|
|
668
|
+
case 'uvx':
|
|
669
|
+
return pyLike('uvx', rest);
|
|
670
|
+
case 'uv':
|
|
671
|
+
return sub === 'tool' && rest[1]?.text === 'run' ? pyLike('uvx', rest.slice(2)) : [];
|
|
672
|
+
case 'pipx':
|
|
673
|
+
return sub === 'run' ? pyLike('pipx run', rest.slice(1)) : [];
|
|
674
|
+
case 'docker':
|
|
675
|
+
case 'podman':
|
|
676
|
+
case 'nerdctl': {
|
|
677
|
+
if (sub === 'run')
|
|
678
|
+
return dockerRun(prog, rest.slice(1));
|
|
679
|
+
if (sub === 'container' && rest[1]?.text === 'run')
|
|
680
|
+
return dockerRun(prog, rest.slice(2));
|
|
681
|
+
return [];
|
|
682
|
+
}
|
|
683
|
+
default:
|
|
684
|
+
return [];
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
const NPM_VALUE_FLAGS = new Set(['--registry', '--cache', '--prefix', '--userconfig', '--node-options', '-w', '--workspace', '--node-arg', '-n']);
|
|
688
|
+
function npmLike(runner, toks) {
|
|
689
|
+
const packages = [];
|
|
690
|
+
for (let i = 0; i < toks.length; i++) {
|
|
691
|
+
const tok = toks[i];
|
|
692
|
+
const t = tok.text;
|
|
693
|
+
if (t === '--') {
|
|
694
|
+
const next = toks[i + 1];
|
|
695
|
+
if (next && packages.length === 0)
|
|
696
|
+
packages.push(next);
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
if (t.startsWith('--package=')) {
|
|
700
|
+
packages.push({ text: t.slice('--package='.length), origin: tok.origin });
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
if (t === '-p' || t === '--package') {
|
|
704
|
+
const next = toks[++i];
|
|
705
|
+
if (next)
|
|
706
|
+
packages.push(next);
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
if (t === '-c' || t === '--call')
|
|
710
|
+
return packages.map((p) => ({ runner, ecosystem: 'npm', tok: p, spec: p.text }));
|
|
711
|
+
if (t.startsWith('-')) {
|
|
712
|
+
if (NPM_VALUE_FLAGS.has(t))
|
|
713
|
+
i++;
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
if (packages.length === 0)
|
|
717
|
+
packages.push(tok);
|
|
718
|
+
break;
|
|
719
|
+
}
|
|
720
|
+
return packages.map((p) => ({ runner, ecosystem: 'npm', tok: p, spec: p.text }));
|
|
721
|
+
}
|
|
722
|
+
const PY_VALUE_FLAGS = new Set([
|
|
723
|
+
'--with', '--with-editable', '--with-requirements', '--python', '-p', '--index-url', '--extra-index-url', '--index', '--default-index',
|
|
724
|
+
'--directory', '--env-file', '--constraints', '--overrides', '--pip-args', '--cache-dir', '--find-links', '-f', '-i',
|
|
725
|
+
]);
|
|
726
|
+
function pyLike(runner, toks) {
|
|
727
|
+
let from;
|
|
728
|
+
for (let i = 0; i < toks.length; i++) {
|
|
729
|
+
const tok = toks[i];
|
|
730
|
+
const t = tok.text;
|
|
731
|
+
if (t === '--from' || t === '--spec') {
|
|
732
|
+
from = toks[++i];
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
const eq = /^--(?:from|spec)=(.{1,2000})$/.exec(t);
|
|
736
|
+
if (eq) {
|
|
737
|
+
from = { text: eq[1], origin: tok.origin };
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
if (t === '--')
|
|
741
|
+
continue;
|
|
742
|
+
if (t.startsWith('-')) {
|
|
743
|
+
if (PY_VALUE_FLAGS.has(t))
|
|
744
|
+
i++;
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
const spec = from ?? tok;
|
|
748
|
+
return [{ runner, ecosystem: 'pypi', tok: spec, spec: spec.text }];
|
|
749
|
+
}
|
|
750
|
+
return from ? [{ runner, ecosystem: 'pypi', tok: from, spec: from.text }] : [];
|
|
751
|
+
}
|
|
752
|
+
const DOCKER_VALUE_LONG = new Set([
|
|
753
|
+
'--env', '--env-file', '--volume', '--mount', '--name', '--network', '--net', '--publish', '--workdir', '--user', '--entrypoint',
|
|
754
|
+
'--platform', '--label', '--label-file', '--memory', '--memory-swap', '--memory-reservation', '--cpus', '--cpu-shares', '--cpuset-cpus',
|
|
755
|
+
'--add-host', '--cap-add', '--cap-drop', '--device', '--dns', '--dns-search', '--dns-option', '--hostname', '--ipc', '--pid',
|
|
756
|
+
'--restart', '--runtime', '--security-opt', '--shm-size', '--tmpfs', '--ulimit', '--log-driver', '--log-opt', '--pull', '--gpus',
|
|
757
|
+
'--expose', '--stop-signal', '--stop-timeout', '--health-cmd', '--health-interval', '--health-retries', '--health-timeout',
|
|
758
|
+
'--cidfile', '--volumes-from', '--link', '--group-add', '--attach', '--cgroupns', '--userns', '--uts', '--isolation',
|
|
759
|
+
'--network-alias', '--ip', '--ip6', '--mac-address', '--annotation', '--detach-keys', '--domainname', '--storage-opt', '--sysctl',
|
|
760
|
+
'--device-cgroup-rule', '--blkio-weight', '--oom-score-adj', '--pids-limit', '--cgroup-parent',
|
|
761
|
+
]);
|
|
762
|
+
const DOCKER_VALUE_SHORT = new Set(['e', 'v', 'p', 'w', 'u', 'l', 'm', 'h', 'a', 'c']);
|
|
763
|
+
function dockerRun(prog, toks) {
|
|
764
|
+
for (let i = 0; i < toks.length; i++) {
|
|
765
|
+
const tok = toks[i];
|
|
766
|
+
const t = tok.text;
|
|
767
|
+
if (t.startsWith('--')) {
|
|
768
|
+
if (!t.includes('=') && DOCKER_VALUE_LONG.has(t))
|
|
769
|
+
i++;
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
if (t.startsWith('-') && t.length > 1) {
|
|
773
|
+
if (!t.includes('=') && DOCKER_VALUE_SHORT.has(t[t.length - 1]))
|
|
774
|
+
i++;
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
return [{ runner: `${prog} run`, ecosystem: 'docker', tok, spec: t }];
|
|
778
|
+
}
|
|
779
|
+
return [];
|
|
780
|
+
}
|
|
781
|
+
const EXACT_SEMVER = /^[=v]?\d{1,9}\.\d{1,9}\.\d{1,9}(?:-[0-9A-Za-z.-]{1,64})?(?:\+[0-9A-Za-z.-]{1,64})?$/;
|
|
782
|
+
const LOCAL_SPEC = /^(?:\.{1,2}(?:[\\/]|$)|[\\/]|~|[A-Za-z]:[\\/]|file:|link:|workspace:)/i;
|
|
783
|
+
const COMMIT_REF = /^[0-9a-f]{7,40}$/i;
|
|
784
|
+
function pinProblem(run) {
|
|
785
|
+
const spec = run.spec.trim();
|
|
786
|
+
if (!spec || isPlaceholder(spec) || LOCAL_SPEC.test(spec))
|
|
787
|
+
return undefined;
|
|
788
|
+
if (run.ecosystem === 'npm')
|
|
789
|
+
return npmPinProblem(spec);
|
|
790
|
+
if (run.ecosystem === 'pypi')
|
|
791
|
+
return pyPinProblem(spec);
|
|
792
|
+
return dockerPinProblem(spec);
|
|
793
|
+
}
|
|
794
|
+
function npmPinProblem(spec) {
|
|
795
|
+
if (/^(?:git\+|git:|github:|gitlab:|bitbucket:|gist:)/i.test(spec) || /^[A-Za-z0-9][\w.-]{0,100}\/[\w.-]{1,100}(?:#\S{0,200})?$/.test(spec)) {
|
|
796
|
+
const hash = spec.lastIndexOf('#');
|
|
797
|
+
return hash !== -1 && COMMIT_REF.test(spec.slice(hash + 1)) ? undefined : { kind: 'git' };
|
|
798
|
+
}
|
|
799
|
+
if (/^https?:\/\//i.test(spec))
|
|
800
|
+
return { kind: 'url' };
|
|
801
|
+
if (/^npm:/i.test(spec))
|
|
802
|
+
return npmPinProblem(spec.slice(4));
|
|
803
|
+
const at = spec.indexOf('@', spec.startsWith('@') ? 1 : 0);
|
|
804
|
+
if (at <= 0)
|
|
805
|
+
return { kind: 'missing', name: spec };
|
|
806
|
+
const name = spec.slice(0, at);
|
|
807
|
+
const version = spec.slice(at + 1);
|
|
808
|
+
if (EXACT_SEMVER.test(version))
|
|
809
|
+
return undefined;
|
|
810
|
+
return version ? { kind: 'loose', name, version } : { kind: 'missing', name };
|
|
811
|
+
}
|
|
812
|
+
function pyPinProblem(spec) {
|
|
813
|
+
if (/^git\+/i.test(spec)) {
|
|
814
|
+
const ref = /@([0-9A-Za-z._-]{1,100})(?:#\S{0,200})?$/.exec(spec.replace(/^git\+[a-z]{1,10}:\/\/[^@/]{0,200}@/i, ''));
|
|
815
|
+
return ref && COMMIT_REF.test(ref[1]) ? undefined : { kind: 'git' };
|
|
816
|
+
}
|
|
817
|
+
if (/^https?:\/\//i.test(spec))
|
|
818
|
+
return { kind: 'url' };
|
|
819
|
+
const name = /^[A-Za-z0-9][A-Za-z0-9._-]{0,200}(?:\[[^\]]{0,200}\])?/.exec(spec)?.[0];
|
|
820
|
+
if (!name)
|
|
821
|
+
return undefined;
|
|
822
|
+
const rest = spec.slice(name.length).trim();
|
|
823
|
+
if (!rest)
|
|
824
|
+
return { kind: 'missing', name };
|
|
825
|
+
if (/^(?:===?|@)\s{0,3}v?\d[0-9A-Za-z.+!-]{0,64}$/.test(rest) && !rest.includes('*'))
|
|
826
|
+
return undefined;
|
|
827
|
+
return { kind: 'loose', name, version: rest.replace(/^@/, '') };
|
|
828
|
+
}
|
|
829
|
+
function dockerPinProblem(image) {
|
|
830
|
+
if (/@sha256:[0-9a-f]{64}$/i.test(image))
|
|
831
|
+
return undefined;
|
|
832
|
+
const lastSlash = image.lastIndexOf('/');
|
|
833
|
+
const colon = image.indexOf(':', lastSlash + 1);
|
|
834
|
+
if (colon === -1)
|
|
835
|
+
return { kind: 'missing', name: image };
|
|
836
|
+
if (image.slice(colon + 1) === 'latest')
|
|
837
|
+
return { kind: 'latest', name: image };
|
|
838
|
+
return undefined;
|
|
839
|
+
}
|
|
840
|
+
function pinMessage(server, run, problem) {
|
|
841
|
+
const who = serverLabel(server);
|
|
842
|
+
const tail = 'a malicious update would run on next start.';
|
|
843
|
+
const what = run.ecosystem === 'docker' ? `${run.runner.split(' ')[0]} image` : `${run.runner} package`;
|
|
844
|
+
switch (problem.kind) {
|
|
845
|
+
case 'missing':
|
|
846
|
+
return run.ecosystem === 'docker'
|
|
847
|
+
? `${who} runs ${what} '${show(problem.name)}' without a tag or digest; ${tail}`
|
|
848
|
+
: `${who} runs ${what} '${show(problem.name)}' without a pinned version; ${tail}`;
|
|
849
|
+
case 'loose':
|
|
850
|
+
return `${who} runs ${what} '${show(problem.name)}' at '${show(problem.version, 40)}', which is not an exact version; ${tail}`;
|
|
851
|
+
case 'latest':
|
|
852
|
+
return `${who} runs ${what} '${show(problem.name)}' with the moving ':latest' tag and no digest; ${tail}`;
|
|
853
|
+
case 'git':
|
|
854
|
+
return `${who} runs ${what} from git '${show(run.spec)}' without a pinned commit; ${tail}`;
|
|
855
|
+
case 'url':
|
|
856
|
+
return `${who} runs ${what} from the URL '${show(maskUrlString(run.spec))}', whose content can change at any time; ${tail}`;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
// ===========================================================================
|
|
860
|
+
// Paths, URLs, secrets
|
|
861
|
+
// ===========================================================================
|
|
862
|
+
/** Forward slashes, no trailing slash, lowercased for Windows drive paths. */
|
|
863
|
+
function normPath(p) {
|
|
864
|
+
let out = p.replace(/\\/g, '/').replace(/\/{2,}/g, '/');
|
|
865
|
+
if (out.length > 1 && out.endsWith('/'))
|
|
866
|
+
out = out.slice(0, -1);
|
|
867
|
+
if (/^[A-Za-z]:/.test(out))
|
|
868
|
+
out = out.toLowerCase();
|
|
869
|
+
return out;
|
|
870
|
+
}
|
|
871
|
+
function samePath(a, b) {
|
|
872
|
+
return isUnder(a, b) && isUnder(b, a);
|
|
873
|
+
}
|
|
874
|
+
function isUnder(p, root) {
|
|
875
|
+
const a = /^[a-z]:/i.test(p) ? p.toLowerCase() : p;
|
|
876
|
+
const b = /^[a-z]:/i.test(root) ? root.toLowerCase() : root;
|
|
877
|
+
return a === b || a.startsWith(b.endsWith('/') ? b : `${b}/`);
|
|
878
|
+
}
|
|
879
|
+
function home() {
|
|
880
|
+
return normPath(homeDir());
|
|
881
|
+
}
|
|
882
|
+
/** Expands ~, $HOME, ${HOME}, %USERPROFILE% and $env:USERPROFILE at the start of a path. */
|
|
883
|
+
function expandHomeVars(p) {
|
|
884
|
+
const m = /^(?:~|\$HOME|\$\{HOME\}|%USERPROFILE%|\$env:USERPROFILE)(?=$|[\\/])/i.exec(p);
|
|
885
|
+
return m ? home() + p.slice(m[0].length) : p;
|
|
886
|
+
}
|
|
887
|
+
const TEMP_PATTERNS = [
|
|
888
|
+
/^(?:\/private)?(?:\/var)?\/tmp\//i,
|
|
889
|
+
/^\/dev\/shm\//,
|
|
890
|
+
/^(?:\/private)?\/var\/folders\/[^/]{1,10}\/[^/]{1,64}\/T\//,
|
|
891
|
+
/^(?:\$\{?TMPDIR\}?|\$\{?TMP\}?|\$\{?TEMP\}?|%TEMP%|%TMP%|\$env:TEMP|\$env:TMP)\//i,
|
|
892
|
+
/\/appdata\/local\/temp\//i,
|
|
893
|
+
/^[a-z]:\/windows\/temp\//i,
|
|
894
|
+
];
|
|
895
|
+
const DOWNLOAD_PATTERNS = [
|
|
896
|
+
/^(?:~|\$\{?HOME\}?|%USERPROFILE%|\$env:USERPROFILE)\/downloads\//i,
|
|
897
|
+
/^\/(?:users|home)\/[^/]{1,64}\/downloads\//i,
|
|
898
|
+
/^[a-z]:\/users\/[^/]{1,64}\/downloads\//i,
|
|
899
|
+
];
|
|
900
|
+
/** 'temp' or 'downloads' when an executable path lives in a scratch folder. */
|
|
901
|
+
function scratchFolder(token) {
|
|
902
|
+
const p = token.replace(/\\/g, '/');
|
|
903
|
+
if (TEMP_PATTERNS.some((re) => re.test(p)))
|
|
904
|
+
return 'temp';
|
|
905
|
+
if (DOWNLOAD_PATTERNS.some((re) => re.test(p)))
|
|
906
|
+
return 'downloads';
|
|
907
|
+
const h = home();
|
|
908
|
+
if (h.length > 1 && isUnder(normPath(p), `${h}/Downloads`))
|
|
909
|
+
return 'downloads';
|
|
910
|
+
return undefined;
|
|
911
|
+
}
|
|
912
|
+
function isPlaceholder(s) {
|
|
913
|
+
return (/\$\{[^}\n]{1,200}\}/.test(s) ||
|
|
914
|
+
/^\$[A-Za-z_][A-Za-z0-9_]{0,100}$/.test(s) ||
|
|
915
|
+
/%[A-Za-z_][A-Za-z0-9_]{0,100}%/.test(s) ||
|
|
916
|
+
/\{\{[^}\n]{1,200}\}\}/.test(s) ||
|
|
917
|
+
/^\$env:[A-Za-z_]/i.test(s) ||
|
|
918
|
+
/^<[^>\n]{1,200}>$/.test(s));
|
|
919
|
+
}
|
|
920
|
+
function ipv4(host) {
|
|
921
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
922
|
+
if (!m)
|
|
923
|
+
return undefined;
|
|
924
|
+
const parts = m.slice(1).map(Number);
|
|
925
|
+
return parts.every((p) => p <= 255) ? parts : undefined;
|
|
926
|
+
}
|
|
927
|
+
function hostClass(hostname) {
|
|
928
|
+
const h = hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
|
|
929
|
+
if (h === 'localhost' || h.endsWith('.localhost') || h === '::1' || h === '0.0.0.0' || h === 'host.docker.internal')
|
|
930
|
+
return 'loopback';
|
|
931
|
+
const v4 = ipv4(h);
|
|
932
|
+
if (v4) {
|
|
933
|
+
const [a = 0, b = 0] = v4;
|
|
934
|
+
if (a === 127)
|
|
935
|
+
return 'loopback';
|
|
936
|
+
if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127)) {
|
|
937
|
+
return 'private';
|
|
938
|
+
}
|
|
939
|
+
return 'public';
|
|
940
|
+
}
|
|
941
|
+
if (h.includes(':'))
|
|
942
|
+
return /^(?:f[cd]|fe80)/.test(h) ? 'private' : 'public';
|
|
943
|
+
if (!h.includes('.') || /\.(?:local|lan|internal|intranet|home\.arpa|corp|home)$/.test(h))
|
|
944
|
+
return 'private';
|
|
945
|
+
return 'public';
|
|
946
|
+
}
|
|
947
|
+
const TUNNEL_HOSTS = [
|
|
948
|
+
['ngrok.io', 'ngrok'], ['ngrok-free.app', 'ngrok'], ['ngrok.app', 'ngrok'], ['ngrok-free.dev', 'ngrok'], ['ngrok.dev', 'ngrok'],
|
|
949
|
+
['trycloudflare.com', 'Cloudflare quick tunnel'], ['loca.lt', 'localtunnel'], ['localtunnel.me', 'localtunnel'],
|
|
950
|
+
['serveo.net', 'Serveo'], ['serveousercontent.com', 'Serveo'], ['localhost.run', 'localhost.run'], ['lhr.life', 'localhost.run'],
|
|
951
|
+
['lhr.rocks', 'localhost.run'], ['pinggy.io', 'Pinggy'], ['pinggy.link', 'Pinggy'], ['pinggy.online', 'Pinggy'], ['bore.pub', 'bore'],
|
|
952
|
+
['devtunnels.ms', 'Dev Tunnels'], ['tunnelto.dev', 'tunnelto'], ['localxpose.io', 'LocalXpose'], ['loclx.io', 'LocalXpose'],
|
|
953
|
+
['telebit.io', 'Telebit'], ['telebit.cloud', 'Telebit'],
|
|
954
|
+
];
|
|
955
|
+
function tunnelProvider(hostname) {
|
|
956
|
+
const h = hostname.toLowerCase();
|
|
957
|
+
return TUNNEL_HOSTS.find(([suffix]) => h === suffix || h.endsWith(`.${suffix}`))?.[1];
|
|
958
|
+
}
|
|
959
|
+
function parseUrl(raw) {
|
|
960
|
+
try {
|
|
961
|
+
const url = new URL(raw.trim());
|
|
962
|
+
return /[${}%]/.test(url.hostname) ? undefined : url;
|
|
963
|
+
}
|
|
964
|
+
catch {
|
|
965
|
+
return undefined;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
/** Scheme, host and path only: never userinfo or query, which may carry credentials. */
|
|
969
|
+
function shownUrl(url) {
|
|
970
|
+
return truncate(`${url.protocol}//${url.host}${url.pathname === '/' ? '' : url.pathname}`, 100);
|
|
971
|
+
}
|
|
972
|
+
/** A URL or URL-like spec safe to show: userinfo and query masked. Shorthands such as github:user/repo stay as written. */
|
|
973
|
+
function maskUrlString(raw) {
|
|
974
|
+
const url = /^[a-z][a-z0-9+.-]{0,20}:\/\//i.test(raw) ? parseUrl(raw) : undefined;
|
|
975
|
+
return url ? shownUrl(url) : raw.replace(/\/\/[^/@\s]{1,200}@/, '//****@').replace(/\?[^\n]{0,2000}$/, '?…');
|
|
976
|
+
}
|
|
977
|
+
const CREDENTIAL_PARAMS = new Set([
|
|
978
|
+
'token', 'accesstoken', 'authtoken', 'auth', 'key', 'apikey', 'xapikey', 'secret', 'clientsecret', 'password', 'passwd', 'pwd',
|
|
979
|
+
'pass', 'sig', 'signature', 'sessiontoken', 'bearertoken', 'privatekey',
|
|
980
|
+
]);
|
|
981
|
+
function isCredentialParam(name) {
|
|
982
|
+
const n = name.toLowerCase().replace(/[-_.]/g, '');
|
|
983
|
+
return CREDENTIAL_PARAMS.has(n) || n.endsWith('token') || n.endsWith('apikey') || n.endsWith('secret') || n.endsWith('password');
|
|
984
|
+
}
|
|
985
|
+
/** Remote endpoints a server talks to: its url fields, plus URL arguments such as those given to mcp-remote. */
|
|
986
|
+
function endpoints(server) {
|
|
987
|
+
const out = [...server.urls];
|
|
988
|
+
for (const arg of server.args) {
|
|
989
|
+
if (/^(?:https?|wss?):\/\//i.test(arg.value))
|
|
990
|
+
out.push(arg);
|
|
991
|
+
else {
|
|
992
|
+
const m = /^--?[A-Za-z][\w-]{0,40}=((?:https?|wss?):\/\/\S{1,2000})$/i.exec(arg.value);
|
|
993
|
+
if (m)
|
|
994
|
+
out.push({ value: m[1], path: arg.path });
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
return out;
|
|
998
|
+
}
|
|
999
|
+
const SECRET_WORDS = new Set([
|
|
1000
|
+
'KEY', 'APIKEY', 'TOKEN', 'SECRET', 'PASSWORD', 'PASSWD', 'PASSPHRASE', 'PWD', 'PAT', 'AUTH', 'AUTHORIZATION', 'CREDENTIAL',
|
|
1001
|
+
'CREDENTIALS', 'BEARER', 'COOKIE', 'PASS',
|
|
1002
|
+
]);
|
|
1003
|
+
const NOT_SECRET_WORDS = new Set([
|
|
1004
|
+
'FILE', 'PATH', 'DIR', 'URL', 'URI', 'ENDPOINT', 'HOST', 'NAME', 'ID', 'TYPE', 'MODE', 'LIMIT', 'COUNT', 'TIMEOUT', 'TTL', 'EXPIRY',
|
|
1005
|
+
'EXPIRES', 'VAR', 'ENV', 'PREFIX', 'FORMAT', 'REGION', 'LENGTH', 'SIZE', 'MAX', 'MIN', 'PORT', 'USER', 'USERNAME', 'EMAIL', 'ACCOUNT',
|
|
1006
|
+
'PROJECT', 'ORG', 'DOMAIN', 'SCOPE', 'SCOPES', 'METHOD', 'PROVIDER', 'STRATEGY', 'ENABLED', 'DISABLED', 'REQUIRED', 'LOCATION',
|
|
1007
|
+
'ALGORITHM', 'ALG', 'PUBLIC', 'PUBLISHABLE', 'ISSUER', 'AUDIENCE', 'HEADER', 'FIELD', 'ROTATION', 'THROUGH',
|
|
1008
|
+
]);
|
|
1009
|
+
function keyWords(key) {
|
|
1010
|
+
return key
|
|
1011
|
+
.slice(0, 200)
|
|
1012
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
1013
|
+
.toUpperCase()
|
|
1014
|
+
.split(/[^A-Z0-9]+/)
|
|
1015
|
+
.filter(Boolean);
|
|
1016
|
+
}
|
|
1017
|
+
function isSecretName(key) {
|
|
1018
|
+
const words = keyWords(key);
|
|
1019
|
+
return words.some((w) => SECRET_WORDS.has(w)) && !words.some((w) => NOT_SECRET_WORDS.has(w));
|
|
1020
|
+
}
|
|
1021
|
+
const DUMMY_VALUES = [
|
|
1022
|
+
/^(?:changeme|placeholder|replace[_-]?me|example|sample|dummy|test(?:ing)?|todo|tbd|secret|password|token|api[_-]?key|redacted|none|null|undefined|true|false)$/i,
|
|
1023
|
+
/^your[\w.-]{0,80}$/i,
|
|
1024
|
+
/your[_-](?:api[_-])?(?:key|token|secret|password)/i,
|
|
1025
|
+
/[_-]here$/i,
|
|
1026
|
+
/^x{3}/i,
|
|
1027
|
+
/^\*{3}/,
|
|
1028
|
+
];
|
|
1029
|
+
/** A literal that could plausibly be a credential: long enough, no spaces, not a reference, path, URL, number or obvious dummy. */
|
|
1030
|
+
function looksLikeLiteralSecret(value) {
|
|
1031
|
+
const s = value.trim();
|
|
1032
|
+
if (s.length < 8 || s.length > 4096 || /\s/.test(s) || isPlaceholder(s))
|
|
1033
|
+
return false;
|
|
1034
|
+
if (/^(?:[~./\\]|[A-Za-z]:[\\/])/.test(s) || /^[a-z][a-z0-9+.-]{1,20}:\/\//i.test(s) || /^\d+$/.test(s))
|
|
1035
|
+
return false;
|
|
1036
|
+
if (/^(.)\1+$/.test(s))
|
|
1037
|
+
return false;
|
|
1038
|
+
return !DUMMY_VALUES.some((re) => re.test(s));
|
|
1039
|
+
}
|
|
1040
|
+
function stripAuthScheme(value) {
|
|
1041
|
+
return value.replace(/^(?:Bearer|Basic|Token|Bot|ApiKey)\s{1,10}/i, '');
|
|
1042
|
+
}
|
|
1043
|
+
function maskValue(value) {
|
|
1044
|
+
return value.length <= 6 ? '****' : `${value.slice(0, 2)}****`;
|
|
1045
|
+
}
|
|
1046
|
+
/** Fallback signals for when the dangerous-code rules report nothing (or are not loaded). */
|
|
1047
|
+
const RED_FLAGS = [
|
|
1048
|
+
{
|
|
1049
|
+
regex: /\b(?:curl|wget)\b[^|\n]{0,300}\|\s{0,10}(?:sudo\s{1,10})?(?:ba|z|da|k)?sh\b/i,
|
|
1050
|
+
severity: 'high',
|
|
1051
|
+
message: 'pipes a download straight into a shell',
|
|
1052
|
+
},
|
|
1053
|
+
{
|
|
1054
|
+
regex: /\b(?:irm|iwr|Invoke-RestMethod|Invoke-WebRequest)\b[^|\n]{0,300}\|\s{0,10}(?:iex|Invoke-Expression)\b/i,
|
|
1055
|
+
severity: 'high',
|
|
1056
|
+
message: 'pipes a download into Invoke-Expression',
|
|
1057
|
+
},
|
|
1058
|
+
{
|
|
1059
|
+
regex: /\b(?:iex|Invoke-Expression)\b[^\n]{0,40}\b(?:irm|iwr|Invoke-RestMethod|Invoke-WebRequest|DownloadString)\b/i,
|
|
1060
|
+
severity: 'high',
|
|
1061
|
+
message: 'runs downloaded code with Invoke-Expression',
|
|
1062
|
+
},
|
|
1063
|
+
{
|
|
1064
|
+
regex: /\bbase64\s{1,10}(?:-d|--decode|-D)\b|\batob\s{0,5}\(|\bBuffer\.from\s{0,5}\([^)\n]{0,2000}['"]base64['"]|FromBase64String|\bb64decode\b/i,
|
|
1065
|
+
severity: 'high',
|
|
1066
|
+
message: 'decodes base64 data, a common way to hide a payload',
|
|
1067
|
+
},
|
|
1068
|
+
{
|
|
1069
|
+
regex: /\bxattr\b[^\n]{0,60}com\.apple\.quarantine/i,
|
|
1070
|
+
severity: 'high',
|
|
1071
|
+
message: 'removes the macOS quarantine flag so a downloaded binary runs unchecked',
|
|
1072
|
+
},
|
|
1073
|
+
];
|
|
1074
|
+
function analyzeScript(script, file, label, ext) {
|
|
1075
|
+
const content = script.length > MAX_SCRIPT ? script.slice(0, MAX_SCRIPT) : script;
|
|
1076
|
+
const synthetic = {
|
|
1077
|
+
path: `${file.path}#${label}${ext}`,
|
|
1078
|
+
displayPath: `${file.displayPath}#${label}${ext}`,
|
|
1079
|
+
kind: 'script',
|
|
1080
|
+
content,
|
|
1081
|
+
...(file.target !== undefined ? { target: file.target } : {}),
|
|
1082
|
+
};
|
|
1083
|
+
const hits = [];
|
|
1084
|
+
const seen = new Set();
|
|
1085
|
+
for (const rule of codeRules) {
|
|
1086
|
+
if (!rule.kinds.includes('script'))
|
|
1087
|
+
continue;
|
|
1088
|
+
let matches;
|
|
1089
|
+
try {
|
|
1090
|
+
matches = rule.check(synthetic);
|
|
1091
|
+
}
|
|
1092
|
+
catch {
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
for (const m of matches) {
|
|
1096
|
+
const key = `${rule.id}|${m.message}`;
|
|
1097
|
+
if (seen.has(key))
|
|
1098
|
+
continue;
|
|
1099
|
+
seen.add(key);
|
|
1100
|
+
hits.push({ ruleId: rule.id, title: rule.title, message: m.message, severity: m.severity ?? rule.severity });
|
|
1101
|
+
if (hits.length >= MAX_INNER_HITS)
|
|
1102
|
+
return hits;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
if (hits.length > 0)
|
|
1106
|
+
return hits;
|
|
1107
|
+
for (const flag of RED_FLAGS) {
|
|
1108
|
+
if (flag.regex.test(content))
|
|
1109
|
+
hits.push({ message: flag.message, severity: flag.severity });
|
|
1110
|
+
}
|
|
1111
|
+
return hits;
|
|
1112
|
+
}
|
|
1113
|
+
/** "<context> triggers TM-EXEC-001 (Title): inner message" or, for built-in signals, "<context> <signal>". */
|
|
1114
|
+
function hitMessage(context, hit) {
|
|
1115
|
+
return hit.ruleId ? `${context} triggers ${hit.ruleId} (${hit.title ?? ''}): ${show(hit.message, 160)}` : `${context} ${hit.message}`;
|
|
1116
|
+
}
|
|
1117
|
+
function maxSeverity(a, b) {
|
|
1118
|
+
return SEVERITY_RANK[a] >= SEVERITY_RANK[b] ? a : b;
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Project settings live in <repo>/.claude/settings.json and arrive with a
|
|
1122
|
+
* clone. <repo>/.claude/settings.local.json is personal: Claude Code writes the
|
|
1123
|
+
* user's own approvals there and keeps it out of git. User settings live in
|
|
1124
|
+
* ~/.claude/.
|
|
1125
|
+
*/
|
|
1126
|
+
function settingsScope(file) {
|
|
1127
|
+
const p = normPath(file.path);
|
|
1128
|
+
const base = path.posix.basename(p).toLowerCase();
|
|
1129
|
+
const dir = path.posix.dirname(p);
|
|
1130
|
+
if (base.startsWith('managed-settings'))
|
|
1131
|
+
return 'managed';
|
|
1132
|
+
if (!base.startsWith('settings'))
|
|
1133
|
+
return 'other';
|
|
1134
|
+
if (samePath(dir, `${home()}/.claude`))
|
|
1135
|
+
return 'user';
|
|
1136
|
+
if (path.posix.basename(dir).toLowerCase() !== '.claude')
|
|
1137
|
+
return 'other';
|
|
1138
|
+
return base === 'settings.local.json' ? 'local' : 'project';
|
|
1139
|
+
}
|
|
1140
|
+
const PROJECT_SUFFIX = '; this is a project settings file, so a cloned repository can grant itself these permissions';
|
|
1141
|
+
/** Directories whose scripts count as part of what is being scanned: the scan root and the config's own project or plugin. */
|
|
1142
|
+
function knownRoots(file) {
|
|
1143
|
+
const roots = [];
|
|
1144
|
+
const abs = normPath(file.path);
|
|
1145
|
+
const shown = normPath(file.displayPath);
|
|
1146
|
+
if (!/^(?:\/|[a-z]:)/i.test(shown) && abs.toLowerCase().endsWith(`/${shown.toLowerCase()}`)) {
|
|
1147
|
+
roots.push(abs.slice(0, abs.length - shown.length - 1));
|
|
1148
|
+
}
|
|
1149
|
+
let dir = path.posix.dirname(abs);
|
|
1150
|
+
if (!samePath(dir, `${home()}/.claude`) && /^(?:\.claude|hooks|\.claude-plugin|\.codex|\.gemini|\.cursor|\.vscode)$/i.test(path.posix.basename(dir))) {
|
|
1151
|
+
dir = path.posix.dirname(dir);
|
|
1152
|
+
}
|
|
1153
|
+
roots.push(dir);
|
|
1154
|
+
return roots.filter((r) => r.length > 1);
|
|
1155
|
+
}
|
|
1156
|
+
const SYSTEM_DIRS = [
|
|
1157
|
+
/^\/(?:usr|bin|sbin|opt\/homebrew|nix|snap|system|library\/apple)(?:\/|$)/i,
|
|
1158
|
+
/^[a-z]:\/(?:windows|program files|program files \(x86\)|programdata\/chocolatey)\//i,
|
|
1159
|
+
];
|
|
1160
|
+
/** Absolute path outside the scanned roots, after expanding ~ and $HOME; undefined when unknown or inside. */
|
|
1161
|
+
function outsidePath(token, roots) {
|
|
1162
|
+
if (/CLAUDE_PLUGIN_ROOT|CLAUDE_PROJECT_DIR/.test(token))
|
|
1163
|
+
return undefined;
|
|
1164
|
+
const expanded = normPath(expandHomeVars(token));
|
|
1165
|
+
if (!/^(?:\/|[a-z]:\/)/i.test(expanded) || /[$%]/.test(expanded))
|
|
1166
|
+
return undefined;
|
|
1167
|
+
if (SYSTEM_DIRS.some((re) => re.test(expanded)))
|
|
1168
|
+
return undefined;
|
|
1169
|
+
if (roots.some((root) => isUnder(expanded, root)))
|
|
1170
|
+
return undefined;
|
|
1171
|
+
return expanded;
|
|
1172
|
+
}
|
|
1173
|
+
function show(text, max = 80) {
|
|
1174
|
+
return truncate(printable(text.length > max * 4 ? text.slice(0, max * 4) : text), max);
|
|
1175
|
+
}
|
|
1176
|
+
function showCommand(command, max = 120) {
|
|
1177
|
+
return show(redactSecrets(command.length > MAX_SCRIPT ? command.slice(0, MAX_SCRIPT) : command).replace(/\s+/g, ' ').trim(), max);
|
|
1178
|
+
}
|
|
1179
|
+
function hookWords(hook) {
|
|
1180
|
+
const words = shellWords(hook.command).map((text) => ({ text, origin: 0 }));
|
|
1181
|
+
const lines = [words];
|
|
1182
|
+
const inline = inlineScript(words);
|
|
1183
|
+
if (inline && inline.words.length > 0)
|
|
1184
|
+
lines.push(inline.words);
|
|
1185
|
+
return lines;
|
|
1186
|
+
}
|
|
1187
|
+
// ===========================================================================
|
|
1188
|
+
// Rules: supply chain
|
|
1189
|
+
// ===========================================================================
|
|
1190
|
+
const unpinnedRunner = {
|
|
1191
|
+
id: 'TM-SUP-001',
|
|
1192
|
+
title: 'Unpinned package runner',
|
|
1193
|
+
description: 'An MCP server is launched through npx, pnpm/yarn dlx, bunx, uvx, pipx run or docker run without an exact version, so every start fetches whatever the registry serves at that moment. A hijacked maintainer account or malicious release then runs with your privileges.',
|
|
1194
|
+
category: 'supply-chain',
|
|
1195
|
+
severity: 'medium',
|
|
1196
|
+
kinds: SERVER_KINDS,
|
|
1197
|
+
remediation: 'Pin an exact version (npx -y pkg@1.2.3, uvx pkg==1.2.3, image@sha256:<digest>) and update deliberately after reviewing the changes.',
|
|
1198
|
+
check(file) {
|
|
1199
|
+
const doc = loadDoc(file);
|
|
1200
|
+
const out = [];
|
|
1201
|
+
for (const server of activeServers(doc)) {
|
|
1202
|
+
for (const run of packageRuns(unwrap(baseTokens(server)))) {
|
|
1203
|
+
const problem = pinProblem(run);
|
|
1204
|
+
if (problem)
|
|
1205
|
+
out.push({ ...doc.where(tokPath(server, run.tok)), message: pinMessage(server, run, problem) });
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
return out;
|
|
1209
|
+
},
|
|
1210
|
+
};
|
|
1211
|
+
const INSTALL_SCRIPTS = {
|
|
1212
|
+
preinstall: 'medium',
|
|
1213
|
+
install: 'medium',
|
|
1214
|
+
postinstall: 'medium',
|
|
1215
|
+
prepare: 'low',
|
|
1216
|
+
preprepare: 'low',
|
|
1217
|
+
postprepare: 'low',
|
|
1218
|
+
prepublish: 'low',
|
|
1219
|
+
};
|
|
1220
|
+
const installScript = {
|
|
1221
|
+
id: 'TM-SUP-002',
|
|
1222
|
+
title: 'Install-time script in a package manifest',
|
|
1223
|
+
description: 'The package runs a lifecycle script (preinstall, install, postinstall, prepare) automatically when it is installed. Install scripts are the most common way malicious packages execute code before anyone looks at them.',
|
|
1224
|
+
category: 'supply-chain',
|
|
1225
|
+
severity: 'medium',
|
|
1226
|
+
kinds: ['package-manifest'],
|
|
1227
|
+
remediation: 'Review what the script does; install with --ignore-scripts when you do not need it, and prefer packages that need no install step.',
|
|
1228
|
+
check(file) {
|
|
1229
|
+
const doc = loadDoc(file);
|
|
1230
|
+
const scripts = doc.format === 'json' ? doc.data?.scripts : undefined;
|
|
1231
|
+
if (!isRecord(scripts))
|
|
1232
|
+
return [];
|
|
1233
|
+
const out = [];
|
|
1234
|
+
for (const [name, base] of Object.entries(INSTALL_SCRIPTS)) {
|
|
1235
|
+
const script = scripts[name];
|
|
1236
|
+
if (typeof script !== 'string' || !script.trim())
|
|
1237
|
+
continue;
|
|
1238
|
+
const where = doc.where(['scripts', name]);
|
|
1239
|
+
const hits = analyzeScript(script, file, `scripts.${name}`, '.sh');
|
|
1240
|
+
if (hits.length === 0) {
|
|
1241
|
+
out.push({ ...where, severity: base, message: `'${name}' script runs automatically on install: ${showCommand(script)}` });
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
for (const hit of hits) {
|
|
1245
|
+
out.push({ ...where, severity: maxSeverity(base, hit.severity), message: hitMessage(`'${name}' install script`, hit) });
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
return out;
|
|
1249
|
+
},
|
|
1250
|
+
};
|
|
1251
|
+
/** npm dependency specs that bypass the registry: git, GitHub shorthand, URLs. */
|
|
1252
|
+
function npmSourceProblem(spec) {
|
|
1253
|
+
const s = spec.trim();
|
|
1254
|
+
if (!s || /^(?:file:|link:|workspace:|portal:|patch:|npm:|catalog:)/i.test(s) || /^\.{0,2}\//.test(s))
|
|
1255
|
+
return undefined;
|
|
1256
|
+
if (/^(?:git\+)?http:\/\//i.test(s) || /^git:\/\//i.test(s)) {
|
|
1257
|
+
return { severity: 'medium', message: `over an unencrypted connection (${show(maskUrlString(s))}); anyone on the network path can swap the code` };
|
|
1258
|
+
}
|
|
1259
|
+
const gitLike = /^(?:git\+|git@|github:|gitlab:|bitbucket:|gist:)/i.test(s) || /^https:\/\/[^\s]{1,2000}\.git(?:#|$)/i.test(s);
|
|
1260
|
+
if (gitLike || /^[A-Za-z0-9][\w.-]{0,100}\/[\w.-]{1,100}(?:#\S{0,200})?$/.test(s)) {
|
|
1261
|
+
const hash = s.lastIndexOf('#');
|
|
1262
|
+
const ref = hash === -1 ? '' : s.slice(hash + 1).replace(/^commit=/, '');
|
|
1263
|
+
if (COMMIT_REF.test(ref))
|
|
1264
|
+
return undefined;
|
|
1265
|
+
return { severity: 'low', message: `from git (${show(maskUrlString(s))}) without a pinned commit; the branch or tag can change under you` };
|
|
1266
|
+
}
|
|
1267
|
+
if (/^https:\/\//i.test(s)) {
|
|
1268
|
+
return { severity: 'medium', message: `from a raw URL (${show(maskUrlString(s))}); the file can change without a version bump and skips registry integrity checks` };
|
|
1269
|
+
}
|
|
1270
|
+
return undefined;
|
|
1271
|
+
}
|
|
1272
|
+
/** Python direct references: git+..., http(s):// archives. */
|
|
1273
|
+
function pySourceProblem(url) {
|
|
1274
|
+
const s = url.trim();
|
|
1275
|
+
if (!s || /^file:/i.test(s))
|
|
1276
|
+
return undefined;
|
|
1277
|
+
if (/^(?:git\+)?http:\/\//i.test(s) || /^git:\/\//i.test(s)) {
|
|
1278
|
+
return { severity: 'medium', message: `over an unencrypted connection (${show(maskUrlString(s))}); anyone on the network path can swap the code` };
|
|
1279
|
+
}
|
|
1280
|
+
if (/^git\+/i.test(s)) {
|
|
1281
|
+
const ref = /@([0-9A-Za-z._-]{1,100})(?:#\S{0,200})?$/.exec(s.replace(/^git\+[a-z]{1,10}:\/\/[^@/]{0,200}@/i, ''));
|
|
1282
|
+
if (ref && COMMIT_REF.test(ref[1]))
|
|
1283
|
+
return undefined;
|
|
1284
|
+
return { severity: 'low', message: `from git (${show(maskUrlString(s))}) without a pinned commit; the branch or tag can change under you` };
|
|
1285
|
+
}
|
|
1286
|
+
if (/^https:\/\//i.test(s)) {
|
|
1287
|
+
return { severity: 'medium', message: `from a raw URL (${show(maskUrlString(s))}); the file can change without a version bump` };
|
|
1288
|
+
}
|
|
1289
|
+
return undefined;
|
|
1290
|
+
}
|
|
1291
|
+
const PEP508_DIRECT = /^\s{0,10}([A-Za-z0-9][A-Za-z0-9._-]{0,200})(?:\[[^\]]{0,200}\])?\s{0,10}@\s{0,10}(\S{1,2000})/;
|
|
1292
|
+
function pyprojectDeps(doc) {
|
|
1293
|
+
const data = doc.data;
|
|
1294
|
+
if (!data)
|
|
1295
|
+
return [];
|
|
1296
|
+
const out = [];
|
|
1297
|
+
const fromList = (list, at) => {
|
|
1298
|
+
if (!Array.isArray(list))
|
|
1299
|
+
return;
|
|
1300
|
+
list.forEach((dep, i) => {
|
|
1301
|
+
if (typeof dep !== 'string')
|
|
1302
|
+
return;
|
|
1303
|
+
const m = PEP508_DIRECT.exec(dep);
|
|
1304
|
+
const problem = m ? pySourceProblem(m[2]) : undefined;
|
|
1305
|
+
if (m && problem)
|
|
1306
|
+
out.push({ name: m[1], problem, at: [...at, i] });
|
|
1307
|
+
});
|
|
1308
|
+
};
|
|
1309
|
+
const fromTable = (table, at) => {
|
|
1310
|
+
if (!isRecord(table))
|
|
1311
|
+
return;
|
|
1312
|
+
for (const [name, spec] of Object.entries(table)) {
|
|
1313
|
+
const entries = Array.isArray(spec) ? spec : [spec];
|
|
1314
|
+
for (const entry of entries) {
|
|
1315
|
+
if (!isRecord(entry))
|
|
1316
|
+
continue;
|
|
1317
|
+
let problem;
|
|
1318
|
+
if (typeof entry.git === 'string') {
|
|
1319
|
+
const rev = typeof entry.rev === 'string' ? entry.rev : '';
|
|
1320
|
+
problem = pySourceProblem(`git+${entry.git.replace(/^git\+/i, '')}${rev ? `@${rev}` : ''}`);
|
|
1321
|
+
}
|
|
1322
|
+
else if (typeof entry.url === 'string') {
|
|
1323
|
+
problem = pySourceProblem(entry.url);
|
|
1324
|
+
}
|
|
1325
|
+
if (problem)
|
|
1326
|
+
out.push({ name, problem, at: [...at, name] });
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
};
|
|
1330
|
+
const project = data.project;
|
|
1331
|
+
if (isRecord(project)) {
|
|
1332
|
+
fromList(project.dependencies, ['project', 'dependencies']);
|
|
1333
|
+
if (isRecord(project['optional-dependencies'])) {
|
|
1334
|
+
for (const [group, list] of Object.entries(project['optional-dependencies']))
|
|
1335
|
+
fromList(list, ['project', 'optional-dependencies', group]);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
if (isRecord(data['dependency-groups'])) {
|
|
1339
|
+
for (const [group, list] of Object.entries(data['dependency-groups']))
|
|
1340
|
+
fromList(list, ['dependency-groups', group]);
|
|
1341
|
+
}
|
|
1342
|
+
const tool = isRecord(data.tool) ? data.tool : undefined;
|
|
1343
|
+
if (tool && isRecord(tool.poetry)) {
|
|
1344
|
+
fromTable(tool.poetry.dependencies, ['tool', 'poetry', 'dependencies']);
|
|
1345
|
+
fromTable(tool.poetry['dev-dependencies'], ['tool', 'poetry', 'dev-dependencies']);
|
|
1346
|
+
if (isRecord(tool.poetry.group)) {
|
|
1347
|
+
for (const [group, body] of Object.entries(tool.poetry.group)) {
|
|
1348
|
+
if (isRecord(body))
|
|
1349
|
+
fromTable(body.dependencies, ['tool', 'poetry', 'group', group, 'dependencies']);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
if (tool && isRecord(tool.uv))
|
|
1354
|
+
fromTable(tool.uv.sources, ['tool', 'uv', 'sources']);
|
|
1355
|
+
return out;
|
|
1356
|
+
}
|
|
1357
|
+
/** requirements.txt lines that install from git, a URL or a plain-HTTP index. */
|
|
1358
|
+
function requirementsDeps(content) {
|
|
1359
|
+
const out = [];
|
|
1360
|
+
const lines = content.split('\n');
|
|
1361
|
+
for (let i = 0; i < lines.length && out.length < 50; i++) {
|
|
1362
|
+
const raw = lines[i];
|
|
1363
|
+
const comment = raw.search(/(?:^|\s)#/);
|
|
1364
|
+
const line = (comment === -1 ? raw : raw.slice(0, comment)).trim();
|
|
1365
|
+
if (!line)
|
|
1366
|
+
continue;
|
|
1367
|
+
const index = /^(?:--index-url|--extra-index-url|-i)[\s=]{1,10}(\S{1,2000})/.exec(line);
|
|
1368
|
+
if (index) {
|
|
1369
|
+
const url = index[1];
|
|
1370
|
+
if (/^http:\/\//i.test(url) && hostClass(parseUrl(url)?.hostname ?? '') === 'public') {
|
|
1371
|
+
out.push({ line: i + 1, severity: 'medium', message: `Package index ${show(maskUrlString(url))} is used over plain HTTP; anyone on the network path can serve different packages` });
|
|
1372
|
+
}
|
|
1373
|
+
else if (line.startsWith('--extra-index-url')) {
|
|
1374
|
+
out.push({ line: i + 1, severity: 'low', message: `Extra package index ${show(maskUrlString(url))} is consulted alongside PyPI, which allows dependency-confusion attacks` });
|
|
1375
|
+
}
|
|
1376
|
+
continue;
|
|
1377
|
+
}
|
|
1378
|
+
const spec = line.replace(/^(?:-e|--editable)\s{1,10}/, '');
|
|
1379
|
+
const direct = PEP508_DIRECT.exec(spec);
|
|
1380
|
+
const url = direct ? direct[2] : spec;
|
|
1381
|
+
if (!/^(?:git\+|https?:\/\/|git:\/\/)/i.test(url))
|
|
1382
|
+
continue;
|
|
1383
|
+
const problem = pySourceProblem(url.split(/\s/)[0]);
|
|
1384
|
+
if (problem) {
|
|
1385
|
+
const name = direct?.[1] ?? /#egg=([\w.-]{1,100})/.exec(url)?.[1] ?? 'package';
|
|
1386
|
+
out.push({ line: i + 1, severity: problem.severity, message: `Dependency '${show(name, 60)}' is fetched ${problem.message}` });
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
return out;
|
|
1390
|
+
}
|
|
1391
|
+
const NPM_DEP_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies', 'resolutions'];
|
|
1392
|
+
const dependencySource = {
|
|
1393
|
+
id: 'TM-SUP-003',
|
|
1394
|
+
title: 'Dependency fetched from a URL or git',
|
|
1395
|
+
description: 'A dependency is installed straight from a git repository or URL instead of a registry release. It bypasses registry integrity checks and can change without a version bump, and plain-HTTP sources can be swapped in transit.',
|
|
1396
|
+
category: 'supply-chain',
|
|
1397
|
+
severity: 'low',
|
|
1398
|
+
kinds: ['package-manifest'],
|
|
1399
|
+
remediation: 'Depend on a published registry version, or pin git dependencies to a full commit hash and serve everything over HTTPS.',
|
|
1400
|
+
check(file) {
|
|
1401
|
+
const doc = loadDoc(file);
|
|
1402
|
+
const out = [];
|
|
1403
|
+
if (doc.format === 'json' && doc.data) {
|
|
1404
|
+
for (const field of NPM_DEP_FIELDS) {
|
|
1405
|
+
const deps = doc.data[field];
|
|
1406
|
+
if (!isRecord(deps))
|
|
1407
|
+
continue;
|
|
1408
|
+
for (const [name, spec] of Object.entries(deps)) {
|
|
1409
|
+
if (typeof spec !== 'string' || out.length >= 100)
|
|
1410
|
+
continue;
|
|
1411
|
+
const problem = npmSourceProblem(spec);
|
|
1412
|
+
if (problem) {
|
|
1413
|
+
out.push({ ...doc.where([field, name]), severity: problem.severity, message: `Dependency '${show(name, 60)}' is fetched ${problem.message}` });
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
else if (doc.format === 'toml') {
|
|
1419
|
+
for (const dep of pyprojectDeps(doc)) {
|
|
1420
|
+
out.push({ ...doc.where(dep.at), severity: dep.problem.severity, message: `Dependency '${show(dep.name, 60)}' is fetched ${dep.problem.message}` });
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
else if (/\.(?:txt|in)$/i.test(file.path)) {
|
|
1424
|
+
for (const dep of requirementsDeps(file.content))
|
|
1425
|
+
out.push({ line: dep.line, severity: dep.severity, message: dep.message });
|
|
1426
|
+
}
|
|
1427
|
+
return out;
|
|
1428
|
+
},
|
|
1429
|
+
};
|
|
1430
|
+
const scratchBinary = {
|
|
1431
|
+
id: 'TM-SUP-004',
|
|
1432
|
+
title: 'Runs a binary from a temp or downloads folder',
|
|
1433
|
+
description: 'An MCP server or hook executes a program or script that lives in /tmp, %TEMP% or ~/Downloads. Those folders hold unreviewed, unversioned files written by browsers and other programs, which is exactly where droppers stage payloads.',
|
|
1434
|
+
category: 'supply-chain',
|
|
1435
|
+
severity: 'medium',
|
|
1436
|
+
kinds: LAUNCH_KINDS,
|
|
1437
|
+
remediation: 'Install the tool to a stable location (or reference it from the project or plugin) and verify where it came from.',
|
|
1438
|
+
check(file) {
|
|
1439
|
+
const doc = loadDoc(file);
|
|
1440
|
+
const out = [];
|
|
1441
|
+
for (const server of activeServers(doc)) {
|
|
1442
|
+
for (const line of launchLines(server)) {
|
|
1443
|
+
for (const tok of executedWords(line)) {
|
|
1444
|
+
const folder = scratchFolder(tok.text);
|
|
1445
|
+
if (folder)
|
|
1446
|
+
out.push({ ...doc.where(tokPath(server, tok)), message: `${serverLabel(server)} runs ${show(tok.text)} from ${folderName(folder)}; files there are not reviewed or versioned.` });
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
for (const hook of hooks(doc)) {
|
|
1451
|
+
for (const line of hookWords(hook)) {
|
|
1452
|
+
for (const tok of executedWords(line)) {
|
|
1453
|
+
const folder = scratchFolder(tok.text);
|
|
1454
|
+
if (folder)
|
|
1455
|
+
out.push({ ...doc.where(hook.path), message: `${hookLabel(hook)} runs ${show(tok.text)} from ${folderName(folder)}; files there are not reviewed or versioned.` });
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
return out;
|
|
1460
|
+
},
|
|
1461
|
+
};
|
|
1462
|
+
function folderName(folder) {
|
|
1463
|
+
return folder === 'temp' ? 'a temporary folder' : 'the Downloads folder';
|
|
1464
|
+
}
|
|
1465
|
+
// ===========================================================================
|
|
1466
|
+
// Rules: transport
|
|
1467
|
+
// ===========================================================================
|
|
1468
|
+
const plainHttp = {
|
|
1469
|
+
id: 'TM-NET-001',
|
|
1470
|
+
title: 'Remote MCP server over plain HTTP',
|
|
1471
|
+
description: 'A remote MCP server is reached over unencrypted HTTP. Anyone on the network path can read tool calls and results, steal bearer tokens and inject tool descriptions or responses.',
|
|
1472
|
+
category: 'insecure-transport',
|
|
1473
|
+
severity: 'high',
|
|
1474
|
+
kinds: SERVER_KINDS,
|
|
1475
|
+
remediation: 'Use an https:// URL. Plain HTTP is only acceptable for localhost.',
|
|
1476
|
+
check(file) {
|
|
1477
|
+
const doc = loadDoc(file);
|
|
1478
|
+
const out = [];
|
|
1479
|
+
for (const server of activeServers(doc)) {
|
|
1480
|
+
for (const endpoint of endpoints(server)) {
|
|
1481
|
+
const url = parseUrl(endpoint.value);
|
|
1482
|
+
if (!url || (url.protocol !== 'http:' && url.protocol !== 'ws:'))
|
|
1483
|
+
continue;
|
|
1484
|
+
const cls = hostClass(url.hostname);
|
|
1485
|
+
if (cls === 'loopback')
|
|
1486
|
+
continue;
|
|
1487
|
+
out.push({
|
|
1488
|
+
...doc.where(endpoint.path),
|
|
1489
|
+
severity: cls === 'private' ? 'medium' : 'high',
|
|
1490
|
+
message: `${serverLabel(server)} connects to ${show(shownUrl(url), 100)} over plain HTTP; tool calls, results and tokens can be read or altered on the ${cls === 'private' ? 'local network' : 'network'}.`,
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
return out;
|
|
1495
|
+
},
|
|
1496
|
+
};
|
|
1497
|
+
const urlCredentials = {
|
|
1498
|
+
id: 'TM-NET-002',
|
|
1499
|
+
title: 'Credentials in a remote MCP URL',
|
|
1500
|
+
description: 'A remote MCP server URL carries a credential in its query string or user:password part. URLs are logged by proxies and servers, shown in UIs and copied into bug reports, so the credential leaks easily.',
|
|
1501
|
+
category: 'insecure-transport',
|
|
1502
|
+
severity: 'high',
|
|
1503
|
+
kinds: SERVER_KINDS,
|
|
1504
|
+
remediation: "Send the credential in a header backed by an environment variable or the client's secret store (for example ${input:token} in VS Code), then rotate it.",
|
|
1505
|
+
check(file) {
|
|
1506
|
+
const doc = loadDoc(file);
|
|
1507
|
+
const out = [];
|
|
1508
|
+
for (const server of activeServers(doc)) {
|
|
1509
|
+
for (const endpoint of endpoints(server)) {
|
|
1510
|
+
const url = parseUrl(endpoint.value);
|
|
1511
|
+
if (!url)
|
|
1512
|
+
continue;
|
|
1513
|
+
const where = doc.where(endpoint.path);
|
|
1514
|
+
const params = [...url.searchParams.entries()]
|
|
1515
|
+
.filter(([name, value]) => isCredentialParam(name) && value.length >= 6 && !isPlaceholder(value))
|
|
1516
|
+
.map(([name]) => name);
|
|
1517
|
+
if (params.length > 0) {
|
|
1518
|
+
out.push({
|
|
1519
|
+
...where,
|
|
1520
|
+
snippet: maskedUrl(url),
|
|
1521
|
+
message: `${serverLabel(server)} URL carries a credential in query parameter '${show(params[0], 40)}'; URLs end up in logs, proxies and screenshots.`,
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
const password = decodeURIComponentSafe(url.password);
|
|
1525
|
+
const username = decodeURIComponentSafe(url.username);
|
|
1526
|
+
if ((password && !isPlaceholder(password)) || (!password && username.length >= 20 && !isPlaceholder(username))) {
|
|
1527
|
+
out.push({
|
|
1528
|
+
...where,
|
|
1529
|
+
snippet: maskedUrl(url),
|
|
1530
|
+
message: `${serverLabel(server)} URL embeds ${password ? 'a username and password' : 'a token as the username'} before the host; URLs end up in logs, proxies and screenshots.`,
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
return out;
|
|
1536
|
+
},
|
|
1537
|
+
};
|
|
1538
|
+
function decodeURIComponentSafe(text) {
|
|
1539
|
+
try {
|
|
1540
|
+
return decodeURIComponent(text);
|
|
1541
|
+
}
|
|
1542
|
+
catch {
|
|
1543
|
+
return text;
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
function maskedUrl(url) {
|
|
1547
|
+
const copy = new URL(url.href);
|
|
1548
|
+
if (copy.password)
|
|
1549
|
+
copy.password = '****';
|
|
1550
|
+
else if (copy.username.length >= 20)
|
|
1551
|
+
copy.username = '****';
|
|
1552
|
+
for (const name of [...copy.searchParams.keys()])
|
|
1553
|
+
if (isCredentialParam(name))
|
|
1554
|
+
copy.searchParams.set(name, '****');
|
|
1555
|
+
return truncate(copy.href, 160);
|
|
1556
|
+
}
|
|
1557
|
+
const tunnelEndpoint = {
|
|
1558
|
+
id: 'TM-NET-003',
|
|
1559
|
+
title: 'Remote MCP server on a tunnel or raw IP',
|
|
1560
|
+
description: 'A remote MCP server is reached through a public tunnelling service (ngrok, Cloudflare quick tunnels, localtunnel, serveo...) or a bare public IP address. Anyone can create these in seconds, they rotate freely and usually front a personal machine, so there is no stable identity behind the server.',
|
|
1561
|
+
category: 'insecure-transport',
|
|
1562
|
+
severity: 'medium',
|
|
1563
|
+
kinds: SERVER_KINDS,
|
|
1564
|
+
remediation: 'Only connect to MCP servers on domains you can attribute to their operator, over HTTPS.',
|
|
1565
|
+
check(file) {
|
|
1566
|
+
const doc = loadDoc(file);
|
|
1567
|
+
const out = [];
|
|
1568
|
+
for (const server of activeServers(doc)) {
|
|
1569
|
+
for (const endpoint of endpoints(server)) {
|
|
1570
|
+
const url = parseUrl(endpoint.value);
|
|
1571
|
+
if (!url)
|
|
1572
|
+
continue;
|
|
1573
|
+
const host = url.hostname.toLowerCase();
|
|
1574
|
+
const provider = tunnelProvider(host);
|
|
1575
|
+
if (provider) {
|
|
1576
|
+
out.push({ ...doc.where(endpoint.path), message: `${serverLabel(server)} connects to ${show(host, 80)}, a public ${provider} tunnel that anyone can stand up and that usually fronts a personal machine.` });
|
|
1577
|
+
}
|
|
1578
|
+
else if ((ipv4(host) || host.startsWith('[')) && hostClass(host) === 'public') {
|
|
1579
|
+
out.push({ ...doc.where(endpoint.path), message: `${serverLabel(server)} connects to the raw IP address ${show(host, 60)}; there is no domain name to tell you who operates it.` });
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
return out;
|
|
1584
|
+
},
|
|
1585
|
+
};
|
|
1586
|
+
// ===========================================================================
|
|
1587
|
+
// Rules: config hygiene
|
|
1588
|
+
// ===========================================================================
|
|
1589
|
+
function secretMatches(doc, server) {
|
|
1590
|
+
const out = [];
|
|
1591
|
+
const who = serverLabel(server);
|
|
1592
|
+
const tail = 'anyone or anything that can read this file, including a malicious skill or plugin, can copy it.';
|
|
1593
|
+
const checkValue = (label, key, value, at) => {
|
|
1594
|
+
if (typeof value !== 'string')
|
|
1595
|
+
return;
|
|
1596
|
+
const hit = findSecrets(value, 1)[0];
|
|
1597
|
+
const snippet = `${key}: ${maskValue(value)}`;
|
|
1598
|
+
if (hit) {
|
|
1599
|
+
out.push({ ...doc.where(at), snippet, severity: 'high', message: `${who} ${label} '${show(key, 60)}' holds a plaintext ${hit.name}; ${tail}` });
|
|
1600
|
+
}
|
|
1601
|
+
else if (isSecretName(key) && looksLikeLiteralSecret(stripAuthScheme(value))) {
|
|
1602
|
+
out.push({ ...doc.where(at), snippet, severity: 'medium', message: `${who} ${label} '${show(key, 60)}' holds a literal value that looks like a credential; ${tail}` });
|
|
1603
|
+
}
|
|
1604
|
+
};
|
|
1605
|
+
if (server.env)
|
|
1606
|
+
for (const [key, value] of Object.entries(server.env.values))
|
|
1607
|
+
checkValue('env var', key, value, [...server.env.path, key]);
|
|
1608
|
+
for (const headers of server.headers) {
|
|
1609
|
+
for (const [key, value] of Object.entries(headers.values))
|
|
1610
|
+
checkValue('header', key, value, [...headers.path, key]);
|
|
1611
|
+
}
|
|
1612
|
+
server.args.forEach((arg, i) => {
|
|
1613
|
+
const snippet = `argument ${i + 1}: ${maskValue(arg.value)}`;
|
|
1614
|
+
const hit = findSecrets(arg.value, 1)[0];
|
|
1615
|
+
if (hit) {
|
|
1616
|
+
out.push({ ...doc.where(arg.path), snippet, severity: 'high', message: `${who} passes a plaintext ${hit.name} on its command line (argument ${i + 1}); command lines are visible to other local processes and end up in logs.` });
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
const assign = /^(?:--?)?([A-Za-z_][\w-]{0,100})=([^\n]{1,4096})$/.exec(arg.value);
|
|
1620
|
+
const prev = server.args[i - 1]?.value ?? '';
|
|
1621
|
+
const flag = /^--?([A-Za-z][\w-]{0,60})$/.exec(prev);
|
|
1622
|
+
const name = assign ? assign[1] : flag && !arg.value.startsWith('-') ? flag[1] : undefined;
|
|
1623
|
+
const value = assign ? assign[2] : arg.value;
|
|
1624
|
+
if (name && isSecretName(name) && looksLikeLiteralSecret(stripAuthScheme(value))) {
|
|
1625
|
+
out.push({ ...doc.where(arg.path), snippet, severity: 'medium', message: `${who} passes a literal value for '${show(name, 60)}' on its command line; ${tail}` });
|
|
1626
|
+
}
|
|
1627
|
+
});
|
|
1628
|
+
return out;
|
|
1629
|
+
}
|
|
1630
|
+
const plaintextSecret = {
|
|
1631
|
+
id: 'TM-CFG-001',
|
|
1632
|
+
title: 'Plaintext secret in MCP server config',
|
|
1633
|
+
description: 'An MCP server env var, header or argument holds a literal credential. Client configs are read by every tool, skill and plugin running as you, are synced and backed up, and are a prime target for infostealers; secrets there leak easily.',
|
|
1634
|
+
category: 'secrets',
|
|
1635
|
+
severity: 'high',
|
|
1636
|
+
kinds: SERVER_KINDS,
|
|
1637
|
+
remediation: "Move the secret to the environment or the client's input/secret store (${input:...} in VS Code, ${env:VAR} or bearer_token_env_var), then rotate it.",
|
|
1638
|
+
check(file) {
|
|
1639
|
+
const doc = loadDoc(file);
|
|
1640
|
+
return servers(doc).flatMap((server) => secretMatches(doc, server));
|
|
1641
|
+
},
|
|
1642
|
+
};
|
|
1643
|
+
const inlineShell = {
|
|
1644
|
+
id: 'TM-CFG-002',
|
|
1645
|
+
title: 'Server command runs an inline shell script',
|
|
1646
|
+
description: 'An MCP server is launched as bash -c, cmd /c or powershell -Command with a script written into the config. Inline scripts hide arbitrary logic (downloads, decoding, chained commands) behind what looks like a server entry; encoded PowerShell hides it completely.',
|
|
1647
|
+
category: 'dangerous-execution',
|
|
1648
|
+
severity: 'medium',
|
|
1649
|
+
kinds: SERVER_KINDS,
|
|
1650
|
+
remediation: 'Point the server at a real executable with plain arguments; move any setup into a reviewed script file.',
|
|
1651
|
+
check(file) {
|
|
1652
|
+
const doc = loadDoc(file);
|
|
1653
|
+
const out = [];
|
|
1654
|
+
for (const server of activeServers(doc)) {
|
|
1655
|
+
const inline = findInline(baseTokens(server));
|
|
1656
|
+
if (!inline)
|
|
1657
|
+
continue;
|
|
1658
|
+
const at = doc.where(tokPath(server, { text: '', origin: inline.origin }));
|
|
1659
|
+
const base = inline.encoded ? 'high' : 'medium';
|
|
1660
|
+
const ext = inline.shell === 'cmd' ? '.cmd' : inline.shell === 'powershell' || inline.shell === 'pwsh' ? '.ps1' : '.sh';
|
|
1661
|
+
const hits = analyzeScript(inline.script, file, `${server.name}.command`, ext);
|
|
1662
|
+
const who = serverLabel(server);
|
|
1663
|
+
for (const hit of hits) {
|
|
1664
|
+
out.push({ ...at, severity: maxSeverity(base, hit.severity), message: hitMessage(`${who} inline ${inline.shell} script`, hit) });
|
|
1665
|
+
}
|
|
1666
|
+
if (hits.length > 0)
|
|
1667
|
+
continue;
|
|
1668
|
+
if (inline.encoded) {
|
|
1669
|
+
out.push({ ...at, severity: base, message: `${who} runs a base64-encoded PowerShell command, which hides what it does${inline.words.length ? `; decoded: ${showCommand(inline.script, 100)}` : ''}.` });
|
|
1670
|
+
}
|
|
1671
|
+
else if (!isSimpleScript(inline.script)) {
|
|
1672
|
+
out.push({ ...at, severity: base, message: `${who} runs an inline ${inline.shell} script instead of a program: ${showCommand(inline.script, 100)}` });
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
return out;
|
|
1676
|
+
},
|
|
1677
|
+
};
|
|
1678
|
+
function enableAllMessage(scope) {
|
|
1679
|
+
if (scope === 'user') {
|
|
1680
|
+
return {
|
|
1681
|
+
message: "enableAllProjectMcpServers is true in your user settings: the MCP servers in any repository's .mcp.json start without asking, so cloning a repository is enough to run its servers.",
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
if (scope === 'local') {
|
|
1685
|
+
return {
|
|
1686
|
+
severity: 'low',
|
|
1687
|
+
message: 'enableAllProjectMcpServers is true in local project settings: every server this project adds to .mcp.json later, for example in a pulled commit, starts without asking you.',
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
return { message: 'enableAllProjectMcpServers is true in project settings: a cloned repository can switch on the MCP servers it ships in .mcp.json without asking you.' };
|
|
1691
|
+
}
|
|
1692
|
+
function enabledListMessage(scope, names) {
|
|
1693
|
+
if (scope === 'user') {
|
|
1694
|
+
return {
|
|
1695
|
+
severity: 'low',
|
|
1696
|
+
message: `User settings pre-approve project MCP servers named ${names}; any repository whose .mcp.json reuses one of these names gets that server started without asking.`,
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
if (scope === 'local') {
|
|
1700
|
+
return {
|
|
1701
|
+
severity: 'info',
|
|
1702
|
+
message: `Local project settings pre-approve the .mcp.json servers ${names}; if a pulled commit changes what one of them runs, it starts without asking you again.`,
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
return { message: `Project settings pre-approve the .mcp.json servers ${names}: a cloned repository can switch on its own MCP servers without asking you.` };
|
|
1706
|
+
}
|
|
1707
|
+
/** The innermost interesting inline script: simple one-liners that just launch another wrapper are followed. */
|
|
1708
|
+
function findInline(toks, depth = 0) {
|
|
1709
|
+
if (depth > 3)
|
|
1710
|
+
return undefined;
|
|
1711
|
+
const inline = inlineScript(stripEnv(toks));
|
|
1712
|
+
if (!inline)
|
|
1713
|
+
return undefined;
|
|
1714
|
+
if (!inline.encoded && isSimpleScript(inline.script))
|
|
1715
|
+
return findInline(inline.words, depth + 1) ?? inline;
|
|
1716
|
+
return inline;
|
|
1717
|
+
}
|
|
1718
|
+
const autoEnableServers = {
|
|
1719
|
+
id: 'TM-CFG-003',
|
|
1720
|
+
title: 'Settings auto-enable project MCP servers',
|
|
1721
|
+
description: "Claude Code settings turn on the MCP servers from a project's .mcp.json without the usual approval prompt (enableAllProjectMcpServers or enabledMcpjsonServers). When the setting ships inside a repository, cloning it is enough to start whatever servers it defines.",
|
|
1722
|
+
category: 'excessive-permissions',
|
|
1723
|
+
severity: 'medium',
|
|
1724
|
+
kinds: ['agent-settings'],
|
|
1725
|
+
remediation: 'Remove the setting from project settings and approve project MCP servers interactively after reviewing .mcp.json.',
|
|
1726
|
+
check(file) {
|
|
1727
|
+
const doc = loadDoc(file);
|
|
1728
|
+
const data = doc.data;
|
|
1729
|
+
if (!data)
|
|
1730
|
+
return [];
|
|
1731
|
+
const scope = settingsScope(file);
|
|
1732
|
+
if (scope === 'managed')
|
|
1733
|
+
return [];
|
|
1734
|
+
const out = [];
|
|
1735
|
+
if (data.enableAllProjectMcpServers === true) {
|
|
1736
|
+
out.push({ ...doc.where(['enableAllProjectMcpServers']), ...enableAllMessage(scope) });
|
|
1737
|
+
}
|
|
1738
|
+
const enabled = Array.isArray(data.enabledMcpjsonServers) ? data.enabledMcpjsonServers.filter((n) => typeof n === 'string') : [];
|
|
1739
|
+
if (enabled.length > 0) {
|
|
1740
|
+
const names = enabled.slice(0, 5).map((n) => `'${show(n, 40)}'`).join(', ') + (enabled.length > 5 ? ', ...' : '');
|
|
1741
|
+
out.push({ ...doc.where(['enabledMcpjsonServers']), ...enabledListMessage(scope, names) });
|
|
1742
|
+
}
|
|
1743
|
+
return out;
|
|
1744
|
+
},
|
|
1745
|
+
};
|
|
1746
|
+
// ===========================================================================
|
|
1747
|
+
// Rules: permissions
|
|
1748
|
+
// ===========================================================================
|
|
1749
|
+
const CODE_RUNNERS = new Set([
|
|
1750
|
+
'bash', 'sh', 'zsh', 'dash', 'fish', 'ksh', 'pwsh', 'powershell', 'cmd', 'python', 'python3', 'py', 'node', 'deno', 'bun', 'ruby',
|
|
1751
|
+
'perl', 'php', 'eval', 'exec', 'sudo', 'su', 'doas', 'env', 'xargs', 'npx', 'uvx', 'bunx', 'pnpx', 'osascript',
|
|
1752
|
+
]);
|
|
1753
|
+
const NETWORK_RUNNERS = new Set(['curl', 'wget', 'nc', 'ncat', 'netcat', 'socat', 'ssh', 'scp', 'iwr', 'irm', 'invoke-webrequest', 'invoke-restmethod']);
|
|
1754
|
+
/** What a permissions.allow or allowed-tools entry effectively grants, when that is broad. */
|
|
1755
|
+
function classifyGrant(entry) {
|
|
1756
|
+
const t = entry.trim();
|
|
1757
|
+
if (t === '*')
|
|
1758
|
+
return { severity: 'high', effect: 'which allows every tool without asking' };
|
|
1759
|
+
const shell = /^(Bash|PowerShell)(?:\(([^)\n]{0,500})\))?$/i.exec(t);
|
|
1760
|
+
if (shell) {
|
|
1761
|
+
const spec = (shell[2] ?? '').trim();
|
|
1762
|
+
if (spec === '' || spec.startsWith('*') || spec === ':*') {
|
|
1763
|
+
return { severity: 'high', effect: 'which lets the agent run any shell command without asking' };
|
|
1764
|
+
}
|
|
1765
|
+
const first = programName(spec.split(/[\s:]/)[0] ?? '');
|
|
1766
|
+
if (CODE_RUNNERS.has(first))
|
|
1767
|
+
return { severity: 'medium', effect: `which lets the agent run arbitrary code through ${first} without asking` };
|
|
1768
|
+
if (NETWORK_RUNNERS.has(first))
|
|
1769
|
+
return { severity: 'medium', effect: `which lets the agent send data anywhere with ${first} without asking` };
|
|
1770
|
+
return undefined;
|
|
1771
|
+
}
|
|
1772
|
+
if (/^WebFetch(?:\(\s{0,5}(?:\*|domain:\*)?\s{0,5}\))?$/i.test(t)) {
|
|
1773
|
+
return { severity: 'medium', effect: 'which lets the agent fetch any URL without asking, an easy exfiltration channel for injected instructions' };
|
|
1774
|
+
}
|
|
1775
|
+
if (/^WebSearch(?:\(\s{0,5}\*?\s{0,5}\))?$/i.test(t)) {
|
|
1776
|
+
return { severity: 'low', effect: 'which lets the agent send search queries (and whatever it puts in them) without asking' };
|
|
1777
|
+
}
|
|
1778
|
+
if (/^mcp__\*/i.test(t))
|
|
1779
|
+
return { severity: 'medium', effect: 'which pre-approves every tool of every MCP server' };
|
|
1780
|
+
return undefined;
|
|
1781
|
+
}
|
|
1782
|
+
const BYPASS_FLAG = /^(?:skipDangerousModePermissionPrompt|dangerouslySkipPermissions|dangerously[A-Z][A-Za-z]{0,60}|skip[A-Z][A-Za-z]{0,40}Permission[A-Za-z]{0,40})$/;
|
|
1783
|
+
function codexGrants(doc, table, at) {
|
|
1784
|
+
if (table.sandbox_mode !== 'danger-full-access')
|
|
1785
|
+
return [];
|
|
1786
|
+
const never = table.approval_policy === 'never';
|
|
1787
|
+
return [
|
|
1788
|
+
{
|
|
1789
|
+
...doc.where([...at, 'sandbox_mode']),
|
|
1790
|
+
severity: never ? 'high' : 'medium',
|
|
1791
|
+
message: never
|
|
1792
|
+
? "Codex runs with sandbox_mode 'danger-full-access' and approval_policy 'never': every command runs unsandboxed and without asking."
|
|
1793
|
+
: "Codex sandbox_mode is 'danger-full-access': commands run without the sandbox, limited only by approval prompts.",
|
|
1794
|
+
},
|
|
1795
|
+
];
|
|
1796
|
+
}
|
|
1797
|
+
const broadPermissions = {
|
|
1798
|
+
id: 'TM-PERM-001',
|
|
1799
|
+
title: 'Overly broad agent permissions',
|
|
1800
|
+
description: 'Agent settings pre-approve dangerous capabilities: bypassPermissions mode, unrestricted Bash, any URL fetch, every MCP tool, or sandbox-free execution. A single prompt injection then turns into code execution or data theft with no human in the loop.',
|
|
1801
|
+
category: 'excessive-permissions',
|
|
1802
|
+
severity: 'high',
|
|
1803
|
+
kinds: ['agent-settings', 'mcp-config'],
|
|
1804
|
+
remediation: 'Allow narrow, specific commands (Bash(git status:*), WebFetch(domain:docs.example.com)) and keep the default permission mode.',
|
|
1805
|
+
check(file) {
|
|
1806
|
+
const doc = loadDoc(file);
|
|
1807
|
+
const data = doc.data;
|
|
1808
|
+
if (!data)
|
|
1809
|
+
return [];
|
|
1810
|
+
const suffix = settingsScope(file) === 'project' ? PROJECT_SUFFIX : '';
|
|
1811
|
+
const out = [];
|
|
1812
|
+
const perms = isRecord(data.permissions) ? data.permissions : undefined;
|
|
1813
|
+
if (perms?.defaultMode === 'bypassPermissions') {
|
|
1814
|
+
out.push({
|
|
1815
|
+
...doc.where(['permissions', 'defaultMode']),
|
|
1816
|
+
severity: 'high',
|
|
1817
|
+
message: `permissions.defaultMode is 'bypassPermissions': every tool call, including shell commands and file writes, runs without asking${suffix}.`,
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1820
|
+
if (perms && Array.isArray(perms.allow)) {
|
|
1821
|
+
perms.allow.forEach((entry, i) => {
|
|
1822
|
+
if (typeof entry !== 'string')
|
|
1823
|
+
return;
|
|
1824
|
+
const grant = classifyGrant(entry);
|
|
1825
|
+
if (grant)
|
|
1826
|
+
out.push({ ...doc.where(['permissions', 'allow', i]), severity: grant.severity, message: `permissions.allow grants '${show(entry, 60)}', ${grant.effect}${suffix}.` });
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1829
|
+
const flagTables = [[[], data], [['permissions'], perms]];
|
|
1830
|
+
for (const [at, table] of flagTables) {
|
|
1831
|
+
for (const [key, value] of Object.entries(table ?? {})) {
|
|
1832
|
+
if (value === true && BYPASS_FLAG.test(key)) {
|
|
1833
|
+
out.push({ ...doc.where([...at, key]), severity: 'medium', message: `${show(key, 60)} is true, which skips the safety prompt for dangerous permission modes${suffix}.` });
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
for (const server of servers(doc)) {
|
|
1838
|
+
if (server.raw.trust === true) {
|
|
1839
|
+
out.push({ ...doc.where([...server.path, 'trust']), severity: 'medium', message: `${serverLabel(server)} is marked trust: true, so its tool calls run without confirmation.` });
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
if (doc.format === 'toml') {
|
|
1843
|
+
out.push(...codexGrants(doc, data, []));
|
|
1844
|
+
if (isRecord(data.profiles)) {
|
|
1845
|
+
for (const [name, profile] of Object.entries(data.profiles))
|
|
1846
|
+
if (isRecord(profile))
|
|
1847
|
+
out.push(...codexGrants(doc, profile, ['profiles', name]));
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
return out;
|
|
1851
|
+
},
|
|
1852
|
+
};
|
|
1853
|
+
const skillShell = {
|
|
1854
|
+
id: 'TM-PERM-002',
|
|
1855
|
+
title: 'Skill or command grants unrestricted shell',
|
|
1856
|
+
description: 'A skill, slash command or agent pre-approves unrestricted shell access in its allowed-tools frontmatter. While it is active the agent can run any command without asking, so instructions hidden in the skill or in content it reads go straight to your shell.',
|
|
1857
|
+
category: 'excessive-permissions',
|
|
1858
|
+
severity: 'medium',
|
|
1859
|
+
kinds: ['skill', 'instructions'],
|
|
1860
|
+
remediation: 'List only the commands the skill needs, for example "Bash(git status:*), Bash(npm test:*)".',
|
|
1861
|
+
check(file) {
|
|
1862
|
+
const fm = parseFrontmatter(file.content);
|
|
1863
|
+
if (!fm)
|
|
1864
|
+
return [];
|
|
1865
|
+
const key = ['allowed-tools', 'allowed_tools', 'allowedTools'].find((k) => fm.data[k] !== undefined);
|
|
1866
|
+
if (!key)
|
|
1867
|
+
return [];
|
|
1868
|
+
const line = fm.lines[key] ?? 1;
|
|
1869
|
+
const owner = ownerLabel(file, fm.data.name);
|
|
1870
|
+
const out = [];
|
|
1871
|
+
for (const tool of asList(fm.data[key]).slice(0, 100)) {
|
|
1872
|
+
const grant = classifyGrant(tool);
|
|
1873
|
+
if (!grant || /^Web|^mcp__/i.test(tool.trim()))
|
|
1874
|
+
continue;
|
|
1875
|
+
const severity = grant.severity === 'high' ? 'medium' : 'low';
|
|
1876
|
+
out.push({ line, severity, message: `${owner} pre-approves '${show(tool, 60)}' in ${key}, ${grant.effect} while it is active.` });
|
|
1877
|
+
}
|
|
1878
|
+
return out;
|
|
1879
|
+
},
|
|
1880
|
+
};
|
|
1881
|
+
function ownerLabel(file, name) {
|
|
1882
|
+
const p = normPath(file.displayPath);
|
|
1883
|
+
const label = typeof name === 'string' && name.trim() ? name.trim() : path.posix.basename(p).replace(/\.md$/i, '');
|
|
1884
|
+
const what = file.kind === 'skill' ? 'Skill' : /(?:^|\/)agents\//.test(p) ? 'Agent' : /(?:^|\/)commands\//.test(p) ? 'Command' : 'File';
|
|
1885
|
+
return `${what} '${show(label, 60)}'`;
|
|
1886
|
+
}
|
|
1887
|
+
const SENSITIVE_DIRS = /\/(?:\.ssh|\.aws|\.gnupg|\.kube|\.docker|\.config|\.azure|\.gcloud|\.password-store|library\/keychains)$/i;
|
|
1888
|
+
/** Why an additional directory is too broad, or undefined when it is fine. */
|
|
1889
|
+
function broadDirectory(dir) {
|
|
1890
|
+
const raw = dir.trim();
|
|
1891
|
+
if (!raw)
|
|
1892
|
+
return undefined;
|
|
1893
|
+
const p = normPath(expandHomeVars(raw));
|
|
1894
|
+
const h = home();
|
|
1895
|
+
if (p === '/' || /^[a-z]:\/?$/i.test(p))
|
|
1896
|
+
return 'the whole filesystem';
|
|
1897
|
+
if ((h.length > 1 && p === h) || /^(?:~|\$HOME|%USERPROFILE%)$/i.test(raw))
|
|
1898
|
+
return 'your entire home directory';
|
|
1899
|
+
if (/^\/(?:users|home)(?:\/[^/]{1,64})?$/i.test(p) || /^[a-z]:\/users(?:\/[^/]{1,64})?$/i.test(p))
|
|
1900
|
+
return 'a whole home directory';
|
|
1901
|
+
if (SENSITIVE_DIRS.test(p))
|
|
1902
|
+
return 'a directory that holds credentials';
|
|
1903
|
+
return undefined;
|
|
1904
|
+
}
|
|
1905
|
+
const outsideProject = {
|
|
1906
|
+
id: 'TM-PERM-003',
|
|
1907
|
+
title: 'Agent access outside the project',
|
|
1908
|
+
description: 'permissions.additionalDirectories extends the agent beyond the project to the home directory, a drive root or a credentials folder. Any prompt injection can then read SSH keys, cloud tokens and browser data that have nothing to do with the task.',
|
|
1909
|
+
category: 'excessive-permissions',
|
|
1910
|
+
severity: 'medium',
|
|
1911
|
+
kinds: ['agent-settings'],
|
|
1912
|
+
remediation: 'Add only the specific sibling directories the work needs.',
|
|
1913
|
+
check(file) {
|
|
1914
|
+
const doc = loadDoc(file);
|
|
1915
|
+
const permissions = doc.data?.permissions;
|
|
1916
|
+
const dirs = isRecord(permissions) ? permissions.additionalDirectories : undefined;
|
|
1917
|
+
if (!Array.isArray(dirs))
|
|
1918
|
+
return [];
|
|
1919
|
+
const suffix = settingsScope(file) === 'project' ? PROJECT_SUFFIX : '';
|
|
1920
|
+
const out = [];
|
|
1921
|
+
dirs.forEach((dir, i) => {
|
|
1922
|
+
if (typeof dir !== 'string')
|
|
1923
|
+
return;
|
|
1924
|
+
const why = broadDirectory(dir);
|
|
1925
|
+
if (why)
|
|
1926
|
+
out.push({ ...doc.where(['permissions', 'additionalDirectories', i]), message: `permissions.additionalDirectories grants the agent access to '${show(dir, 60)}', ${why}${suffix}.` });
|
|
1927
|
+
});
|
|
1928
|
+
return out;
|
|
1929
|
+
},
|
|
1930
|
+
};
|
|
1931
|
+
// ===========================================================================
|
|
1932
|
+
// Rules: hooks
|
|
1933
|
+
// ===========================================================================
|
|
1934
|
+
const hookInventory = {
|
|
1935
|
+
id: 'TM-HOOK-001',
|
|
1936
|
+
title: 'Hook inventory',
|
|
1937
|
+
description: 'Lists every hook command, plus the statusLine and helper commands (apiKeyHelper, awsAuthRefresh...) in Claude Code settings. These run automatically on agent events (every prompt, every tool call, session start) without asking each time, so you should know exactly what each one executes.',
|
|
1938
|
+
category: 'persistence',
|
|
1939
|
+
severity: 'info',
|
|
1940
|
+
kinds: HOOK_KINDS,
|
|
1941
|
+
check(file) {
|
|
1942
|
+
const doc = loadDoc(file);
|
|
1943
|
+
return hooks(doc).map((hook) => ({ ...doc.where(hook.path), message: `${hookLabel(hook)} runs: ${showCommand(hook.command)}` }));
|
|
1944
|
+
},
|
|
1945
|
+
};
|
|
1946
|
+
const dangerousHook = {
|
|
1947
|
+
id: 'TM-HOOK-002',
|
|
1948
|
+
title: 'Dangerous hook command',
|
|
1949
|
+
description: 'A hook command matches a dangerous-code pattern such as download-and-execute, decoding hidden payloads, credential access or destructive deletion. Hooks fire automatically, so this runs every time the event happens.',
|
|
1950
|
+
category: 'dangerous-execution',
|
|
1951
|
+
severity: 'high',
|
|
1952
|
+
kinds: HOOK_KINDS,
|
|
1953
|
+
remediation: 'Remove the hook, or replace it with a reviewed script that is part of the project or plugin.',
|
|
1954
|
+
check(file) {
|
|
1955
|
+
const doc = loadDoc(file);
|
|
1956
|
+
const out = [];
|
|
1957
|
+
hooks(doc).forEach((hook, i) => {
|
|
1958
|
+
for (const hit of analyzeScript(hook.command, file, `hook${i}`, '.sh')) {
|
|
1959
|
+
out.push({ ...doc.where(hook.path), severity: hit.severity, message: hitMessage(`${hookLabel(hook)} command`, hit) });
|
|
1960
|
+
}
|
|
1961
|
+
});
|
|
1962
|
+
return out;
|
|
1963
|
+
},
|
|
1964
|
+
};
|
|
1965
|
+
const NETWORK_TOOL = /(?:^|[\s;&|(`'"]|\$\()(curl|wget|Invoke-WebRequest|Invoke-RestMethod|iwr|irm|nc|ncat|netcat|socat|Start-BitsTransfer)(?=$|[\s;&|)`'"]|\.exe\b)/gi;
|
|
1966
|
+
const URL_IN_TEXT = /\bhttps?:\/\/[^\s'"`<>|;&()]{1,500}/gi;
|
|
1967
|
+
const hookNetwork = {
|
|
1968
|
+
id: 'TM-HOOK-003',
|
|
1969
|
+
title: 'Hook makes network requests',
|
|
1970
|
+
description: 'A hook command calls curl, wget, Invoke-WebRequest, nc or a similar tool. Hooks see prompts, tool inputs and outputs, so a hook that phones home can leak code, files and tokens on every run without any prompt.',
|
|
1971
|
+
category: 'exfiltration',
|
|
1972
|
+
severity: 'medium',
|
|
1973
|
+
kinds: HOOK_KINDS,
|
|
1974
|
+
remediation: 'Check where the hook sends data and what it includes; keep notifications local or send only fixed messages.',
|
|
1975
|
+
check(file) {
|
|
1976
|
+
const doc = loadDoc(file);
|
|
1977
|
+
const out = [];
|
|
1978
|
+
for (const hook of hooks(doc)) {
|
|
1979
|
+
const tools = [...new Set(findAll(NETWORK_TOOL, hook.command, 10).map((h) => (h.groups[0] ?? '').toLowerCase()))].filter(Boolean);
|
|
1980
|
+
if (tools.length === 0)
|
|
1981
|
+
continue;
|
|
1982
|
+
const hosts = [...new Set(findAll(URL_IN_TEXT, hook.command, 10).map((h) => parseUrl(h.text)?.hostname).filter((h) => Boolean(h)))];
|
|
1983
|
+
const localOnly = hosts.length > 0 && hosts.every((h) => hostClass(h) === 'loopback');
|
|
1984
|
+
const target = hosts.length > 0 ? ` to ${hosts.slice(0, 3).map((h) => show(h, 60)).join(', ')}` : '';
|
|
1985
|
+
out.push({
|
|
1986
|
+
...doc.where(hook.path),
|
|
1987
|
+
severity: localOnly ? 'low' : 'medium',
|
|
1988
|
+
message: `${hookLabel(hook)} makes network requests with ${tools.join(', ')}${target}; a hook that phones home can leak prompts, files or tokens on every run.`,
|
|
1989
|
+
});
|
|
1990
|
+
}
|
|
1991
|
+
return out;
|
|
1992
|
+
},
|
|
1993
|
+
};
|
|
1994
|
+
const SCRIPT_URL_EXT = /\.(?:sh|bash|zsh|py|js|mjs|cjs|ts|ps1|psm1|rb|pl|bat|cmd)$/i;
|
|
1995
|
+
const RUNS_FETCHED = /\|\s{0,10}(?:sudo\s{1,10})?(?:ba|z|da|k)?sh\b|\|\s{0,10}(?:python3?|node|perl|ruby|iex|Invoke-Expression)\b|<\(|\$\(\s{0,10}(?:curl|wget)|\bdeno\s{1,10}run\b|\b(?:iex|Invoke-Expression)\b/i;
|
|
1996
|
+
const externalHookScript = {
|
|
1997
|
+
id: 'TM-HOOK-004',
|
|
1998
|
+
title: 'Hook runs a script outside the project or plugin',
|
|
1999
|
+
description: 'A hook executes a script by absolute path outside the scanned project or plugin, or fetches one from a URL. That code is not covered by this scan or by code review, and can change without the hook changing.',
|
|
2000
|
+
category: 'dangerous-execution',
|
|
2001
|
+
severity: 'low',
|
|
2002
|
+
kinds: HOOK_KINDS,
|
|
2003
|
+
remediation: 'Keep hook scripts inside the project or plugin (for example ${CLAUDE_PLUGIN_ROOT}/hooks/...) and scan them too.',
|
|
2004
|
+
check(file) {
|
|
2005
|
+
const doc = loadDoc(file);
|
|
2006
|
+
const roots = knownRoots(file);
|
|
2007
|
+
const out = [];
|
|
2008
|
+
for (const hook of hooks(doc)) {
|
|
2009
|
+
const where = doc.where(hook.path);
|
|
2010
|
+
for (const line of hookWords(hook)) {
|
|
2011
|
+
for (const tok of executedWords(line)) {
|
|
2012
|
+
if (scratchFolder(tok.text))
|
|
2013
|
+
continue;
|
|
2014
|
+
const outside = outsidePath(tok.text, roots);
|
|
2015
|
+
if (outside)
|
|
2016
|
+
out.push({ ...where, message: `${hookLabel(hook)} runs ${show(tok.text)}, which is outside the scanned project or plugin; that script is not covered by this scan.` });
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
if (RUNS_FETCHED.test(hook.command)) {
|
|
2020
|
+
for (const h of findAll(URL_IN_TEXT, hook.command, 5)) {
|
|
2021
|
+
const url = parseUrl(h.text);
|
|
2022
|
+
if (url && SCRIPT_URL_EXT.test(url.pathname)) {
|
|
2023
|
+
out.push({ ...where, message: `${hookLabel(hook)} fetches and runs the remote script ${show(shownUrl(url), 100)}; whatever is served there runs on every trigger.` });
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
return out;
|
|
2029
|
+
},
|
|
2030
|
+
};
|
|
2031
|
+
const MAX_MASK_LINE = 100_000;
|
|
2032
|
+
const MAX_MASKS_PER_PATTERN = 1000;
|
|
2033
|
+
const SNIPPET_WIDTH = 150;
|
|
2034
|
+
const URL_USERINFO = /\/\/([^/@\s"'<>]{1,200})@/g;
|
|
2035
|
+
const QUERY_PARAM = /[?&]([A-Za-z0-9_.-]{1,60})=([^&\s"'#<>]{1,2000})/g;
|
|
2036
|
+
const JSON_PAIR = /"([A-Za-z_][\w.-]{0,100})"\s{0,5}:\s{0,5}"([^"\\\n]{1,4096})"/g;
|
|
2037
|
+
const TOML_BASIC_PAIR = /(?:^|[\s{,])([A-Za-z_][\w-]{0,100})\s{0,5}=\s{0,5}"([^"\\\n]{1,4096})"/g;
|
|
2038
|
+
const TOML_LITERAL_PAIR = /(?:^|[\s{,])([A-Za-z_][\w-]{0,100})\s{0,5}=\s{0,5}'([^'\n]{1,4096})'/g;
|
|
2039
|
+
const FLAG_PAIR = /--?([A-Za-z][\w-]{0,60})=([^\s"',&]{1,4096})/g;
|
|
2040
|
+
/** One-entry memo: the rules of this module report on the same file in turn, often on the same lines. */
|
|
2041
|
+
let maskMemo;
|
|
2042
|
+
function isLiteralCredential(key, value) {
|
|
2043
|
+
return isSecretName(key) && looksLikeLiteralSecret(stripAuthScheme(value));
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* Offsets of credential-looking values on one line: URL passwords and
|
|
2047
|
+
* query-string credentials, and literals under secret-like keys in JSON,
|
|
2048
|
+
* TOML and --flag=value form. The generic secret patterns redact known token
|
|
2049
|
+
* formats; these cover the rest, which config files are full of.
|
|
2050
|
+
*/
|
|
2051
|
+
function secretRanges(content, start, end) {
|
|
2052
|
+
const line = content.slice(start, Math.min(end, start + MAX_MASK_LINE));
|
|
2053
|
+
const out = [];
|
|
2054
|
+
/** Masks the value captured last in the match, which always ends `tail` characters before the match ends. */
|
|
2055
|
+
const add = (hit, value, tail) => {
|
|
2056
|
+
const valueEnd = hit.index + hit.text.length - tail;
|
|
2057
|
+
out.push([start + valueEnd - value.length, start + valueEnd]);
|
|
2058
|
+
};
|
|
2059
|
+
for (const hit of findAll(URL_USERINFO, line, MAX_MASKS_PER_PATTERN)) {
|
|
2060
|
+
const userinfo = hit.groups[0] ?? '';
|
|
2061
|
+
const colon = userinfo.indexOf(':');
|
|
2062
|
+
if (colon !== -1 && colon < userinfo.length - 1)
|
|
2063
|
+
add(hit, userinfo.slice(colon + 1), 1);
|
|
2064
|
+
else if (userinfo.length >= 20 && !isPlaceholder(userinfo))
|
|
2065
|
+
add(hit, userinfo, 1);
|
|
2066
|
+
}
|
|
2067
|
+
for (const hit of findAll(QUERY_PARAM, line, MAX_MASKS_PER_PATTERN)) {
|
|
2068
|
+
const value = hit.groups[1] ?? '';
|
|
2069
|
+
if (isCredentialParam(hit.groups[0] ?? '') && !isPlaceholder(value))
|
|
2070
|
+
add(hit, value, 0);
|
|
2071
|
+
}
|
|
2072
|
+
for (const [regex, tail] of [[JSON_PAIR, 1], [TOML_BASIC_PAIR, 1], [TOML_LITERAL_PAIR, 1], [FLAG_PAIR, 0]]) {
|
|
2073
|
+
for (const hit of findAll(regex, line, MAX_MASKS_PER_PATTERN)) {
|
|
2074
|
+
const value = hit.groups[1] ?? '';
|
|
2075
|
+
if (isLiteralCredential(hit.groups[0] ?? '', value))
|
|
2076
|
+
add(hit, value, tail);
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
return mergeRanges(out);
|
|
2080
|
+
}
|
|
2081
|
+
function mergeRanges(ranges) {
|
|
2082
|
+
ranges.sort((a, b) => a[0] - b[0]);
|
|
2083
|
+
const out = [];
|
|
2084
|
+
for (const [s, e] of ranges) {
|
|
2085
|
+
const last = out[out.length - 1];
|
|
2086
|
+
if (last && s <= last[1])
|
|
2087
|
+
last[1] = Math.max(last[1], e);
|
|
2088
|
+
else
|
|
2089
|
+
out.push([s, e]);
|
|
2090
|
+
}
|
|
2091
|
+
return out;
|
|
2092
|
+
}
|
|
2093
|
+
function lineMasks(content, lineStart, lineEnd) {
|
|
2094
|
+
if (maskMemo?.content !== content)
|
|
2095
|
+
maskMemo = { content, lines: new Map() };
|
|
2096
|
+
let ranges = maskMemo.lines.get(lineStart);
|
|
2097
|
+
if (!ranges) {
|
|
2098
|
+
ranges = secretRanges(content, lineStart, lineEnd);
|
|
2099
|
+
maskMemo.lines.set(lineStart, ranges);
|
|
2100
|
+
}
|
|
2101
|
+
return ranges;
|
|
2102
|
+
}
|
|
2103
|
+
/**
|
|
2104
|
+
* Gives a match an explicit snippet with credentials masked when its line
|
|
2105
|
+
* holds any. The snippet is a window around the match, built from the raw
|
|
2106
|
+
* line, so a value cut by the window edge is still masked.
|
|
2107
|
+
*/
|
|
2108
|
+
function maskSnippet(content, match) {
|
|
2109
|
+
if (match.snippet !== undefined)
|
|
2110
|
+
return match;
|
|
2111
|
+
let at;
|
|
2112
|
+
if (match.index !== undefined)
|
|
2113
|
+
at = Math.max(0, Math.min(match.index, content.length));
|
|
2114
|
+
else if (match.line !== undefined)
|
|
2115
|
+
at = lineStartIndex(content, match.line);
|
|
2116
|
+
else
|
|
2117
|
+
return match;
|
|
2118
|
+
if (at < 0)
|
|
2119
|
+
return match;
|
|
2120
|
+
const lineStart = content.lastIndexOf('\n', at - 1) + 1;
|
|
2121
|
+
const newline = content.indexOf('\n', at);
|
|
2122
|
+
const lineEnd = newline === -1 ? content.length : content[newline - 1] === '\r' ? newline - 1 : newline;
|
|
2123
|
+
const ranges = lineMasks(content, lineStart, lineEnd);
|
|
2124
|
+
if (ranges.length === 0)
|
|
2125
|
+
return match;
|
|
2126
|
+
const width = lineEnd - lineStart;
|
|
2127
|
+
const from = width <= SNIPPET_WIDTH ? lineStart : lineStart + Math.max(0, Math.min(at - lineStart - 40, width - SNIPPET_WIDTH));
|
|
2128
|
+
const to = Math.min(lineEnd, from + SNIPPET_WIDTH);
|
|
2129
|
+
let snippet = from > lineStart ? '…' : '';
|
|
2130
|
+
let cursor = from;
|
|
2131
|
+
for (const [s, e] of ranges) {
|
|
2132
|
+
if (e <= cursor || s >= to)
|
|
2133
|
+
continue;
|
|
2134
|
+
snippet += `${content.slice(cursor, Math.max(cursor, s))}****`;
|
|
2135
|
+
cursor = Math.min(to, e);
|
|
2136
|
+
}
|
|
2137
|
+
snippet += content.slice(cursor, to) + (to < lineEnd ? '…' : '');
|
|
2138
|
+
return { ...match, snippet };
|
|
2139
|
+
}
|
|
2140
|
+
/** Wraps a rule so every match it returns passes through maskSnippet. */
|
|
2141
|
+
function withMaskedSnippets(rule) {
|
|
2142
|
+
const check = rule.check;
|
|
2143
|
+
return { ...rule, check: (file) => check(file).map((match) => maskSnippet(file.content, match)) };
|
|
2144
|
+
}
|
|
2145
|
+
/** Structure-aware rules for MCP configs, agent settings, hooks and manifests (TM-SUP-*, TM-PERM-*, TM-HOOK-*, TM-NET-*, TM-CFG-*). */
|
|
2146
|
+
export const configRules = [
|
|
2147
|
+
unpinnedRunner,
|
|
2148
|
+
installScript,
|
|
2149
|
+
dependencySource,
|
|
2150
|
+
scratchBinary,
|
|
2151
|
+
plainHttp,
|
|
2152
|
+
urlCredentials,
|
|
2153
|
+
tunnelEndpoint,
|
|
2154
|
+
plaintextSecret,
|
|
2155
|
+
inlineShell,
|
|
2156
|
+
autoEnableServers,
|
|
2157
|
+
broadPermissions,
|
|
2158
|
+
skillShell,
|
|
2159
|
+
outsideProject,
|
|
2160
|
+
hookInventory,
|
|
2161
|
+
dangerousHook,
|
|
2162
|
+
hookNetwork,
|
|
2163
|
+
externalHookScript,
|
|
2164
|
+
].map(withMaskedSnippets);
|