openclaw-plugin-vt-sentinel 0.4.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,96 @@
1
+ import { FileCategory } from './classifier';
2
+ export type ScanVerdict = 'clean' | 'malicious' | 'suspicious' | 'unknown' | 'pending' | 'skipped' | 'needs_consent';
3
+ /**
4
+ * Policy for handling sensitive/ambiguous files (PDF, Office, unknown ZIP).
5
+ *
6
+ * "ask" → return needs_consent; agent asks user each time (default)
7
+ * "ask_once" → ask the first time, remember choice for the session
8
+ * "always_upload" → always upload sensitive files to VT
9
+ * "hash_only" → never upload, only check hash
10
+ */
11
+ export type SensitiveFilePolicy = 'ask' | 'ask_once' | 'always_upload' | 'hash_only';
12
+ export interface ScanResult {
13
+ filePath: string;
14
+ fileName: string;
15
+ sha256: string;
16
+ category: FileCategory;
17
+ verdict: ScanVerdict;
18
+ detections?: {
19
+ malicious: number;
20
+ suspicious: number;
21
+ total: number;
22
+ };
23
+ codeInsight?: {
24
+ source: string;
25
+ analysis: string;
26
+ verdict: string;
27
+ };
28
+ vtLink?: string;
29
+ message: string;
30
+ }
31
+ export declare class Scanner {
32
+ private api;
33
+ private cache;
34
+ private limiter;
35
+ private maxFileSizeMb;
36
+ private sensitivePolicy;
37
+ /** In-memory consent cache for "ask_once" policy. null = not yet asked. */
38
+ private consentDecision;
39
+ private logger;
40
+ constructor(apiKey: string, logger: {
41
+ info: (m: string) => void;
42
+ warn: (m: string) => void;
43
+ error: (m: string) => void;
44
+ }, maxFileSizeMb?: number, sensitivePolicy?: SensitiveFilePolicy, useVtai?: boolean);
45
+ /**
46
+ * Record the user's consent decision (used by the tool when the user responds).
47
+ * When policy is "ask_once", this persists for the session.
48
+ */
49
+ recordConsent(upload: boolean): void;
50
+ /**
51
+ * Full scan of a file: classify → hash → VT lookup → code insight if applicable.
52
+ * When force=true (manual scan), always do at least a hash check even for SAFE/MEDIA files.
53
+ */
54
+ scanFile(filePath: string, force?: boolean, precomputedHash?: string): Promise<ScanResult>;
55
+ /**
56
+ * Scan a HIGH_RISK or SEMANTIC_RISK file: hash lookup → upload if unknown.
57
+ * Code Insight is extracted automatically by fromReport() (same as all categories).
58
+ */
59
+ private scanAutoUpload;
60
+ /**
61
+ * Scan a SENSITIVE file according to the configured policy.
62
+ *
63
+ * Step 1 (always): Check hash — this reveals nothing about file content.
64
+ * - If VT knows the hash → report findings (malicious PDF templates, etc.)
65
+ *
66
+ * Step 2 (if hash unknown): Apply policy:
67
+ * - "hash_only" → done, don't upload
68
+ * - "always_upload" → upload to VT
69
+ * - "ask" → return needs_consent every time
70
+ * - "ask_once" → return needs_consent the first time, then use remembered decision
71
+ */
72
+ private scanSensitive;
73
+ /**
74
+ * Forced scan for SAFE/MEDIA files when user explicitly requests it.
75
+ * Always checks hash; does NOT upload (no privacy concern but no reason to upload safe files).
76
+ */
77
+ private scanForced;
78
+ private uploadSensitive;
79
+ private needsConsent;
80
+ /**
81
+ * Upload a file that the user previously consented to (after needs_consent).
82
+ */
83
+ uploadWithConsent(filePath: string): Promise<ScanResult>;
84
+ /**
85
+ * Quick hash check — no classification, no upload.
86
+ */
87
+ checkHash(hash: string): Promise<ScanResult | null>;
88
+ /**
89
+ * Build ScanResult from a VT report.
90
+ * Always extracts both AV detections AND Code Insight (if available).
91
+ * The final verdict is the worst of: AV engines + AI analysis.
92
+ */
93
+ private fromReport;
94
+ private result;
95
+ clearCache(): void;
96
+ }
@@ -0,0 +1,312 @@
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.Scanner = void 0;
37
+ const path = __importStar(require("path"));
38
+ const fs = __importStar(require("fs"));
39
+ const vt_api_1 = require("./vt-api");
40
+ const classifier_1 = require("./classifier");
41
+ const cache_1 = require("./cache");
42
+ // --- Scanner ---
43
+ class Scanner {
44
+ constructor(apiKey, logger, maxFileSizeMb = 32, sensitivePolicy = 'ask', useVtai = false) {
45
+ /** In-memory consent cache for "ask_once" policy. null = not yet asked. */
46
+ this.consentDecision = null;
47
+ this.api = new vt_api_1.VTApiClient(apiKey, useVtai);
48
+ this.cache = new cache_1.Cache(15);
49
+ this.limiter = new cache_1.RateLimiter(4);
50
+ this.maxFileSizeMb = maxFileSizeMb;
51
+ this.sensitivePolicy = sensitivePolicy;
52
+ this.logger = logger;
53
+ }
54
+ /**
55
+ * Record the user's consent decision (used by the tool when the user responds).
56
+ * When policy is "ask_once", this persists for the session.
57
+ */
58
+ recordConsent(upload) {
59
+ this.consentDecision = upload;
60
+ }
61
+ /**
62
+ * Full scan of a file: classify → hash → VT lookup → code insight if applicable.
63
+ * When force=true (manual scan), always do at least a hash check even for SAFE/MEDIA files.
64
+ */
65
+ async scanFile(filePath, force = false, precomputedHash) {
66
+ const fileName = path.basename(filePath);
67
+ if (!fs.existsSync(filePath)) {
68
+ return this.result(filePath, '', classifier_1.FileCategory.SAFE, 'skipped', `File not found: ${fileName}`);
69
+ }
70
+ const stat = fs.statSync(filePath);
71
+ if (stat.size > this.maxFileSizeMb * 1024 * 1024) {
72
+ return this.result(filePath, '', classifier_1.FileCategory.SAFE, 'skipped', `File too large (${(stat.size / 1024 / 1024).toFixed(1)}MB > ${this.maxFileSizeMb}MB limit)`);
73
+ }
74
+ const category = classifier_1.FileClassifier.classify(filePath);
75
+ // Auto-scan: skip SAFE/MEDIA early (avoid hashing large benign files and avoid cache poisoning).
76
+ if (!force && (category === classifier_1.FileCategory.MEDIA || category === classifier_1.FileCategory.SAFE)) {
77
+ return this.result(filePath, '', category, 'skipped', `Safe/media file (${fileName}) — skipped`);
78
+ }
79
+ const sha256 = precomputedHash || await (0, vt_api_1.calculateSHA256)(filePath);
80
+ const cached = this.cache.get(sha256);
81
+ if (cached) {
82
+ this.logger.info(`[VT-Sentinel] Cache hit for ${fileName}`);
83
+ // Cache entries are keyed by SHA-256 (VT identity). Rebase per-file context.
84
+ return { ...cached, filePath, fileName, category, sha256 };
85
+ }
86
+ let result;
87
+ // When force=true (code dirs), treat all files as HIGH_RISK: hash + auto-upload.
88
+ // Media/safe files in skills/hooks/extensions dirs are anomalous and should be fully analyzed.
89
+ const effectiveCategory = force && (category === classifier_1.FileCategory.MEDIA || category === classifier_1.FileCategory.SAFE)
90
+ ? classifier_1.FileCategory.HIGH_RISK
91
+ : category;
92
+ switch (effectiveCategory) {
93
+ case classifier_1.FileCategory.HIGH_RISK:
94
+ case classifier_1.FileCategory.SEMANTIC_RISK:
95
+ result = await this.scanAutoUpload(filePath, sha256, effectiveCategory);
96
+ break;
97
+ case classifier_1.FileCategory.SENSITIVE:
98
+ result = await this.scanSensitive(filePath, sha256, effectiveCategory);
99
+ break;
100
+ case classifier_1.FileCategory.MEDIA:
101
+ case classifier_1.FileCategory.SAFE:
102
+ default:
103
+ // Manual scan: always check hash even for safe/media files
104
+ result = await this.scanForced(filePath, sha256, category);
105
+ break;
106
+ }
107
+ // Cache only results derived from VT knowledge or an upload attempt.
108
+ // Do NOT cache "skipped/unknown/needs_consent" because those depend on local policy
109
+ // and should not prevent future uploads or policy changes.
110
+ if (result.verdict === 'clean' ||
111
+ result.verdict === 'malicious' ||
112
+ result.verdict === 'suspicious' ||
113
+ result.verdict === 'pending') {
114
+ this.cache.set(sha256, result);
115
+ }
116
+ return result;
117
+ }
118
+ /**
119
+ * Scan a HIGH_RISK or SEMANTIC_RISK file: hash lookup → upload if unknown.
120
+ * Code Insight is extracted automatically by fromReport() (same as all categories).
121
+ */
122
+ async scanAutoUpload(filePath, sha256, category) {
123
+ const fileName = path.basename(filePath);
124
+ await this.limiter.acquire();
125
+ const report = await this.api.checkHash(sha256);
126
+ if (report) {
127
+ return this.fromReport(filePath, sha256, category, report);
128
+ }
129
+ this.logger.info(`[VT-Sentinel] Unknown ${category} file ${fileName}, uploading...`);
130
+ await this.limiter.acquire();
131
+ try {
132
+ const upload = await this.api.uploadFile(filePath);
133
+ return this.result(filePath, sha256, category, 'pending', `Uploaded for analysis (${upload.analysisId}). Results pending.`);
134
+ }
135
+ catch (err) {
136
+ return this.result(filePath, sha256, category, 'unknown', `Upload failed: ${err.message}`);
137
+ }
138
+ }
139
+ /**
140
+ * Scan a SENSITIVE file according to the configured policy.
141
+ *
142
+ * Step 1 (always): Check hash — this reveals nothing about file content.
143
+ * - If VT knows the hash → report findings (malicious PDF templates, etc.)
144
+ *
145
+ * Step 2 (if hash unknown): Apply policy:
146
+ * - "hash_only" → done, don't upload
147
+ * - "always_upload" → upload to VT
148
+ * - "ask" → return needs_consent every time
149
+ * - "ask_once" → return needs_consent the first time, then use remembered decision
150
+ */
151
+ async scanSensitive(filePath, sha256, category) {
152
+ const fileName = path.basename(filePath);
153
+ // Step 1: Hash check (always safe, reveals nothing)
154
+ await this.limiter.acquire();
155
+ const report = await this.api.checkHash(sha256);
156
+ if (report) {
157
+ const result = this.fromReport(filePath, sha256, category, report);
158
+ result.message += ' (hash-only check — file NOT uploaded to VT)';
159
+ return result;
160
+ }
161
+ // Step 2: Hash unknown — apply policy
162
+ switch (this.sensitivePolicy) {
163
+ case 'hash_only':
164
+ return this.result(filePath, sha256, category, 'unknown', `Unknown to VT (${fileName}). Hash checked only — file NOT uploaded (privacy policy).`);
165
+ case 'always_upload':
166
+ return this.uploadSensitive(filePath, sha256, category, fileName);
167
+ case 'ask_once':
168
+ if (this.consentDecision === true) {
169
+ return this.uploadSensitive(filePath, sha256, category, fileName);
170
+ }
171
+ if (this.consentDecision === false) {
172
+ return this.result(filePath, sha256, category, 'unknown', `Unknown to VT (${fileName}). User previously declined upload — hash-only.`);
173
+ }
174
+ // First time — fall through to ask
175
+ return this.needsConsent(filePath, sha256, category, fileName);
176
+ case 'ask':
177
+ default:
178
+ return this.needsConsent(filePath, sha256, category, fileName);
179
+ }
180
+ }
181
+ /**
182
+ * Forced scan for SAFE/MEDIA files when user explicitly requests it.
183
+ * Always checks hash; does NOT upload (no privacy concern but no reason to upload safe files).
184
+ */
185
+ async scanForced(filePath, sha256, category) {
186
+ const fileName = path.basename(filePath);
187
+ await this.limiter.acquire();
188
+ const report = await this.api.checkHash(sha256);
189
+ if (report) {
190
+ return this.fromReport(filePath, sha256, category, report);
191
+ }
192
+ return this.result(filePath, sha256, category, 'unknown', `File "${fileName}" (${category}) not found in VT database. Hash checked — no threats known.`);
193
+ }
194
+ async uploadSensitive(filePath, sha256, category, fileName) {
195
+ this.logger.info(`[VT-Sentinel] Uploading SENSITIVE file ${fileName} (user consented)...`);
196
+ await this.limiter.acquire();
197
+ try {
198
+ const upload = await this.api.uploadFile(filePath);
199
+ return this.result(filePath, sha256, category, 'pending', `Uploaded with consent (${upload.analysisId}). May contain macros or embedded threats — analysis pending.`);
200
+ }
201
+ catch (err) {
202
+ return this.result(filePath, sha256, category, 'unknown', `Upload failed: ${err.message}`);
203
+ }
204
+ }
205
+ needsConsent(filePath, sha256, category, fileName) {
206
+ return this.result(filePath, sha256, category, 'needs_consent', `File "${fileName}" may contain private data (detected as ${category}). ` +
207
+ `Hash is unknown to VT. ` +
208
+ `Should I upload it to VirusTotal for deep analysis? ` +
209
+ `This would check for macros, embedded threats, and other risks, ` +
210
+ `but the file content will be shared with VirusTotal. ` +
211
+ `Reply YES to upload, NO for hash-only check.`);
212
+ }
213
+ /**
214
+ * Upload a file that the user previously consented to (after needs_consent).
215
+ */
216
+ async uploadWithConsent(filePath) {
217
+ if (!fs.existsSync(filePath)) {
218
+ return this.result(filePath, '', classifier_1.FileCategory.SENSITIVE, 'skipped', 'File not found');
219
+ }
220
+ const sha256 = await (0, vt_api_1.calculateSHA256)(filePath);
221
+ const category = classifier_1.FileClassifier.classify(filePath);
222
+ const fileName = path.basename(filePath);
223
+ // Remember consent for ask_once policy
224
+ if (this.sensitivePolicy === 'ask_once') {
225
+ this.consentDecision = true;
226
+ }
227
+ return this.uploadSensitive(filePath, sha256, category, fileName);
228
+ }
229
+ /**
230
+ * Quick hash check — no classification, no upload.
231
+ */
232
+ async checkHash(hash) {
233
+ await this.limiter.acquire();
234
+ const report = await this.api.checkHash(hash);
235
+ if (!report)
236
+ return null;
237
+ return this.fromReport('', hash, classifier_1.FileCategory.SAFE, report);
238
+ }
239
+ /**
240
+ * Build ScanResult from a VT report.
241
+ * Always extracts both AV detections AND Code Insight (if available).
242
+ * The final verdict is the worst of: AV engines + AI analysis.
243
+ */
244
+ fromReport(filePath, sha256, category, report) {
245
+ const stats = report.stats;
246
+ const total = stats.malicious + stats.suspicious + stats.harmless + stats.undetected;
247
+ // --- AV verdict ---
248
+ let verdict = 'clean';
249
+ const msgParts = [];
250
+ if (stats.malicious > 0) {
251
+ verdict = 'malicious';
252
+ msgParts.push(`AV: ${stats.malicious}/${total} engines detected malware`);
253
+ }
254
+ else if (stats.suspicious > 0) {
255
+ verdict = 'suspicious';
256
+ msgParts.push(`AV: ${stats.suspicious}/${total} engines flagged suspicious`);
257
+ }
258
+ else {
259
+ msgParts.push(`AV: clean (0/${total} detections)`);
260
+ }
261
+ const result = {
262
+ filePath,
263
+ fileName: filePath ? path.basename(filePath) : sha256.substring(0, 12),
264
+ sha256,
265
+ category,
266
+ verdict,
267
+ detections: { malicious: stats.malicious, suspicious: stats.suspicious, total },
268
+ vtLink: report.vtLink,
269
+ message: '', // set below
270
+ };
271
+ // --- Code Insight (always extract if present) ---
272
+ if (report.crowdsourcedAiResults && report.crowdsourcedAiResults.length > 0) {
273
+ const ci = report.crowdsourcedAiResults.find((r) => r.source?.toLowerCase().includes('code insight')) || report.crowdsourcedAiResults[0];
274
+ const ciVerdict = (ci.verdict || 'UNKNOWN').toUpperCase();
275
+ result.codeInsight = {
276
+ source: ci.source || 'AI',
277
+ analysis: ci.analysis || '',
278
+ verdict: ciVerdict,
279
+ };
280
+ // AI can escalate the verdict (never downgrade)
281
+ if (ciVerdict.includes('MALICIOUS') && verdict !== 'malicious') {
282
+ verdict = 'malicious';
283
+ result.verdict = verdict;
284
+ msgParts.push(`AI: MALICIOUS — ${ci.analysis?.substring(0, 200)}`);
285
+ }
286
+ else if (ciVerdict.includes('SUSPICIOUS') && verdict === 'clean') {
287
+ verdict = 'suspicious';
288
+ result.verdict = verdict;
289
+ msgParts.push(`AI: SUSPICIOUS — ${ci.analysis?.substring(0, 200)}`);
290
+ }
291
+ else {
292
+ msgParts.push(`AI: ${ciVerdict}`);
293
+ }
294
+ }
295
+ result.message = msgParts.join(' | ');
296
+ return result;
297
+ }
298
+ result(filePath, sha256, category, verdict, message) {
299
+ return {
300
+ filePath,
301
+ fileName: filePath ? path.basename(filePath) : '',
302
+ sha256,
303
+ category,
304
+ verdict,
305
+ message,
306
+ };
307
+ }
308
+ clearCache() {
309
+ this.cache.clear();
310
+ }
311
+ }
312
+ exports.Scanner = Scanner;
@@ -0,0 +1,69 @@
1
+ export interface VTAnalysisStats {
2
+ malicious: number;
3
+ suspicious: number;
4
+ harmless: number;
5
+ undetected: number;
6
+ }
7
+ export interface VTCrowdsourcedAiResult {
8
+ source: string;
9
+ analysis: string;
10
+ verdict?: string;
11
+ id?: string;
12
+ }
13
+ export interface VTReport {
14
+ hash: string;
15
+ stats: VTAnalysisStats;
16
+ name?: string;
17
+ crowdsourcedAiResults?: VTCrowdsourcedAiResult[];
18
+ vtLink: string;
19
+ }
20
+ export interface VTUploadResult {
21
+ analysisId: string;
22
+ message: string;
23
+ }
24
+ export interface AgentCredentials {
25
+ agentId: string;
26
+ agentToken: string;
27
+ publicHandle: string;
28
+ registeredAt: string;
29
+ }
30
+ /**
31
+ * Path to the cached agent credentials file.
32
+ * Stored in the OpenClaw state directory for persistence across sessions.
33
+ */
34
+ export declare function getAgentCredentialsPath(): string;
35
+ export declare function loadAgentCredentials(): AgentCredentials | null;
36
+ export declare function saveAgentCredentials(creds: AgentCredentials): void;
37
+ /**
38
+ * Register a new agent with VirusTotal AI API.
39
+ * No authentication required — zero-friction onboarding.
40
+ */
41
+ export declare function registerAgent(version?: string): Promise<AgentCredentials>;
42
+ export declare function calculateSHA256(filePath: string): Promise<string>;
43
+ export declare class VTApiClient {
44
+ private apiKey;
45
+ private baseUrl;
46
+ private vtai;
47
+ constructor(apiKey: string, useVtai?: boolean);
48
+ private headers;
49
+ /**
50
+ * Lookup a file hash in VirusTotal.
51
+ * Returns full report including AI results if available, or null if not found.
52
+ * Handles both standard VT and VTAI response formats.
53
+ */
54
+ checkHash(hash: string): Promise<VTReport | null>;
55
+ /**
56
+ * Parse standard VT API response (data.attributes.*)
57
+ */
58
+ private parseStandardReport;
59
+ /**
60
+ * Parse VTAI simplified response (data.* without attributes wrapper)
61
+ */
62
+ private parseVtaiReport;
63
+ /**
64
+ * Upload a file to VirusTotal for analysis.
65
+ * Standard VT: supports large files (>32MB) via upload_url endpoint.
66
+ * VTAI: max 32MB, no large file support.
67
+ */
68
+ uploadFile(filePath: string): Promise<VTUploadResult>;
69
+ }