vigile-scan 0.2.3 → 0.2.5

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/dist/index.js CHANGED
@@ -6,6 +6,9 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
6
  var __getOwnPropNames = Object.getOwnPropertyNames;
7
7
  var __getProtoOf = Object.getPrototypeOf;
8
8
  var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __commonJS = (cb, mod) => function __require() {
10
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
11
+ };
9
12
  var __copyProps = (to, from, except, desc) => {
10
13
  if (from && typeof from === "object" || typeof from === "function") {
11
14
  for (let key of __getOwnPropNames(from))
@@ -23,11 +26,88 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
26
  mod
24
27
  ));
25
28
 
29
+ // package.json
30
+ var require_package = __commonJS({
31
+ "package.json"(exports2, module2) {
32
+ module2.exports = {
33
+ name: "vigile-scan",
34
+ version: "0.2.5",
35
+ description: "Security scanner for AI agent tools \u2014 detect tool poisoning, permission abuse, and supply chain attacks in MCP servers and agent skills",
36
+ main: "dist/index.js",
37
+ bin: {
38
+ "vigile-scan": "dist/index.js",
39
+ vigile: "dist/index.js"
40
+ },
41
+ files: [
42
+ "dist",
43
+ "README.md",
44
+ "LICENSE"
45
+ ],
46
+ scripts: {
47
+ build: "tsup && chmod +x dist/index.js",
48
+ dev: "tsup --watch",
49
+ start: "node dist/index.js",
50
+ lint: "eslint src/",
51
+ typecheck: "tsc --noEmit",
52
+ test: "vitest run",
53
+ "test:watch": "vitest",
54
+ "test:coverage": "vitest run --coverage",
55
+ prepublishOnly: "npm run build"
56
+ },
57
+ keywords: [
58
+ "mcp",
59
+ "security",
60
+ "ai-agent",
61
+ "scanner",
62
+ "trust",
63
+ "model-context-protocol",
64
+ "tool-poisoning",
65
+ "ai-security",
66
+ "agent-skills",
67
+ "skill-scanning",
68
+ "prompt-injection",
69
+ "data-exfiltration",
70
+ "claude",
71
+ "cursor",
72
+ "copilot"
73
+ ],
74
+ author: "Vigile AI Security",
75
+ license: "Apache-2.0",
76
+ homepage: "https://vigile.dev",
77
+ repository: {
78
+ type: "git",
79
+ url: "git+https://github.com/Vigile-ai/vigile-cli.git"
80
+ },
81
+ bugs: {
82
+ url: "https://github.com/Vigile-ai/vigile-cli/issues"
83
+ },
84
+ publishConfig: {
85
+ access: "public"
86
+ },
87
+ engines: {
88
+ node: ">=18.0.0"
89
+ },
90
+ dependencies: {
91
+ chalk: "^5.3.0",
92
+ commander: "^12.1.0",
93
+ glob: "^11.0.0",
94
+ ora: "^8.1.0"
95
+ },
96
+ devDependencies: {
97
+ "@types/node": "^20.11.0",
98
+ tsup: "^8.0.0",
99
+ typescript: "^5.4.0",
100
+ vitest: "^2.0.0"
101
+ }
102
+ };
103
+ }
104
+ });
105
+
26
106
  // src/index.ts
27
107
  var import_commander = require("commander");
28
108
  var import_chalk2 = __toESM(require("chalk"));
29
109
  var import_ora = __toESM(require("ora"));
30
- var import_promises4 = require("fs/promises");
110
+ var import_promises5 = require("fs/promises");
31
111
 
32
112
  // src/discovery/claude-desktop.ts
33
113
  var import_path2 = require("path");
@@ -53,7 +133,7 @@ async function parseMCPConfig(configPath, source) {
53
133
  try {
54
134
  const raw = await (0, import_promises.readFile)(configPath, "utf-8");
55
135
  const config = JSON.parse(raw);
56
- const servers = config.mcpServers || config;
136
+ const servers = config.mcpServers || config.servers || config;
57
137
  if (typeof servers !== "object" || servers === null) {
58
138
  return [];
59
139
  }
@@ -118,13 +198,63 @@ async function discoverCursor() {
118
198
 
119
199
  // src/discovery/claude-code.ts
120
200
  var import_path4 = require("path");
201
+ var import_fs2 = require("fs");
121
202
  async function discoverClaudeCode() {
122
203
  const home = getHome();
123
- const paths = [
204
+ const allServers = [];
205
+ const traditionalPaths = [
124
206
  (0, import_path4.join)(home, ".claude.json"),
125
207
  (0, import_path4.join)(process.cwd(), ".mcp.json")
126
208
  ];
127
- return tryConfigPaths(paths, "claude-code");
209
+ allServers.push(...await tryConfigPaths(traditionalPaths, "claude-code"));
210
+ const pluginCacheDir = (0, import_path4.join)(home, ".claude", "plugins", "cache", "claude-plugins-official");
211
+ if ((0, import_fs2.existsSync)(pluginCacheDir)) {
212
+ allServers.push(...await discoverPluginMCPConfigs(pluginCacheDir));
213
+ }
214
+ return allServers;
215
+ }
216
+ async function discoverPluginMCPConfigs(cacheDir) {
217
+ const servers = [];
218
+ const seen = /* @__PURE__ */ new Set();
219
+ let pluginDirs;
220
+ try {
221
+ pluginDirs = (0, import_fs2.readdirSync)(cacheDir, { withFileTypes: true });
222
+ } catch {
223
+ return [];
224
+ }
225
+ for (const pluginEntry of pluginDirs) {
226
+ if (!pluginEntry.isDirectory()) continue;
227
+ const pluginDir = (0, import_path4.join)(cacheDir, pluginEntry.name);
228
+ let versionDirs;
229
+ try {
230
+ versionDirs = (0, import_fs2.readdirSync)(pluginDir, { withFileTypes: true });
231
+ } catch {
232
+ continue;
233
+ }
234
+ for (const versionEntry of versionDirs) {
235
+ if (!versionEntry.isDirectory()) continue;
236
+ const versionDir = (0, import_path4.join)(pluginDir, versionEntry.name);
237
+ const candidates = [(0, import_path4.join)(versionDir, ".mcp.json")];
238
+ try {
239
+ for (const sub of (0, import_fs2.readdirSync)(versionDir, { withFileTypes: true })) {
240
+ if (sub.isDirectory()) {
241
+ candidates.push((0, import_path4.join)(versionDir, sub.name, ".mcp.json"));
242
+ }
243
+ }
244
+ } catch {
245
+ }
246
+ for (const candidate of candidates) {
247
+ const found = await parseMCPConfig(candidate, "claude-code");
248
+ for (const server of found) {
249
+ if (!seen.has(server.name)) {
250
+ seen.add(server.name);
251
+ servers.push(server);
252
+ }
253
+ }
254
+ }
255
+ }
256
+ }
257
+ return servers;
128
258
  }
129
259
 
130
260
  // src/discovery/windsurf.ts
@@ -146,10 +276,67 @@ async function discoverVSCode() {
146
276
  return tryConfigPaths(paths, "vscode");
147
277
  }
148
278
 
149
- // src/discovery/skills.ts
150
- var import_promises2 = require("fs/promises");
151
- var import_fs2 = require("fs");
279
+ // src/discovery/openclaw.ts
152
280
  var import_path7 = require("path");
281
+ var import_promises2 = require("fs/promises");
282
+ var import_fs3 = require("fs");
283
+ async function discoverOpenClaw() {
284
+ const home = getHome();
285
+ const allServers = [];
286
+ const seen = /* @__PURE__ */ new Set();
287
+ const configPaths = [
288
+ (0, import_path7.join)(home, ".openclaw", "openclaw.json"),
289
+ (0, import_path7.join)(process.cwd(), "openclaw.config.json")
290
+ ];
291
+ for (const server of await tryConfigPaths(configPaths, "openclaw")) {
292
+ if (!seen.has(server.name)) {
293
+ seen.add(server.name);
294
+ allServers.push(server);
295
+ }
296
+ }
297
+ const globalConfig = (0, import_path7.join)(home, ".openclaw", "openclaw.json");
298
+ if ((0, import_fs3.existsSync)(globalConfig)) {
299
+ for (const server of await parseAgentMCPConfigs(globalConfig)) {
300
+ if (!seen.has(server.name)) {
301
+ seen.add(server.name);
302
+ allServers.push(server);
303
+ }
304
+ }
305
+ }
306
+ return allServers;
307
+ }
308
+ async function parseAgentMCPConfigs(configPath) {
309
+ try {
310
+ const raw = await (0, import_promises2.readFile)(configPath, "utf-8");
311
+ const config = JSON.parse(raw);
312
+ const agents = config?.agents?.list;
313
+ if (!Array.isArray(agents)) return [];
314
+ const servers = [];
315
+ for (const agent of agents) {
316
+ const mcpServers = agent?.mcp?.servers;
317
+ if (!Array.isArray(mcpServers)) continue;
318
+ for (const server of mcpServers) {
319
+ if (!server.name || !server.command && !server.url) continue;
320
+ servers.push({
321
+ name: server.name,
322
+ source: "openclaw",
323
+ command: server.command || "",
324
+ args: Array.isArray(server.args) ? server.args : [],
325
+ env: server.env,
326
+ configPath
327
+ });
328
+ }
329
+ }
330
+ return servers;
331
+ } catch {
332
+ return [];
333
+ }
334
+ }
335
+
336
+ // src/discovery/skills.ts
337
+ var import_promises3 = require("fs/promises");
338
+ var import_fs4 = require("fs");
339
+ var import_path8 = require("path");
153
340
  var import_glob = require("glob");
154
341
  async function discoverAllSkills() {
155
342
  const skills = [];
@@ -183,8 +370,8 @@ async function discoverClaudeCodeSkills() {
183
370
  const home = getHome();
184
371
  const skills = [];
185
372
  const projectPatterns = [
186
- (0, import_path7.join)(process.cwd(), ".claude", "skills", "*", "SKILL.md"),
187
- (0, import_path7.join)(process.cwd(), ".claude", "commands", "**", "*.md")
373
+ (0, import_path8.join)(process.cwd(), ".claude", "skills", "*", "SKILL.md"),
374
+ (0, import_path8.join)(process.cwd(), ".claude", "commands", "**", "*.md")
188
375
  ];
189
376
  for (const pattern of projectPatterns) {
190
377
  const files = await (0, import_glob.glob)(pattern, { absolute: true });
@@ -194,8 +381,8 @@ async function discoverClaudeCodeSkills() {
194
381
  }
195
382
  }
196
383
  const globalPatterns = [
197
- (0, import_path7.join)(home, ".claude", "skills", "*", "SKILL.md"),
198
- (0, import_path7.join)(home, ".claude", "commands", "**", "*.md")
384
+ (0, import_path8.join)(home, ".claude", "skills", "*", "SKILL.md"),
385
+ (0, import_path8.join)(home, ".claude", "commands", "**", "*.md")
199
386
  ];
200
387
  for (const pattern of globalPatterns) {
201
388
  const files = await (0, import_glob.glob)(pattern, { absolute: true });
@@ -209,8 +396,8 @@ async function discoverClaudeCodeSkills() {
209
396
  async function discoverGitHubCopilotSkills() {
210
397
  const skills = [];
211
398
  const patterns = [
212
- (0, import_path7.join)(process.cwd(), ".github", "skills", "*", "SKILL.md"),
213
- (0, import_path7.join)(process.cwd(), ".github", "copilot", "**", "*.md")
399
+ (0, import_path8.join)(process.cwd(), ".github", "skills", "*", "SKILL.md"),
400
+ (0, import_path8.join)(process.cwd(), ".github", "copilot", "**", "*.md")
214
401
  ];
215
402
  for (const pattern of patterns) {
216
403
  const files = await (0, import_glob.glob)(pattern, { absolute: true });
@@ -224,18 +411,18 @@ async function discoverGitHubCopilotSkills() {
224
411
  async function discoverCursorRules() {
225
412
  const home = getHome();
226
413
  const skills = [];
227
- const projectPattern = (0, import_path7.join)(process.cwd(), ".cursor", "rules", "*.mdc");
414
+ const projectPattern = (0, import_path8.join)(process.cwd(), ".cursor", "rules", "*.mdc");
228
415
  const projectFiles = await (0, import_glob.glob)(projectPattern, { absolute: true });
229
416
  for (const filePath of projectFiles) {
230
417
  const entry = await readSkillFile(filePath, "cursor", "mdc-rule", "project");
231
418
  if (entry) skills.push(entry);
232
419
  }
233
- const legacyPath = (0, import_path7.join)(process.cwd(), ".cursorrules");
234
- if ((0, import_fs2.existsSync)(legacyPath)) {
420
+ const legacyPath = (0, import_path8.join)(process.cwd(), ".cursorrules");
421
+ if ((0, import_fs4.existsSync)(legacyPath)) {
235
422
  const entry = await readSkillFile(legacyPath, "cursor", "mdc-rule", "project");
236
423
  if (entry) skills.push(entry);
237
424
  }
238
- const globalPattern = (0, import_path7.join)(home, ".cursor", "rules", "*.mdc");
425
+ const globalPattern = (0, import_path8.join)(home, ".cursor", "rules", "*.mdc");
239
426
  const globalFiles = await (0, import_glob.glob)(globalPattern, { absolute: true });
240
427
  for (const filePath of globalFiles) {
241
428
  const entry = await readSkillFile(filePath, "cursor", "mdc-rule", "global");
@@ -249,16 +436,21 @@ async function discoverMemoryFiles() {
249
436
  const cwd = process.cwd();
250
437
  const memoryFiles = [
251
438
  // Project-level memory files
252
- { path: (0, import_path7.join)(cwd, "CLAUDE.md"), fileType: "claude.md", scope: "project" },
253
- { path: (0, import_path7.join)(cwd, ".claude", "CLAUDE.md"), fileType: "claude.md", scope: "project" },
254
- { path: (0, import_path7.join)(cwd, "SOUL.md"), fileType: "soul.md", scope: "project" },
255
- { path: (0, import_path7.join)(cwd, "MEMORY.md"), fileType: "memory.md", scope: "project" },
439
+ { path: (0, import_path8.join)(cwd, "CLAUDE.md"), fileType: "claude.md", scope: "project" },
440
+ { path: (0, import_path8.join)(cwd, ".claude", "CLAUDE.md"), fileType: "claude.md", scope: "project" },
441
+ { path: (0, import_path8.join)(cwd, "SOUL.md"), fileType: "soul.md", scope: "project" },
442
+ { path: (0, import_path8.join)(cwd, "MEMORY.md"), fileType: "memory.md", scope: "project" },
443
+ // Additional project instruction files
444
+ { path: (0, import_path8.join)(cwd, ".github", "copilot-instructions.md"), fileType: "claude.md", scope: "project" },
445
+ { path: (0, import_path8.join)(cwd, "AGENTS.md"), fileType: "claude.md", scope: "project" },
446
+ { path: (0, import_path8.join)(cwd, ".windsurfrules"), fileType: "claude.md", scope: "project" },
447
+ { path: (0, import_path8.join)(cwd, "GEMINI.md"), fileType: "claude.md", scope: "project" },
256
448
  // Global memory files
257
- { path: (0, import_path7.join)(home, ".claude", "CLAUDE.md"), fileType: "claude.md", scope: "global" },
258
- { path: (0, import_path7.join)(home, "CLAUDE.md"), fileType: "claude.md", scope: "global" }
449
+ { path: (0, import_path8.join)(home, ".claude", "CLAUDE.md"), fileType: "claude.md", scope: "global" },
450
+ { path: (0, import_path8.join)(home, "CLAUDE.md"), fileType: "claude.md", scope: "global" }
259
451
  ];
260
452
  for (const { path, fileType, scope } of memoryFiles) {
261
- if ((0, import_fs2.existsSync)(path)) {
453
+ if ((0, import_fs4.existsSync)(path)) {
262
454
  const entry = await readSkillFile(path, "memory-file", fileType, scope);
263
455
  if (entry) skills.push(entry);
264
456
  }
@@ -267,8 +459,8 @@ async function discoverMemoryFiles() {
267
459
  }
268
460
  async function readSkillFile(filePath, source, fileType, scope) {
269
461
  try {
270
- const content = await (0, import_promises2.readFile)(filePath, "utf-8");
271
- const fileStat = await (0, import_promises2.stat)(filePath);
462
+ const content = await (0, import_promises3.readFile)(filePath, "utf-8");
463
+ const fileStat = await (0, import_promises3.stat)(filePath);
272
464
  const name = deriveSkillName(filePath, fileType);
273
465
  return {
274
466
  name,
@@ -286,11 +478,11 @@ async function readSkillFile(filePath, source, fileType, scope) {
286
478
  function deriveSkillName(filePath, fileType) {
287
479
  switch (fileType) {
288
480
  case "skill.md": {
289
- const parentDir = (0, import_path7.basename)((0, import_path7.dirname)(filePath));
290
- return parentDir === "skills" ? (0, import_path7.basename)(filePath, ".md") : parentDir;
481
+ const parentDir = (0, import_path8.basename)((0, import_path8.dirname)(filePath));
482
+ return parentDir === "skills" ? (0, import_path8.basename)(filePath, ".md") : parentDir;
291
483
  }
292
484
  case "mdc-rule": {
293
- const name = (0, import_path7.basename)(filePath);
485
+ const name = (0, import_path8.basename)(filePath);
294
486
  return name.replace(/\.(mdc|cursorrules?)$/, "") || name;
295
487
  }
296
488
  case "claude.md":
@@ -300,7 +492,7 @@ function deriveSkillName(filePath, fileType) {
300
492
  case "memory.md":
301
493
  return "MEMORY.md";
302
494
  default:
303
- return (0, import_path7.basename)(filePath);
495
+ return (0, import_path8.basename)(filePath);
304
496
  }
305
497
  }
306
498
 
@@ -311,7 +503,8 @@ async function discoverAllServers(clientFilter) {
311
503
  { client: "cursor", fn: discoverCursor },
312
504
  { client: "claude-code", fn: discoverClaudeCode },
313
505
  { client: "windsurf", fn: discoverWindsurf },
314
- { client: "vscode", fn: discoverVSCode }
506
+ { client: "vscode", fn: discoverVSCode },
507
+ { client: "openclaw", fn: discoverOpenClaw }
315
508
  ];
316
509
  const toRun = clientFilter ? discoverers.filter((d) => d.client === clientFilter) : discoverers;
317
510
  const servers = [];
@@ -359,15 +552,10 @@ var TOOL_POISONING_PATTERNS = [
359
552
  description: "Tool description instructs the AI agent to hide information from the user \u2014 a hallmark of tool poisoning.",
360
553
  recommendation: "Do NOT install this MCP server. Legitimate tools never instruct agents to hide actions."
361
554
  },
362
- {
363
- id: "TP-003",
364
- category: "tool-poisoning",
365
- severity: "critical",
366
- title: "System prompt override attempt",
367
- pattern: /you\s+are\s+(now\s+)?(a|an|acting\s+as)/i,
368
- description: "Tool description attempts to redefine the AI agent's identity or role.",
369
- recommendation: "Remove this MCP server. Tool descriptions should not redefine agent behavior."
370
- },
555
+ // TP-003 removed: "you are a/an [role]" is too broad — matches legitimate
556
+ // role descriptions in skills (e.g., "You are a computer science professor").
557
+ // SK-001 covers the real threat (malicious persona hijacking) with proper
558
+ // specificity by requiring suspicious role terms like "hacker" or "jailbroken".
371
559
  {
372
560
  id: "TP-004",
373
561
  category: "tool-poisoning",
@@ -1313,6 +1501,7 @@ function deduplicateFindings2(findings) {
1313
1501
 
1314
1502
  // src/sentinel/sentinel.ts
1315
1503
  var import_child_process = require("child_process");
1504
+ var import_dns = require("dns");
1316
1505
 
1317
1506
  // src/sentinel/sentinel-patterns.ts
1318
1507
  var SUSPICIOUS_ENDPOINT_PATTERNS = [
@@ -1580,6 +1769,8 @@ var SentinelEngine = class {
1580
1769
  serverName;
1581
1770
  durationSeconds;
1582
1771
  onEvent;
1772
+ /** DNS reverse-lookup cache: IP → hostname. Persists for the session. */
1773
+ dnsCache = /* @__PURE__ */ new Map();
1583
1774
  constructor(options) {
1584
1775
  this.serverName = options.serverName;
1585
1776
  this.durationSeconds = options.durationSeconds || 120;
@@ -1588,11 +1779,15 @@ var SentinelEngine = class {
1588
1779
  /**
1589
1780
  * Start monitoring network activity for the given MCP server.
1590
1781
  *
1591
- * The monitor works in three modes depending on the OS and permissions:
1592
- * 1. macOS: Uses `nettop` or `networksetup` + `tcpdump`
1593
- * 2. Linux: Uses `ss` polling + optional `tcpdump`
1594
- * 3. Fallback: Uses Node.js HTTP/HTTPS module monkey-patching
1595
- * (only captures Node.js HTTP requests from the current process tree)
1782
+ * The monitor works in four modes depending on the OS and permissions:
1783
+ * 1. macOS: Uses `lsof -i` polling (no root required)
1784
+ * 2. Linux: Uses `ss -tnp` polling (no root required)
1785
+ * 3. Windows: Uses PowerShell `Get-NetTCPConnection` (no admin required)
1786
+ * 4. Fallback: Stub events must be fed manually via ingestEvent()
1787
+ *
1788
+ * All modes perform async reverse-DNS enrichment so that endpoint patterns
1789
+ * (pastebin.com, ngrok.io, etc.) match against resolved hostnames rather
1790
+ * than raw IPs.
1596
1791
  */
1597
1792
  async startMonitoring() {
1598
1793
  this.startTime = Date.now();
@@ -1606,6 +1801,9 @@ var SentinelEngine = class {
1606
1801
  case "ss-poll":
1607
1802
  await this.startSsPolling();
1608
1803
  break;
1804
+ case "netstat-poll":
1805
+ await this.startNetstatPolling();
1806
+ break;
1609
1807
  case "proxy":
1610
1808
  await this.startProxyCapture();
1611
1809
  break;
@@ -1734,66 +1932,103 @@ var SentinelEngine = class {
1734
1932
  }
1735
1933
  // ── Monitoring Methods ──
1736
1934
  detectMonitoringMethod() {
1737
- try {
1738
- (0, import_child_process.execSync)("which lsof", { stdio: "pipe" });
1935
+ if (process.platform === "win32") {
1936
+ return "netstat-poll";
1937
+ }
1938
+ if (process.platform === "darwin") {
1739
1939
  return "lsof-poll";
1940
+ }
1941
+ try {
1942
+ (0, import_child_process.execFileSync)("which", ["ss"], { stdio: "pipe" });
1943
+ return "ss-poll";
1740
1944
  } catch {
1741
1945
  try {
1742
- (0, import_child_process.execSync)("which ss", { stdio: "pipe" });
1743
- return "ss-poll";
1946
+ (0, import_child_process.execFileSync)("which", ["lsof"], { stdio: "pipe" });
1947
+ return "lsof-poll";
1744
1948
  } catch {
1745
1949
  return "proxy";
1746
1950
  }
1747
1951
  }
1748
1952
  }
1749
1953
  /**
1750
- * macOS/Linux: Poll `lsof` to capture network connections for a process.
1751
- * This is the lowest-privilege method no root needed.
1954
+ * macOS: Poll `lsof -i` to capture network connections.
1955
+ * No root required. DNS-enriches IPs hostnames before pattern matching.
1752
1956
  */
1753
1957
  async startLsofPolling() {
1754
- const pollInterval = setInterval(() => {
1958
+ const safeName = this.serverName.replace(/[^a-zA-Z0-9._-]/g, "");
1959
+ const pollInterval = setInterval(async () => {
1755
1960
  try {
1756
- const output = (0, import_child_process.execSync)(
1757
- `lsof -i -n -P 2>/dev/null | grep -i "${this.serverName}" || true`,
1758
- { timeout: 5e3, encoding: "utf-8" }
1759
- );
1760
- for (const line of output.split("\n").filter(Boolean)) {
1761
- const event = this.parseLsofLine(line);
1762
- if (event) this.ingestEvent(event);
1763
- }
1961
+ const lsofOutput = (0, import_child_process.execFileSync)("/usr/sbin/lsof", ["-i", "-n", "-P"], {
1962
+ timeout: 5e3,
1963
+ encoding: "utf-8",
1964
+ stdio: ["pipe", "pipe", "pipe"]
1965
+ });
1966
+ const lines = lsofOutput.split("\n").filter((line) => line.toLowerCase().includes(safeName.toLowerCase())).filter(Boolean);
1967
+ const rawEvents = lines.map((l) => this.parseLsofLine(l)).filter((e) => e !== null);
1968
+ const enriched = await Promise.all(rawEvents.map((e) => this.enrichWithHostname(e)));
1969
+ for (const event of enriched) this.ingestEvent(event);
1764
1970
  } catch {
1765
1971
  }
1766
1972
  }, 2e3);
1767
- this.monitorProcess = {
1768
- kill: () => clearInterval(pollInterval)
1769
- };
1770
- setTimeout(() => {
1771
- clearInterval(pollInterval);
1772
- }, this.durationSeconds * 1e3);
1973
+ this.monitorProcess = { kill: () => clearInterval(pollInterval) };
1974
+ setTimeout(() => clearInterval(pollInterval), this.durationSeconds * 1e3);
1773
1975
  }
1774
1976
  /**
1775
- * Linux: Poll `ss` (socket statistics) for network connections.
1977
+ * Linux: Poll `ss -tnp` (socket statistics) for network connections.
1978
+ * No root required. DNS-enriches IPs → hostnames before pattern matching.
1776
1979
  */
1777
1980
  async startSsPolling() {
1778
- const pollInterval = setInterval(() => {
1981
+ const safeName = this.serverName.replace(/[^a-zA-Z0-9._-]/g, "");
1982
+ const pollInterval = setInterval(async () => {
1779
1983
  try {
1780
- const output = (0, import_child_process.execSync)(
1781
- `ss -tnp 2>/dev/null | grep "${this.serverName}" || true`,
1782
- { timeout: 5e3, encoding: "utf-8" }
1783
- );
1784
- for (const line of output.split("\n").filter(Boolean)) {
1785
- const event = this.parseSsLine(line);
1786
- if (event) this.ingestEvent(event);
1787
- }
1984
+ const ssOutput = (0, import_child_process.execFileSync)("ss", ["-tnp"], {
1985
+ timeout: 5e3,
1986
+ encoding: "utf-8",
1987
+ stdio: ["pipe", "pipe", "pipe"]
1988
+ });
1989
+ const lines = ssOutput.split("\n").filter((line) => line.includes(safeName)).filter(Boolean);
1990
+ const rawEvents = lines.map((l) => this.parseSsLine(l)).filter((e) => e !== null);
1991
+ const enriched = await Promise.all(rawEvents.map((e) => this.enrichWithHostname(e)));
1992
+ for (const event of enriched) this.ingestEvent(event);
1788
1993
  } catch {
1789
1994
  }
1790
1995
  }, 2e3);
1791
- this.monitorProcess = {
1792
- kill: () => clearInterval(pollInterval)
1793
- };
1794
- setTimeout(() => {
1795
- clearInterval(pollInterval);
1796
- }, this.durationSeconds * 1e3);
1996
+ this.monitorProcess = { kill: () => clearInterval(pollInterval) };
1997
+ setTimeout(() => clearInterval(pollInterval), this.durationSeconds * 1e3);
1998
+ }
1999
+ /**
2000
+ * Windows: Poll PowerShell `Get-NetTCPConnection` for established connections.
2001
+ * No admin required (available on Windows 8+ / Server 2012+).
2002
+ * DNS-enriches IPs → hostnames so endpoint patterns match correctly.
2003
+ *
2004
+ * Note: Unlike lsof/ss, this captures ALL machine connections (not filtered
2005
+ * by process name) because Windows process-to-connection mapping requires
2006
+ * admin rights. Vigil's patterns handle the noise — it flags what matters.
2007
+ */
2008
+ async startNetstatPolling() {
2009
+ const psCommand = `Get-NetTCPConnection -State Established | Where-Object { $_.RemoteAddress -notmatch '^(127\\.|::1$|0\\.0\\.0\\.0$|::$)' } | Select-Object LocalAddress,LocalPort,RemoteAddress,RemotePort | ConvertTo-Csv -NoTypeInformation`;
2010
+ const pollInterval = setInterval(async () => {
2011
+ try {
2012
+ const output = (0, import_child_process.execFileSync)("powershell", [
2013
+ "-NoProfile",
2014
+ "-NonInteractive",
2015
+ "-Command",
2016
+ psCommand
2017
+ ], {
2018
+ timeout: 8e3,
2019
+ // PowerShell startup is slower than lsof
2020
+ encoding: "utf-8",
2021
+ stdio: ["pipe", "pipe", "pipe"]
2022
+ });
2023
+ const lines = output.split("\n").slice(1).map((l) => l.trim()).filter(Boolean);
2024
+ const rawEvents = lines.map((l) => this.parsePowerShellLine(l)).filter((e) => e !== null);
2025
+ const enriched = await Promise.all(rawEvents.map((e) => this.enrichWithHostname(e)));
2026
+ for (const event of enriched) this.ingestEvent(event);
2027
+ } catch {
2028
+ }
2029
+ }, 3e3);
2030
+ this.monitorProcess = { kill: () => clearInterval(pollInterval) };
2031
+ setTimeout(() => clearInterval(pollInterval), this.durationSeconds * 1e3);
1797
2032
  }
1798
2033
  /**
1799
2034
  * Fallback: Start a lightweight MITM proxy that MCP traffic routes through.
@@ -1839,6 +2074,65 @@ var SentinelEngine = class {
1839
2074
  tls: parseInt(remotePort, 10) === 443
1840
2075
  };
1841
2076
  }
2077
+ /**
2078
+ * Windows: Parse a CSV row from PowerShell `Get-NetTCPConnection | ConvertTo-Csv`.
2079
+ * Format: "LocalAddress","LocalPort","RemoteAddress","RemotePort"
2080
+ * Example: "192.168.1.5","54123","172.64.155.209","443"
2081
+ */
2082
+ parsePowerShellLine(line) {
2083
+ const match = line.match(/^"?([^",]+)"?,"\d+","?([^",]+)"?,"?(\d+)"?/);
2084
+ if (!match) return null;
2085
+ const [, , remoteAddress, remotePort] = match;
2086
+ const port = parseInt(remotePort, 10);
2087
+ if (remoteAddress === "127.0.0.1" || remoteAddress === "::1" || remoteAddress.startsWith("10.") || remoteAddress.startsWith("192.168.") || /^172\.(1[6-9]|2\d|3[01])\./.test(remoteAddress)) {
2088
+ return null;
2089
+ }
2090
+ return {
2091
+ timestamp: Date.now(),
2092
+ serverName: this.serverName,
2093
+ method: "TCP",
2094
+ url: `https://${remoteAddress}:${remotePort}/`,
2095
+ destinationIp: remoteAddress,
2096
+ port,
2097
+ requestSize: 0,
2098
+ tls: port === 443
2099
+ };
2100
+ }
2101
+ // ── DNS Enrichment ──
2102
+ /**
2103
+ * Reverse-DNS lookup with a session-scoped cache.
2104
+ * Returns the first PTR record if available, otherwise the raw IP.
2105
+ *
2106
+ * WHY this matters: Sentinel's endpoint patterns match against known-bad
2107
+ * hostnames (pastebin.com, ngrok.io, etc.). Without DNS enrichment, lsof/ss/
2108
+ * netstat all return raw IPs and those patterns would silently miss every hit.
2109
+ */
2110
+ async resolveIp(ip) {
2111
+ const cached = this.dnsCache.get(ip);
2112
+ if (cached !== void 0) return cached;
2113
+ try {
2114
+ const hostnames = await import_dns.promises.reverse(ip);
2115
+ const hostname = hostnames[0] ?? ip;
2116
+ this.dnsCache.set(ip, hostname);
2117
+ return hostname;
2118
+ } catch {
2119
+ this.dnsCache.set(ip, ip);
2120
+ return ip;
2121
+ }
2122
+ }
2123
+ /**
2124
+ * Enrich a NetworkEvent's url field with a resolved hostname.
2125
+ * Returns a new event object (does not mutate the original).
2126
+ */
2127
+ async enrichWithHostname(event) {
2128
+ if (!event.destinationIp) return event;
2129
+ const hostname = await this.resolveIp(event.destinationIp);
2130
+ if (hostname === event.destinationIp) return event;
2131
+ return {
2132
+ ...event,
2133
+ url: `https://${hostname}:${event.port}/`
2134
+ };
2135
+ }
1842
2136
  };
1843
2137
  function getSentinelFeatures(tier) {
1844
2138
  switch (tier) {
@@ -2043,6 +2337,7 @@ function printNoServersFound() {
2043
2337
  console.log(import_chalk.default.gray(" \u2022 Claude Code config (.claude.json / .mcp.json)"));
2044
2338
  console.log(import_chalk.default.gray(" \u2022 Windsurf MCP config"));
2045
2339
  console.log(import_chalk.default.gray(" \u2022 VS Code MCP config (.vscode/mcp.json)"));
2340
+ console.log(import_chalk.default.gray(" \u2022 OpenClaw config (~/.openclaw/openclaw.json)"));
2046
2341
  console.log("");
2047
2342
  console.log(import_chalk.default.gray(" If you have MCP servers configured elsewhere, use:"));
2048
2343
  console.log(import_chalk.default.cyan(" vigile-scan --config /path/to/config.json"));
@@ -2204,8 +2499,8 @@ function formatJSON(summary) {
2204
2499
  }
2205
2500
 
2206
2501
  // src/api/auth.ts
2207
- var import_promises3 = require("fs/promises");
2208
- var import_path8 = require("path");
2502
+ var import_promises4 = require("fs/promises");
2503
+ var import_path9 = require("path");
2209
2504
  var import_os2 = require("os");
2210
2505
 
2211
2506
  // src/api/client.ts
@@ -2216,7 +2511,19 @@ var VigileApiClient = class {
2216
2511
  baseUrl;
2217
2512
  token;
2218
2513
  constructor(baseUrl, token) {
2219
- this.baseUrl = (baseUrl || process.env.VIGILE_API_URL || DEFAULT_API_URL).replace(/\/+$/, "");
2514
+ const rawUrl = (baseUrl || process.env.VIGILE_API_URL || DEFAULT_API_URL).replace(/\/+$/, "");
2515
+ try {
2516
+ const u = new URL(rawUrl);
2517
+ if (u.protocol !== "https:" && u.hostname !== "localhost" && u.hostname !== "127.0.0.1") {
2518
+ console.error(`[vigile] API URL must use HTTPS \u2014 falling back to default`);
2519
+ this.baseUrl = DEFAULT_API_URL;
2520
+ } else {
2521
+ this.baseUrl = rawUrl;
2522
+ }
2523
+ } catch {
2524
+ console.error(`[vigile] Invalid API URL \u2014 falling back to default`);
2525
+ this.baseUrl = DEFAULT_API_URL;
2526
+ }
2220
2527
  this.token = token || null;
2221
2528
  }
2222
2529
  get isAuthenticated() {
@@ -2301,8 +2608,8 @@ var VigileApiClient = class {
2301
2608
  };
2302
2609
 
2303
2610
  // src/api/auth.ts
2304
- var CONFIG_DIR = (0, import_path8.join)((0, import_os2.homedir)(), ".vigile");
2305
- var CONFIG_FILE = (0, import_path8.join)(CONFIG_DIR, "config.json");
2611
+ var CONFIG_DIR = (0, import_path9.join)((0, import_os2.homedir)(), ".vigile");
2612
+ var CONFIG_FILE = (0, import_path9.join)(CONFIG_DIR, "config.json");
2306
2613
  async function resolveToken() {
2307
2614
  const envToken = process.env.VIGILE_TOKEN;
2308
2615
  if (envToken && envToken.length > 0) {
@@ -2319,15 +2626,15 @@ async function resolveApiUrl() {
2319
2626
  }
2320
2627
  async function loadConfig() {
2321
2628
  try {
2322
- const raw = await (0, import_promises3.readFile)(CONFIG_FILE, "utf-8");
2629
+ const raw = await (0, import_promises4.readFile)(CONFIG_FILE, "utf-8");
2323
2630
  return JSON.parse(raw);
2324
2631
  } catch {
2325
2632
  return {};
2326
2633
  }
2327
2634
  }
2328
2635
  async function saveConfig(config) {
2329
- await (0, import_promises3.mkdir)(CONFIG_DIR, { recursive: true });
2330
- await (0, import_promises3.writeFile)(CONFIG_FILE, JSON.stringify(config, null, 2), {
2636
+ await (0, import_promises4.mkdir)(CONFIG_DIR, { recursive: true });
2637
+ await (0, import_promises4.writeFile)(CONFIG_FILE, JSON.stringify(config, null, 2), {
2331
2638
  mode: 384
2332
2639
  // Owner read/write only
2333
2640
  });
@@ -2379,7 +2686,7 @@ async function getAuthenticatedClient() {
2379
2686
  }
2380
2687
 
2381
2688
  // src/index.ts
2382
- var VERSION = "0.2.0";
2689
+ var VERSION = require_package().version;
2383
2690
  var program = new import_commander.Command();
2384
2691
  program.name("vigile-scan").description(
2385
2692
  "Security scanner for AI agent tools \u2014 detect tool poisoning, permission abuse, and supply chain attacks in MCP servers and agent skills"
@@ -2387,7 +2694,7 @@ program.name("vigile-scan").description(
2387
2694
  function addScanOptions(cmd) {
2388
2695
  return cmd.option("-j, --json", "Output results as JSON").option("-v, --verbose", "Show detailed findings and score breakdown").option("-c, --config <path>", "Path to a custom MCP config file").option("-o, --output <path>", "Write results to a file").option(
2389
2696
  "--client <client>",
2390
- "Only scan a specific client (claude-desktop, cursor, claude-code, windsurf, vscode)"
2697
+ "Only scan a specific client (claude-desktop, cursor, claude-code, windsurf, vscode, openclaw)"
2391
2698
  ).option("-s, --skills", "Scan agent skills only (SKILL.md, .mdc rules, CLAUDE.md, etc.)").option("-a, --all", "Scan both MCP servers and agent skills").option("--sentinel", "Enable Sentinel runtime monitoring (Pro+ feature)").option("--sentinel-server <name>", "Monitor a specific MCP server by name").option("--sentinel-duration <seconds>", "Monitoring duration in seconds (default: 120)", parseInt).option("--no-upload", "Skip uploading scan results to Vigile API");
2392
2699
  }
2393
2700
  addScanOptions(
@@ -2527,9 +2834,13 @@ async function runScan(options) {
2527
2834
  if (isJSON) {
2528
2835
  const jsonOutput = formatJSON(summary);
2529
2836
  if (options.output) {
2530
- await (0, import_promises4.writeFile)(options.output, jsonOutput);
2837
+ await (0, import_promises5.writeFile)(options.output, jsonOutput);
2531
2838
  } else {
2532
- console.log(jsonOutput);
2839
+ await new Promise((resolve, reject) => {
2840
+ const ok = process.stdout.write(jsonOutput + "\n");
2841
+ if (ok) resolve();
2842
+ else process.stdout.once("drain", resolve);
2843
+ });
2533
2844
  }
2534
2845
  } else {
2535
2846
  console.log("");
@@ -2545,7 +2856,7 @@ async function runScan(options) {
2545
2856
  }
2546
2857
  printSummary(summary);
2547
2858
  if (options.output) {
2548
- await (0, import_promises4.writeFile)(options.output, formatJSON(summary));
2859
+ await (0, import_promises5.writeFile)(options.output, formatJSON(summary));
2549
2860
  console.log(` Results saved to ${options.output}`);
2550
2861
  }
2551
2862
  }
@@ -2556,6 +2867,13 @@ async function runScan(options) {
2556
2867
  await runSentinel(options, results, isJSON);
2557
2868
  }
2558
2869
  if (summary.bySeverity.critical > 0 || summary.bySeverity.high > 0) {
2870
+ await new Promise((resolve) => {
2871
+ if (process.stdout.writableNeedDrain) {
2872
+ process.stdout.once("drain", resolve);
2873
+ } else {
2874
+ resolve();
2875
+ }
2876
+ });
2559
2877
  process.exit(1);
2560
2878
  }
2561
2879
  }