openclaw-plugin-vt-sentinel 0.9.2 → 0.11.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.
@@ -39,6 +39,7 @@ exports.addInterestingDirs = addInterestingDirs;
39
39
  exports.getInterestingDirs = getInterestingDirs;
40
40
  exports.resetInterestingDirs = resetInterestingDirs;
41
41
  exports.detectDangerousPatterns = detectDangerousPatterns;
42
+ exports.getDangerousPatternCount = getDangerousPatternCount;
42
43
  exports.extractFromCommand = extractFromCommand;
43
44
  exports.extractFromOutput = extractFromOutput;
44
45
  exports.extractFromWriteTool = extractFromWriteTool;
@@ -129,7 +130,7 @@ const REDIRECT_PATTERNS = [
129
130
  /\|\s*Out-File\s+(?:-FilePath\s+)?["']?(\S+?)["']?(?:\s|$)/i,
130
131
  /\|\s*Set-Content\s+(?:-Path\s+)?["']?(\S+?)["']?(?:\s|$)/i,
131
132
  ];
132
- // Script execution patterns (the script itself is what we want to scan)
133
+ // Script execution targets the scripts themselves are what we want to scan.
133
134
  const EXEC_PATTERNS = [
134
135
  // Unix — shells and interpreters (dash = default /bin/sh on Debian/Ubuntu)
135
136
  /(?:bash|sh|zsh|dash|python3?|ruby|perl|node|pwsh|php)\s+["']?(\S+?)["']?(?:\s|$)/,
@@ -151,13 +152,13 @@ const EXEC_PATTERNS = [
151
152
  /rundll32(?:\.exe)?\s+["']?([A-Za-z]:[\\\/][^,"'\s]+)/i,
152
153
  /rundll32(?:\.exe)?\s+["']?(\\\\[^,"'\s]+)/i, // UNC path DLL
153
154
  // Archive extraction — scan the archive BEFORE extraction
154
- /tar\s+(?:[a-zA-Z-]*x[a-zA-Z-]*f?)\s+["']?(\S+?)["']?(?:\s|$)/, // tar xf, tar xzf, tar -xvf
155
- /unzip\s+(?:-[a-z]+\s+)*["']?(\S+?)["']?(?:\s|$)/, // unzip archive.zip
156
- /7z\s+[ex]\s+["']?(\S+?)["']?(?:\s|$)/, // 7z x archive.7z
155
+ /tar\s+(?:[a-zA-Z-]*x[a-zA-Z-]*f?)\s+["']?(\S+?)["']?(?:\s|$)/,
156
+ /unzip\s+(?:-[a-z]+\s+)*["']?(\S+?)["']?(?:\s|$)/,
157
+ /7z\s+[ex]\s+["']?(\S+?)["']?(?:\s|$)/,
157
158
  // Linux package install — scan the package before install
158
159
  /dpkg\s+(?:-i|--install)\s+["']?(\S+?)["']?(?:\s|$)/,
159
160
  /rpm\s+(?:-i|-U|--install|--upgrade)\s+["']?(\S+?)["']?(?:\s|$)/,
160
- /apt\s+install\s+["']?(\.\/\S+?)["']?(?:\s|$)/, // apt install ./local.deb
161
+ /apt\s+install\s+["']?(\.\/\S+?)["']?(?:\s|$)/,
161
162
  ];
162
163
  // ── Interesting directory detection (dynamic) ────────────────────────
163
164
  // Base dirs: always interesting (OS-level temp locations)
@@ -167,7 +168,7 @@ const BASE_INTERESTING_DIRS = (() => {
167
168
  dirs.add(fs.realpathSync(os.tmpdir()));
168
169
  }
169
170
  catch { /* ignore */ }
170
- // macOS: /tmp /private/tmp, /var/tmp /private/var/tmp
171
+ // macOS: /tmp -> /private/tmp, /var/tmp -> /private/var/tmp
171
172
  try {
172
173
  dirs.add(fs.realpathSync('/tmp'));
173
174
  }
@@ -259,151 +260,69 @@ function resetInterestingDirs() {
259
260
  _workspaceDir = undefined;
260
261
  initDirsFromEnv();
261
262
  }
262
- // ── Dangerous command patterns (no file on disk needed) ─────────────
263
- /**
264
- * Patterns that indicate dangerous command behavior regardless of whether
265
- * a malicious file exists on disk. These catch attacks that bypass file-based
266
- * scanning entirely (pipe-to-shell, SSH injection, data exfiltration, etc.)
267
- */
268
- // Pipe-to-shell: remote code piped directly to an interpreter without touching disk
269
- const PIPE_EXEC_PATTERNS = [
270
- // Unix allow optional sudo/env between pipe and interpreter
271
- { pattern: /curl\s+[^|]*\|\s*(?:sudo\s+)?(?:bash|sh|zsh|dash)\b/, description: 'curl piped to shell' },
272
- { pattern: /curl\s+[^|]*\|\s*(?:sudo\s+)?(?:python3?|ruby|perl|node|php|deno|bun)\b/, description: 'curl piped to interpreter' },
273
- { pattern: /wget\s+[^|]*\|\s*(?:sudo\s+)?(?:bash|sh|zsh|dash)\b/, description: 'wget piped to shell' },
274
- { pattern: /wget\s+-O\s*-[^|]*\|\s*(?:sudo\s+)?(?:bash|sh|zsh|dash)\b/, description: 'wget -O- piped to shell' },
275
- { pattern: /\|\s*(?:sudo\s+)?base64\s+-d\s*\|\s*(?:sudo\s+)?(?:bash|sh|zsh|dash)\b/, description: 'base64 decode piped to shell' },
276
- { pattern: /(?:nc|ncat|netcat|socat|openssl)\s+[^|]*\|\s*(?:sudo\s+)?(?:bash|sh|zsh|dash)\b/, description: 'network listener piped to shell' },
277
- { pattern: /eval\s*\$\(curl\b/, description: 'eval with curl subshell' },
278
- { pattern: /eval\s*\$\(wget\b/, description: 'eval with wget subshell' },
279
- { pattern: /\$\(curl\s[^)]*\)/, description: 'command substitution with curl' },
280
- { pattern: /\$\(wget\s[^)]*\)/, description: 'command substitution with wget' },
281
- { pattern: /`curl\s[^`]*`/, description: 'backtick curl execution' },
282
- { pattern: /`wget\s[^`]*`/, description: 'backtick wget execution' },
283
- // Process substitution (Linux/bash-specific fileless technique)
284
- { pattern: /(?:bash|sh|zsh|dash)\s+<\(\s*(?:curl|wget)\b/, description: 'process substitution: shell <(curl/wget ...) — fileless execution' },
285
- { pattern: /(?:source|\.)(?:\s+)<\(\s*(?:curl|wget)\b/, description: 'source <(curl/wget ...) — fileless sourcing of remote script' },
286
- // Linux reverse shells (/dev/tcp is bash built-in, no nc required)
287
- { pattern: /\/dev\/tcp\//, description: '/dev/tcp reverse shell (bash built-in network redirection)' },
288
- { pattern: /mkfifo\s+\S+\s*;.*(?:nc|ncat|netcat)\b/, description: 'mkfifo + netcat reverse shell pipeline' },
289
- { pattern: /mkfifo\s+\S+\s*&&.*(?:nc|ncat|netcat)\b/, description: 'mkfifo + netcat reverse shell pipeline' },
290
- // Windows PowerShell
291
- { pattern: /IEX\s*\(\s*(?:IWR|Invoke-WebRequest)\b/i, description: 'IEX(IWR ...) — PowerShell download-and-execute' },
292
- { pattern: /IEX\s*\(\s*\(?\s*New-Object\s+Net\.WebClient\b/i, description: 'IEX(New-Object Net.WebClient) — PowerShell download-and-execute' },
293
- { pattern: /Invoke-Expression\s*\(\s*(?:Invoke-WebRequest|IWR)\b/i, description: 'Invoke-Expression with web request' },
294
- { pattern: /Invoke-Expression\s*\(\s*\(?\s*New-Object\b/i, description: 'Invoke-Expression with New-Object' },
295
- { pattern: /powershell(?:\.exe)?\s+.*-(?:enc|EncodedCommand)\s+[A-Za-z0-9+\/=]{20,}/i, description: 'PowerShell encoded command execution' },
296
- { pattern: /powershell(?:\.exe)?\s+.*-(?:e|ec)\s+[A-Za-z0-9+\/=]{20,}/i, description: 'PowerShell short-flag encoded command' },
297
- { pattern: /\|\s*(?:powershell|pwsh)(?:\.exe)?\b/i, description: 'output piped to PowerShell' },
298
- // Windows cmd / LOLBins
299
- { pattern: /mshta\s+(?:javascript|vbscript)\s*:/i, description: 'mshta script execution' },
300
- { pattern: /mshta\s+https?:\/\//i, description: 'mshta URL execution — downloads and runs HTA application' },
301
- { pattern: /rundll32(?:\.exe)?\s+javascript:/i, description: 'rundll32 JavaScript execution' },
302
- { pattern: /regsvr32\s+.*\/i:https?:\/\//i, description: 'regsvr32 scriptlet download (Squiblydoo attack)' },
303
- { pattern: /regsvr32\s+.*\/i:\\\\/i, description: 'regsvr32 scriptlet from UNC path (Squiblydoo)' },
304
- // WMI/WMIC fileless execution
305
- { pattern: /wmic\s+.*process\s+call\s+create\b/i, description: 'WMIC process creation (fileless execution)' },
306
- { pattern: /Invoke-(?:Wmi|Cim)Method\s+.*(?:Win32_Process|Create)\b/i, description: 'WMI/CIM process creation (PowerShell fileless)' },
307
- // certutil abuse (LOLBin — base64 payload delivery via signed Microsoft binary)
308
- { pattern: /certutil\s+.*-decode\b/i, description: 'certutil -decode (base64 payload delivery)' },
309
- // macOS fileless techniques
310
- { pattern: /osascript\s+-e\s.*do\s+shell\s+script\b/i, description: 'osascript do shell script (AppleScript execution)' },
311
- { pattern: /osascript\s+-e\s.*(?:curl|wget|urllib|URLSession)\b/i, description: 'osascript with network fetch' },
312
- { pattern: /python3?\s+-c\s+.*(?:urllib|requests|urlopen|subprocess)\b/, description: 'python -c with network/exec indicators' },
313
- { pattern: /ruby\s+-e\s+.*(?:Net::HTTP|open-uri|system|exec)\b/, description: 'ruby -e with network/exec indicators' },
314
- { pattern: /perl\s+-e\s+.*(?:LWP|HTTP|system|exec)\b/, description: 'perl -e with network/exec indicators' },
315
- { pattern: /node\s+-e\s+.*(?:child_process|https?\.get|fetch|exec)\b/, description: 'node -e with network/exec indicators' },
316
- // PowerShell download cradle variants
317
- { pattern: /\.Content\s*\|\s*(?:iex|Invoke-Expression)\b/i, description: '.Content piped to IEX (download cradle)' },
318
- { pattern: /(?:irm|Invoke-RestMethod)\s+[^|]*\|\s*(?:iex|Invoke-Expression)\b/i, description: 'Invoke-RestMethod piped to IEX (download cradle)' },
319
- // UNC path to raw IP (possible SMB relay / NTLM hash capture)
320
- { pattern: /\\\\(?:\d{1,3}\.){3}\d{1,3}\\/, description: 'UNC path to IP address (SMB relay/NTLM capture risk)' },
321
- ];
322
- // SSH key injection: appending to authorized_keys
323
- const SSH_INJECTION_PATTERNS = [
324
- // Unix
325
- { pattern: />>\s*~?\/?[^\s]*authorized_keys/, description: 'SSH authorized_keys append' },
326
- { pattern: />>\s*~?\/?\.ssh\//, description: 'append to .ssh directory' },
327
- { pattern: /tee\s+-a\s+[^\s]*authorized_keys/, description: 'tee append to authorized_keys' },
328
- { pattern: /echo\s+.*ssh-(?:rsa|ed25519|ecdsa)\b/, description: 'echo SSH public key' },
329
- // Windows (same concept, Windows-style paths)
330
- { pattern: />>?\s*[A-Za-z]:[\\\/][^\s]*[\\\/]\.ssh[\\\/]authorized_keys/i, description: 'SSH authorized_keys append (Windows path)' },
331
- { pattern: /Add-Content\s+.*authorized_keys/i, description: 'Add-Content to authorized_keys (PowerShell)' },
332
- { pattern: /Out-File\s+.*-Append\s+.*authorized_keys/i, description: 'Out-File -Append to authorized_keys (PowerShell)' },
333
- ];
334
- // Data exfiltration: sending data to known exfiltration endpoints
335
- const EXFIL_DOMAINS = 'webhook\\.site|requestbin\\.com|pipedream\\.net|hookbin\\.com|burpcollaborator\\.net|interact\\.sh|canarytokens\\.com|oastify\\.com';
336
- const EXFILTRATION_PATTERNS = [
337
- // Unix
338
- { pattern: new RegExp(`(?:curl|wget|fetch)\\s+[^|]*(?:${EXFIL_DOMAINS})`), description: 'data sent to known exfiltration endpoint' },
339
- { pattern: /(?:curl|wget)\s+.*-(?:d|X\s*POST|--data)\s.*(?:\.env|passwd|shadow|id_rsa|credentials)/, description: 'sensitive file content sent via HTTP' },
340
- { pattern: /cat\s+[^\s]*(?:\.env|\.aws\/credentials|id_rsa|\.ssh\/|\.gnupg\/)[^\s]*\s*\|.*(?:curl|wget|nc)\b/, description: 'sensitive file piped to network tool' },
341
- { pattern: /(?:curl|wget)\s+.*-(?:d|X\s*POST|--data)\s.*\$\(cat\b/, description: 'file content sent via curl/wget POST' },
342
- // Windows PowerShell
343
- { pattern: new RegExp(`(?:Invoke-WebRequest|Invoke-RestMethod|iwr|irm)\\s+.*(?:${EXFIL_DOMAINS})`, 'i'), description: 'data sent to exfiltration endpoint (PowerShell)' },
344
- { pattern: /\[Net\.WebClient\].*Upload(?:String|Data|File)\s*\(/i, description: 'Net.WebClient upload (PowerShell exfiltration)' },
345
- { pattern: /\.UploadString\s*\(\s*["']https?:\/\//i, description: 'UploadString to URL (PowerShell exfiltration)' },
346
- ];
347
- // Windows persistence patterns (scheduled tasks, services, registry Run keys)
348
- const PERSISTENCE_PATTERNS = [
349
- // Scheduled tasks (#1 Windows persistence mechanism)
350
- { pattern: /schtasks\s+\/create\b/i, description: 'Scheduled task creation (persistence — survives reboot)' },
351
- { pattern: /Register-ScheduledTask\b/i, description: 'Register-ScheduledTask (PowerShell persistence)' },
352
- // Registry Run keys (#2 persistence mechanism)
353
- { pattern: /reg\s+add\s+["']?HK(?:CU|LM).*\\(?:Run|RunOnce)\b/i, description: 'Registry Run key modification (auto-start persistence)' },
354
- { pattern: /(?:Set-ItemProperty|New-ItemProperty)\s+.*\\(?:Run|RunOnce)\b/i, description: 'Registry Run key via PowerShell (auto-start persistence)' },
355
- // Windows service creation (runs as SYSTEM)
356
- { pattern: /sc\s+create\s+\w+\s+binPath=/i, description: 'Windows service creation (SYSTEM persistence)' },
357
- { pattern: /New-Service\b/i, description: 'New-Service (PowerShell service creation)' },
358
- ];
359
- // Credential/secret access patterns
360
- const CREDENTIAL_PATTERNS = [
361
- // Unix
362
- { pattern: /cat\s+[^\s]*\.env\b[^\s]*\s*\|/, description: '.env file piped to another command' },
363
- { pattern: /cat\s+[^\s]*id_rsa\b[^\s]*\s*\|/, description: 'SSH private key piped' },
364
- { pattern: /cat\s+[^\s]*\.aws\/credentials\b[^\s]*\s*\|/, description: 'AWS credentials piped' },
365
- { pattern: /(?:curl|wget)\s+.*--header.*(?:Authorization|Bearer|Token)\s*:\s*\$/, description: 'dynamic auth header exfiltration' },
366
- // Windows
367
- { pattern: /(?:type|Get-Content|gc)\s+[^\s|]*\.env\b[^\s|]*\s*\|/i, description: '.env read and piped (Windows)' },
368
- { pattern: /(?:type|Get-Content|gc)\s+[^\s|]*id_rsa\b[^\s|]*\s*\|/i, description: 'SSH private key read and piped (Windows)' },
369
- { pattern: /(?:type|Get-Content|gc)\s+[^\s|]*[\\\/]\.aws[\\\/]credentials\b[^\s|]*\s*\|/i, description: 'AWS credentials read and piped (Windows)' },
370
- { pattern: /\$env:\w+.*\|\s*(?:Invoke-WebRequest|Invoke-RestMethod|iwr|irm)\b/i, description: 'environment variable piped to web request (PowerShell)' },
371
- ];
263
+ const SIGNATURE_CATEGORY_MAP = {
264
+ pipeExec: { category: 'pipe_execution', severity: 'critical' },
265
+ sshInjection: { category: 'ssh_injection', severity: 'critical' },
266
+ exfiltration: { category: 'data_exfiltration', severity: 'high' },
267
+ persistence: { category: 'persistence', severity: 'high' },
268
+ credential: { category: 'credential_access', severity: 'high' },
269
+ };
270
+ const DANGEROUS_SIGNATURES = (() => {
271
+ const compiled = [];
272
+ const jsonPath = path.join(__dirname, 'signatures', 'dangerous-commands.json');
273
+ let data;
274
+ try {
275
+ data = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
276
+ }
277
+ catch (err) {
278
+ console.error(`[vt-sentinel] failed to load dangerous-commands.json at ${jsonPath}: ${String(err)}`);
279
+ return compiled;
280
+ }
281
+ for (const key of Object.keys(SIGNATURE_CATEGORY_MAP)) {
282
+ const entries = data[key];
283
+ if (!Array.isArray(entries))
284
+ continue;
285
+ const meta = SIGNATURE_CATEGORY_MAP[key];
286
+ for (const entry of entries) {
287
+ try {
288
+ compiled.push({
289
+ pattern: new RegExp(entry.pattern, entry.flags || ''),
290
+ description: entry.description,
291
+ category: meta.category,
292
+ severity: meta.severity,
293
+ });
294
+ }
295
+ catch (err) {
296
+ console.error(`[vt-sentinel] invalid signature pattern in ${key}: ${entry.description} — ${String(err)}`);
297
+ }
298
+ }
299
+ }
300
+ return compiled;
301
+ })();
372
302
  /**
373
303
  * Detect dangerous patterns in a command string that indicate attacks
374
304
  * which bypass file-based scanning (pipe-to-shell, SSH injection, exfiltration).
375
- *
376
305
  * Returns all matched patterns, or an empty array if the command is safe.
377
306
  */
378
307
  function detectDangerousPatterns(command) {
379
308
  const matches = [];
380
- for (const { pattern, description } of PIPE_EXEC_PATTERNS) {
381
- if (pattern.test(command)) {
382
- matches.push({ category: 'pipe_execution', description, severity: 'critical' });
383
- }
384
- }
385
- for (const { pattern, description } of SSH_INJECTION_PATTERNS) {
386
- if (pattern.test(command)) {
387
- matches.push({ category: 'ssh_injection', description, severity: 'critical' });
388
- }
389
- }
390
- for (const { pattern, description } of EXFILTRATION_PATTERNS) {
391
- if (pattern.test(command)) {
392
- matches.push({ category: 'data_exfiltration', description, severity: 'high' });
393
- }
394
- }
395
- for (const { pattern, description } of CREDENTIAL_PATTERNS) {
396
- if (pattern.test(command)) {
397
- matches.push({ category: 'credential_access', description, severity: 'high' });
398
- }
399
- }
400
- for (const { pattern, description } of PERSISTENCE_PATTERNS) {
401
- if (pattern.test(command)) {
402
- matches.push({ category: 'persistence', description, severity: 'high' });
309
+ for (const sig of DANGEROUS_SIGNATURES) {
310
+ if (sig.pattern.test(command)) {
311
+ matches.push({ category: sig.category, description: sig.description, severity: sig.severity });
403
312
  }
404
313
  }
405
314
  return matches;
406
315
  }
316
+ /**
317
+ * Return the number of compiled signatures (diagnostics/tests).
318
+ */
319
+ function getDangerousPatternCount() {
320
+ return DANGEROUS_SIGNATURES.length;
321
+ }
322
+ /** Build a global-flagged copy of a regex so matchAll can iterate. */
323
+ function toGlobal(re) {
324
+ return re.flags.includes('g') ? re : new RegExp(re.source, re.flags + 'g');
325
+ }
407
326
  /**
408
327
  * Extract file paths from an exec tool command string.
409
328
  */
@@ -417,52 +336,38 @@ function extractFromCommand(command) {
417
336
  seen.add(clean);
418
337
  results.push({ path: clean, source, reason });
419
338
  };
420
- // Download targets (curl -o, wget -O) — global exec to catch multiple in one command
339
+ // Download targets (curl -o, wget -O) — iterate via matchAll to catch multiple in one command
421
340
  for (const re of DOWNLOAD_PATTERNS) {
422
- const globalRe = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
423
- let m;
424
- while ((m = globalRe.exec(command)) !== null) {
341
+ for (const m of command.matchAll(toGlobal(re))) {
425
342
  if (m[1])
426
343
  add(m[1], 'download_target', `Download target in: ${re.source}`);
427
344
  }
428
345
  }
429
346
  // Redirect targets (> file, tee file)
430
347
  for (const re of REDIRECT_PATTERNS) {
431
- const globalRe = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
432
- let m;
433
- while ((m = globalRe.exec(command)) !== null) {
348
+ for (const m of command.matchAll(toGlobal(re))) {
434
349
  if (m[1] && m[1] !== '/dev/null')
435
350
  add(m[1], 'redirect_target', `Output redirected to file`);
436
351
  }
437
352
  }
438
353
  // Script execution targets
439
354
  for (const re of EXEC_PATTERNS) {
440
- const globalRe = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
441
- let m;
442
- while ((m = globalRe.exec(command)) !== null) {
355
+ for (const m of command.matchAll(toGlobal(re))) {
443
356
  if (m[1])
444
357
  add(m[1], 'exec_target', `Script being executed`);
445
358
  }
446
359
  }
447
360
  // Windows %ENV_VAR%\path expansion (e.g., %TEMP%\payload.exe)
448
- {
449
- const envRe = new RegExp(WIN_ENV_PATH_RE.source, WIN_ENV_PATH_RE.flags);
450
- let m;
451
- while ((m = envRe.exec(command)) !== null) {
452
- const expanded = expandWinEnvVar(m[1], m[2]);
453
- if (expanded)
454
- add(expanded, 'command_param', `Expanded Windows env var path %${m[1]}%`);
455
- }
361
+ for (const m of command.matchAll(toGlobal(WIN_ENV_PATH_RE))) {
362
+ const expanded = expandWinEnvVar(m[1], m[2]);
363
+ if (expanded)
364
+ add(expanded, 'command_param', `Expanded Windows env var path %${m[1]}%`);
456
365
  }
457
366
  // PowerShell $env:VAR\path expansion (e.g., $env:TEMP\payload.exe)
458
- {
459
- const psRe = new RegExp(PS_ENV_PATH_RE.source, PS_ENV_PATH_RE.flags);
460
- let m;
461
- while ((m = psRe.exec(command)) !== null) {
462
- const expanded = expandWinEnvVar(m[1], m[2]);
463
- if (expanded)
464
- add(expanded, 'command_param', `Expanded PowerShell env path $env:${m[1]}`);
465
- }
367
+ for (const m of command.matchAll(toGlobal(PS_ENV_PATH_RE))) {
368
+ const expanded = expandWinEnvVar(m[1], m[2]);
369
+ if (expanded)
370
+ add(expanded, 'command_param', `Expanded PowerShell env path $env:${m[1]}`);
466
371
  }
467
372
  return results;
468
373
  }
@@ -548,31 +453,20 @@ function extractFromOutput(output) {
548
453
  results.push({ path: clean, source: 'tool_output', reason: 'File path found in tool output' });
549
454
  };
550
455
  // Unix paths (unquoted)
551
- let match;
552
- const unixRe = new RegExp(UNIX_PATH_RE.source, UNIX_PATH_RE.flags);
553
- while ((match = unixRe.exec(output)) !== null) {
554
- addPath(match[1]);
555
- }
456
+ for (const m of output.matchAll(toGlobal(UNIX_PATH_RE)))
457
+ addPath(m[1]);
556
458
  // Unix quoted paths (handles spaces like "/Users/foo/Application Support/...")
557
- const unixQuotedRe = new RegExp(UNIX_QUOTED_PATH_RE.source, UNIX_QUOTED_PATH_RE.flags);
558
- while ((match = unixQuotedRe.exec(output)) !== null) {
559
- addPath(match[1]);
560
- }
459
+ for (const m of output.matchAll(toGlobal(UNIX_QUOTED_PATH_RE)))
460
+ addPath(m[1]);
561
461
  // Windows paths (unquoted)
562
- const winRe = new RegExp(WIN_PATH_RE.source, WIN_PATH_RE.flags);
563
- while ((match = winRe.exec(output)) !== null) {
564
- addPath(match[1]);
565
- }
462
+ for (const m of output.matchAll(toGlobal(WIN_PATH_RE)))
463
+ addPath(m[1]);
566
464
  // Windows quoted paths (handles spaces)
567
- const winQuotedRe = new RegExp(WIN_QUOTED_PATH_RE.source, WIN_QUOTED_PATH_RE.flags);
568
- while ((match = winQuotedRe.exec(output)) !== null) {
569
- addPath(match[1]);
570
- }
465
+ for (const m of output.matchAll(toGlobal(WIN_QUOTED_PATH_RE)))
466
+ addPath(m[1]);
571
467
  // UNC paths (\\server\share\file)
572
- const uncRe = new RegExp(UNC_PATH_RE.source, UNC_PATH_RE.flags);
573
- while ((match = uncRe.exec(output)) !== null) {
574
- addPath(match[1]);
575
- }
468
+ for (const m of output.matchAll(toGlobal(UNC_PATH_RE)))
469
+ addPath(m[1]);
576
470
  return results;
577
471
  }
578
472
  /**
@@ -593,9 +487,6 @@ function extractFromReadTool(params) {
593
487
  return [];
594
488
  return [{ path: p, source: 'read_target', reason: 'File read by agent' }];
595
489
  }
596
- /**
597
- * Filter paths to only include files that actually exist on disk.
598
- */
599
490
  /**
600
491
  * Expand ~ to $HOME (Node doesn't expand tilde in fs operations).
601
492
  */
@@ -648,59 +539,47 @@ function filterExisting(paths) {
648
539
  */
649
540
  function extractAllPaths(command) {
650
541
  const paths = new Set();
651
- let match;
652
542
  // Unix paths (unquoted)
653
- const unixRe = new RegExp(UNIX_PATH_RE.source, UNIX_PATH_RE.flags);
654
- while ((match = unixRe.exec(command)) !== null) {
655
- const p = match[1].replace(/["']+$/g, '').replace(/[.,;:!?)}\]]+$/, '');
543
+ for (const m of command.matchAll(toGlobal(UNIX_PATH_RE))) {
544
+ const p = m[1].replace(/["']+$/g, '').replace(/[.,;:!?)}\]]+$/, '');
656
545
  if (p.length >= 2)
657
546
  paths.add(p);
658
547
  }
659
548
  // Unix quoted paths (handles spaces)
660
- const unixQuotedRe = new RegExp(UNIX_QUOTED_PATH_RE.source, UNIX_QUOTED_PATH_RE.flags);
661
- while ((match = unixQuotedRe.exec(command)) !== null) {
662
- const p = match[1].replace(/[.,;:!?)}\]]+$/, '');
549
+ for (const m of command.matchAll(toGlobal(UNIX_QUOTED_PATH_RE))) {
550
+ const p = m[1].replace(/[.,;:!?)}\]]+$/, '');
663
551
  if (p.length >= 2)
664
552
  paths.add(p);
665
553
  }
666
554
  // Windows paths (unquoted)
667
- const winRe = new RegExp(WIN_PATH_RE.source, WIN_PATH_RE.flags);
668
- while ((match = winRe.exec(command)) !== null) {
669
- const p = match[1].replace(/["']+$/g, '').replace(/[.,;:!?)}\]]+$/, '');
555
+ for (const m of command.matchAll(toGlobal(WIN_PATH_RE))) {
556
+ const p = m[1].replace(/["']+$/g, '').replace(/[.,;:!?)}\]]+$/, '');
670
557
  if (p.length >= 4)
671
558
  paths.add(p); // minimum: C:\x
672
559
  }
673
560
  // Windows quoted paths (handles spaces like C:\Program Files\...)
674
- const winQuotedRe = new RegExp(WIN_QUOTED_PATH_RE.source, WIN_QUOTED_PATH_RE.flags);
675
- while ((match = winQuotedRe.exec(command)) !== null) {
676
- const p = match[1].replace(/[.,;:!?)}\]]+$/, '');
561
+ for (const m of command.matchAll(toGlobal(WIN_QUOTED_PATH_RE))) {
562
+ const p = m[1].replace(/[.,;:!?)}\]]+$/, '');
677
563
  if (p.length >= 4)
678
564
  paths.add(p);
679
565
  }
680
566
  // UNC paths (\\server\share\file)
681
- const uncRe = new RegExp(UNC_PATH_RE.source, UNC_PATH_RE.flags);
682
- while ((match = uncRe.exec(command)) !== null) {
683
- const p = match[1].replace(/["']+$/g, '').replace(/[.,;:!?)}\]]+$/, '');
567
+ for (const m of command.matchAll(toGlobal(UNC_PATH_RE))) {
568
+ const p = m[1].replace(/["']+$/g, '').replace(/[.,;:!?)}\]]+$/, '');
684
569
  if (p.length >= 4)
685
570
  paths.add(p);
686
571
  }
687
572
  // Windows %ENV%\path expansion
688
- {
689
- const envRe = new RegExp(WIN_ENV_PATH_RE.source, WIN_ENV_PATH_RE.flags);
690
- while ((match = envRe.exec(command)) !== null) {
691
- const expanded = expandWinEnvVar(match[1], match[2]);
692
- if (expanded)
693
- paths.add(expanded);
694
- }
573
+ for (const m of command.matchAll(toGlobal(WIN_ENV_PATH_RE))) {
574
+ const expanded = expandWinEnvVar(m[1], m[2]);
575
+ if (expanded)
576
+ paths.add(expanded);
695
577
  }
696
578
  // PowerShell $env:VAR\path expansion
697
- {
698
- const psRe = new RegExp(PS_ENV_PATH_RE.source, PS_ENV_PATH_RE.flags);
699
- while ((match = psRe.exec(command)) !== null) {
700
- const expanded = expandWinEnvVar(match[1], match[2]);
701
- if (expanded)
702
- paths.add(expanded);
703
- }
579
+ for (const m of command.matchAll(toGlobal(PS_ENV_PATH_RE))) {
580
+ const expanded = expandWinEnvVar(m[1], m[2]);
581
+ if (expanded)
582
+ paths.add(expanded);
704
583
  }
705
584
  return Array.from(paths);
706
585
  }
package/dist/scanner.d.ts CHANGED
@@ -34,35 +34,42 @@ export declare class Scanner {
34
34
  private limiter;
35
35
  private maxFileSizeMb;
36
36
  private sensitivePolicy;
37
- /** In-memory consent cache for "ask_once" policy. null = not yet asked. */
37
+ private semanticPolicy;
38
+ /** In-memory consent cache for "ask_once" policy on sensitive files. null = not yet asked. */
38
39
  private consentDecision;
40
+ /** In-memory consent cache for "ask_once" policy on semantic files. null = not yet asked. */
41
+ private consentDecisionSemantic;
39
42
  private logger;
40
43
  constructor(apiKey: string, logger: {
41
44
  info: (m: string) => void;
42
45
  warn: (m: string) => void;
43
46
  error: (m: string) => void;
44
- }, maxFileSizeMb?: number, sensitivePolicy?: SensitiveFilePolicy, useVtai?: boolean);
47
+ }, maxFileSizeMb?: number, sensitivePolicy?: SensitiveFilePolicy, useVtai?: boolean, semanticPolicy?: SensitiveFilePolicy);
45
48
  /**
46
49
  * Record the user's consent decision (used by the tool when the user responds).
47
50
  * When policy is "ask_once", this persists for the session.
51
+ * @param consentGroup — 'sensitive' or 'semantic', determines which consent cache to update.
48
52
  */
49
- recordConsent(upload: boolean): void;
53
+ recordConsent(upload: boolean, consentGroup?: 'sensitive' | 'semantic'): void;
50
54
  /** Update maxFileSizeMb at runtime without rebuilding scanner. */
51
55
  updateMaxFileSizeMb(mb: number): void;
52
56
  /** Update sensitiveFilePolicy at runtime. Resets consent decision. */
53
57
  updateSensitivePolicy(policy: SensitiveFilePolicy): void;
58
+ /** Update semanticFilePolicy at runtime. Resets semantic consent decision. */
59
+ updateSemanticPolicy(policy: SensitiveFilePolicy): void;
54
60
  /**
55
61
  * Full scan of a file: classify → hash → VT lookup → code insight if applicable.
56
62
  * When force=true (manual scan), always do at least a hash check even for SAFE/MEDIA files.
63
+ * When hashOnly=true: only check hash against VT, never upload regardless of category.
57
64
  */
58
- scanFile(filePath: string, force?: boolean, precomputedHash?: string): Promise<ScanResult>;
65
+ scanFile(filePath: string, force?: boolean, precomputedHash?: string, hashOnly?: boolean): Promise<ScanResult>;
59
66
  /**
60
67
  * Scan a HIGH_RISK or SEMANTIC_RISK file: hash lookup → upload if unknown.
61
68
  * Code Insight is extracted automatically by fromReport() (same as all categories).
62
69
  */
63
70
  private scanAutoUpload;
64
71
  /**
65
- * Scan a SENSITIVE file according to the configured policy.
72
+ * Scan a SENSITIVE or SEMANTIC_RISK file according to the given policy.
66
73
  *
67
74
  * Step 1 (always): Check hash — this reveals nothing about file content.
68
75
  * - If VT knows the hash → report findings (malicious PDF templates, etc.)
@@ -72,6 +79,8 @@ export declare class Scanner {
72
79
  * - "always_upload" → upload to VT
73
80
  * - "ask" → return needs_consent every time
74
81
  * - "ask_once" → return needs_consent the first time, then use remembered decision
82
+ *
83
+ * @param consentGroup — 'sensitive' or 'semantic', determines which consent cache to use
75
84
  */
76
85
  private scanSensitive;
77
86
  /**
@@ -79,6 +88,11 @@ export declare class Scanner {
79
88
  * Always checks hash; does NOT upload (no privacy concern but no reason to upload safe files).
80
89
  */
81
90
  private scanForced;
91
+ /**
92
+ * Hash-only check: rate-limited hash lookup, never uploads.
93
+ * Used when hashOnly=true (e.g., files read by the agent).
94
+ */
95
+ private hashCheckOnly;
82
96
  private uploadSensitive;
83
97
  private needsConsent;
84
98
  /**