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.
- package/CHANGELOG.md +128 -0
- package/README.md +50 -2
- package/dist/config-manager.d.ts +2 -0
- package/dist/config-manager.js +17 -1
- package/dist/env-access.d.ts +18 -0
- package/dist/env-access.js +27 -0
- package/dist/index.d.ts +3 -7
- package/dist/index.js +124 -100
- package/dist/path-extractor.d.ts +6 -2
- package/dist/path-extractor.js +99 -220
- package/dist/scanner.d.ts +19 -5
- package/dist/scanner.js +62 -14
- package/dist/signatures/dangerous-commands.json +82 -0
- package/dist/status-renderer.js +14 -4
- package/dist/vt-api.d.ts +3 -13
- package/dist/vt-api.js +10 -38
- package/dist/vt-credentials.d.ts +27 -0
- package/dist/vt-credentials.js +96 -0
- package/hooks/vt-auto-scan/HOOK.md +8 -0
- package/hooks/vt-auto-scan/handler.js +78 -10
- package/openclaw.plugin.json +24 -1
- package/package.json +24 -6
- package/skills/vt-sentinel/SKILL.md +6 -3
package/dist/path-extractor.js
CHANGED
|
@@ -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
|
|
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|$)/,
|
|
155
|
-
/unzip\s+(?:-[a-z]+\s+)*["']?(\S+?)["']?(?:\s|$)/,
|
|
156
|
-
/7z\s+[ex]\s+["']?(\S+?)["']?(?:\s|$)/,
|
|
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|$)/,
|
|
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
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
{
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
381
|
-
if (pattern.test(command)) {
|
|
382
|
-
matches.push({ category:
|
|
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) —
|
|
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
|
|
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
|
|
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
|
|
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
|
|
450
|
-
|
|
451
|
-
|
|
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
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
552
|
-
|
|
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
|
|
558
|
-
|
|
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
|
|
563
|
-
|
|
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
|
|
568
|
-
|
|
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
|
|
573
|
-
|
|
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
|
|
654
|
-
|
|
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
|
|
661
|
-
|
|
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
|
|
668
|
-
|
|
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
|
|
675
|
-
|
|
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
|
|
682
|
-
|
|
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
|
|
690
|
-
|
|
691
|
-
|
|
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
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
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
|
|
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
|
/**
|