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.
- package/dist/audit-log.d.ts +19 -0
- package/dist/audit-log.js +112 -0
- package/dist/index.js +48 -7
- package/package.json +2 -1
|
@@ -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
|
|
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
|
-
//
|
|
411
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
512
|
+
depth: 0,
|
|
486
513
|
});
|
|
487
|
-
|
|
488
|
-
|
|
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.
|
|
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"
|