openclaw-plugin-vt-sentinel 0.4.0 → 0.6.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.
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Simple rotating audit log.
3
+ * Format: ISO-8601 timestamp \t SHA-256 \t full file path
4
+ * Rotates by keeping the newest half when maxLines or maxBytes is exceeded.
5
+ */
6
+ export declare class AuditLog {
7
+ private logPath;
8
+ private maxLines;
9
+ private maxBytes;
10
+ private lineCount;
11
+ private approxSize;
12
+ constructor(logPath: string, maxLines?: number, maxBytes?: number);
13
+ append(sha256: string, filePath: string): void;
14
+ private rotate;
15
+ }
16
+ /**
17
+ * Returns the directory for VT Sentinel logs (same as state dir).
18
+ */
19
+ export declare function getLogDir(): string;
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.AuditLog = void 0;
37
+ exports.getLogDir = getLogDir;
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const os = __importStar(require("os"));
41
+ /**
42
+ * Simple rotating audit log.
43
+ * Format: ISO-8601 timestamp \t SHA-256 \t full file path
44
+ * Rotates by keeping the newest half when maxLines or maxBytes is exceeded.
45
+ */
46
+ class AuditLog {
47
+ constructor(logPath, maxLines = 1000, maxBytes = 1024 * 1024) {
48
+ this.lineCount = 0;
49
+ this.approxSize = 0;
50
+ this.logPath = logPath;
51
+ this.maxLines = maxLines;
52
+ this.maxBytes = maxBytes;
53
+ // Ensure parent directory exists
54
+ try {
55
+ const dir = path.dirname(logPath);
56
+ if (!fs.existsSync(dir))
57
+ fs.mkdirSync(dir, { recursive: true });
58
+ }
59
+ catch { /* best-effort */ }
60
+ // Seed counters from existing file
61
+ try {
62
+ const stat = fs.statSync(logPath);
63
+ this.approxSize = stat.size;
64
+ const content = fs.readFileSync(logPath, 'utf-8');
65
+ this.lineCount = content.split('\n').filter(l => l.length > 0).length;
66
+ }
67
+ catch {
68
+ this.lineCount = 0;
69
+ this.approxSize = 0;
70
+ }
71
+ }
72
+ append(sha256, filePath) {
73
+ const line = `${new Date().toISOString()}\t${sha256}\t${filePath}\n`;
74
+ try {
75
+ fs.appendFileSync(this.logPath, line);
76
+ this.lineCount++;
77
+ this.approxSize += Buffer.byteLength(line);
78
+ if (this.lineCount > this.maxLines || this.approxSize > this.maxBytes) {
79
+ this.rotate();
80
+ }
81
+ }
82
+ catch { /* best-effort — never crash the plugin for a log write failure */ }
83
+ }
84
+ rotate() {
85
+ try {
86
+ const content = fs.readFileSync(this.logPath, 'utf-8');
87
+ const lines = content.split('\n').filter(l => l.length > 0);
88
+ // Keep at most half of maxLines
89
+ let keep = lines.slice(-Math.floor(this.maxLines / 2));
90
+ // Also trim by size: drop oldest lines until under half of maxBytes
91
+ const halfBytes = Math.floor(this.maxBytes / 2);
92
+ while (keep.length > 1) {
93
+ const size = Buffer.byteLength(keep.join('\n') + '\n');
94
+ if (size <= halfBytes)
95
+ break;
96
+ keep.shift();
97
+ }
98
+ const newContent = keep.join('\n') + '\n';
99
+ fs.writeFileSync(this.logPath, newContent);
100
+ this.lineCount = keep.length;
101
+ this.approxSize = Buffer.byteLength(newContent);
102
+ }
103
+ catch { /* best-effort */ }
104
+ }
105
+ }
106
+ exports.AuditLog = AuditLog;
107
+ /**
108
+ * Returns the directory for VT Sentinel logs (same as state dir).
109
+ */
110
+ function getLogDir() {
111
+ return process.env.OPENCLAW_STATE_DIR || path.join(os.homedir(), '.openclaw');
112
+ }
package/dist/index.js CHANGED
@@ -47,6 +47,7 @@ const path = __importStar(require("path"));
47
47
  const scanner_1 = require("./scanner");
48
48
  const path_extractor_1 = require("./path-extractor");
49
49
  const vt_api_1 = require("./vt-api");
50
+ const audit_log_1 = require("./audit-log");
50
51
  // --- Helpers ---
51
52
  function formatResult(r) {
52
53
  const lines = [];
@@ -157,7 +158,8 @@ function vtSentinelPlugin(api) {
157
158
  if (scanner)
158
159
  return scanner;
159
160
  const cfg = getConfig();
160
- const userApiKey = cfg?.apiKey || process.env.VIRUSTOTAL_API_KEY;
161
+ const envKey = process.env.VIRUSTOTAL_API_KEY;
162
+ const userApiKey = cfg?.apiKey || (envKey && envKey !== 'vtai-active' ? envKey : undefined);
161
163
  if (userApiKey) {
162
164
  // User-provided key → standard VT API
163
165
  if (!process.env.VIRUSTOTAL_API_KEY)
@@ -194,6 +196,21 @@ function vtSentinelPlugin(api) {
194
196
  // If file content changes (different hash), it gets rescanned.
195
197
  const READ_SCAN_REGISTRY_MAX = 5000;
196
198
  const readScanRegistry = new Map();
199
+ // --- Audit logs: rotating logs for uploads and detections ---
200
+ const logDir = (0, audit_log_1.getLogDir)();
201
+ const uploadLog = new audit_log_1.AuditLog(path.join(logDir, 'vt-sentinel-uploads.log'));
202
+ const detectionLog = new audit_log_1.AuditLog(path.join(logDir, 'vt-sentinel-detections.log'));
203
+ /** Log a scan result to the appropriate audit log(s). */
204
+ const auditResult = (result) => {
205
+ if (!result.sha256)
206
+ return;
207
+ if (result.verdict === 'pending') {
208
+ uploadLog.append(result.sha256, result.filePath);
209
+ }
210
+ if (result.verdict === 'malicious' || result.verdict === 'suspicious') {
211
+ detectionLog.append(result.sha256, result.filePath);
212
+ }
213
+ };
197
214
  // --- Blocklist: tracks files detected as malicious/suspicious ---
198
215
  const blocklist = new Map();
199
216
  // --- Context enrichment: derive interesting dirs from OpenClaw runtime ---
@@ -372,6 +389,7 @@ function vtSentinelPlugin(api) {
372
389
  return;
373
390
  const force = isForceScannedDir(filePath);
374
391
  const result = await s.scanFile(filePath, force);
392
+ auditResult(result);
375
393
  if (result.verdict === 'malicious') {
376
394
  api.logger.error(`[VT-Sentinel] ${result.message}`);
377
395
  blockFile(filePath, result);
@@ -407,11 +425,14 @@ function vtSentinelPlugin(api) {
407
425
  path.join(stateDir, 'hooks'),
408
426
  ];
409
427
  // Workspace: agent's working directory — downloads, ClawHub skills, etc.
410
- // Force-scan entire workspace: files here are agent-created or plugin-installed,
411
- // so even SAFE-classified files deserve VT analysis (catches disguised payloads).
428
+ // Watch but DON'T force-scan entire workspace it contains user-private data
429
+ // (session memories, conversation context) that should never be uploaded to VT.
430
+ // Only force-scan code subdirs within workspace (skills/hooks/extensions).
412
431
  const workspaceDir = path.join(stateDir, 'workspace');
413
432
  candidates.push(workspaceDir);
414
- forceScannedDirs.add(workspaceDir);
433
+ const wsCodeDirs = ['skills', 'hooks', 'extensions'].map(s => path.join(workspaceDir, s));
434
+ candidates.push(...wsCodeDirs);
435
+ wsCodeDirs.forEach(d => forceScannedDirs.add(d));
415
436
  // Set default workspace dir for resolving relative paths in hooks
416
437
  if (!resolvedWorkspaceDir) {
417
438
  resolvedWorkspaceDir = workspaceDir;
@@ -478,14 +499,30 @@ function vtSentinelPlugin(api) {
478
499
  api.logger.warn('[VT-Sentinel] No valid watch directories found');
479
500
  return;
480
501
  }
502
+ // depth: 0 — only top-level files per directory. Prevents chokidar from
503
+ // recursively creating thousands of inotify watches on /tmp subdirectories,
504
+ // which in gateway environments blocks the 'ready' event and suppresses
505
+ // all high-level add/change events indefinitely.
506
+ // No awaitWriteFinish — it interacts badly with the gateway's event loop.
507
+ // We use a simple debounce map instead to avoid scanning files mid-write.
481
508
  watcher = chokidar.watch(validDirs, {
482
509
  persistent: true,
483
510
  ignoreInitial: true,
484
511
  ignorePermissionErrors: true,
485
- awaitWriteFinish: { stabilityThreshold: 2000, pollInterval: 100 },
512
+ depth: 0,
486
513
  });
487
- watcher.on('add', handleWatcherFile);
488
- watcher.on('change', handleWatcherFile);
514
+ const debounceTimers = new Map();
515
+ const debouncedHandler = (filePath) => {
516
+ const existing = debounceTimers.get(filePath);
517
+ if (existing)
518
+ clearTimeout(existing);
519
+ debounceTimers.set(filePath, setTimeout(() => {
520
+ debounceTimers.delete(filePath);
521
+ handleWatcherFile(filePath);
522
+ }, 1500));
523
+ };
524
+ watcher.on('add', debouncedHandler);
525
+ watcher.on('change', debouncedHandler);
489
526
  watcher.on('error', (err) => {
490
527
  api.logger.warn(`[VT-Sentinel] Watcher error (non-fatal): ${err.message}`);
491
528
  });
@@ -523,6 +560,7 @@ function vtSentinelPlugin(api) {
523
560
  return textResponse('Error: VT-Sentinel not configured (API key missing and VTAI registration failed)');
524
561
  try {
525
562
  const result = await s.scanFile(params.path, true);
563
+ auditResult(result);
526
564
  return textResponse(formatResult(result));
527
565
  }
528
566
  catch (err) {
@@ -552,6 +590,7 @@ function vtSentinelPlugin(api) {
552
590
  const result = await s.checkHash(params.hash);
553
591
  if (!result)
554
592
  return textResponse(`Hash ${params.hash} not found in VirusTotal database.`);
593
+ auditResult(result);
555
594
  return textResponse(formatResult(result));
556
595
  }
557
596
  catch (err) {
@@ -589,6 +628,7 @@ function vtSentinelPlugin(api) {
589
628
  try {
590
629
  s.recordConsent(true);
591
630
  const result = await s.uploadWithConsent(params.path);
631
+ auditResult(result);
592
632
  return textResponse(formatResult(result));
593
633
  }
594
634
  catch (err) {
@@ -633,6 +673,7 @@ function vtSentinelPlugin(api) {
633
673
  }
634
674
  }
635
675
  const result = await s.scanFile(target.path, false, precomputedHash);
676
+ auditResult(result);
636
677
  if (result.verdict === 'malicious') {
637
678
  api.logger.error(`[VT-Sentinel] THREAT DETECTED — ${result.fileName} ` +
638
679
  `(${result.detections?.malicious} detections) ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-plugin-vt-sentinel",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "VirusTotal Sentinel for OpenClaw - Malware detection and AI-powered code analysis",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -25,6 +25,7 @@
25
25
  "dist/classifier.*",
26
26
  "dist/cache.*",
27
27
  "dist/path-extractor.*",
28
+ "dist/audit-log.*",
28
29
  "skills/",
29
30
  "hooks/",
30
31
  "openclaw.plugin.json"