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/scanner.js
CHANGED
|
@@ -41,22 +41,31 @@ const classifier_1 = require("./classifier");
|
|
|
41
41
|
const cache_1 = require("./cache");
|
|
42
42
|
// --- Scanner ---
|
|
43
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. */
|
|
44
|
+
constructor(apiKey, logger, maxFileSizeMb = 32, sensitivePolicy = 'ask', useVtai = false, semanticPolicy = 'hash_only') {
|
|
45
|
+
/** In-memory consent cache for "ask_once" policy on sensitive files. null = not yet asked. */
|
|
46
46
|
this.consentDecision = null;
|
|
47
|
+
/** In-memory consent cache for "ask_once" policy on semantic files. null = not yet asked. */
|
|
48
|
+
this.consentDecisionSemantic = null;
|
|
47
49
|
this.api = new vt_api_1.VTApiClient(apiKey, useVtai);
|
|
48
50
|
this.cache = new cache_1.Cache(15);
|
|
49
51
|
this.limiter = new cache_1.RateLimiter(4);
|
|
50
52
|
this.maxFileSizeMb = maxFileSizeMb;
|
|
51
53
|
this.sensitivePolicy = sensitivePolicy;
|
|
54
|
+
this.semanticPolicy = semanticPolicy;
|
|
52
55
|
this.logger = logger;
|
|
53
56
|
}
|
|
54
57
|
/**
|
|
55
58
|
* Record the user's consent decision (used by the tool when the user responds).
|
|
56
59
|
* When policy is "ask_once", this persists for the session.
|
|
60
|
+
* @param consentGroup — 'sensitive' or 'semantic', determines which consent cache to update.
|
|
57
61
|
*/
|
|
58
|
-
recordConsent(upload) {
|
|
59
|
-
|
|
62
|
+
recordConsent(upload, consentGroup = 'sensitive') {
|
|
63
|
+
if (consentGroup === 'semantic') {
|
|
64
|
+
this.consentDecisionSemantic = upload;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
this.consentDecision = upload;
|
|
68
|
+
}
|
|
60
69
|
}
|
|
61
70
|
/** Update maxFileSizeMb at runtime without rebuilding scanner. */
|
|
62
71
|
updateMaxFileSizeMb(mb) {
|
|
@@ -67,11 +76,17 @@ class Scanner {
|
|
|
67
76
|
this.sensitivePolicy = policy;
|
|
68
77
|
this.consentDecision = null;
|
|
69
78
|
}
|
|
79
|
+
/** Update semanticFilePolicy at runtime. Resets semantic consent decision. */
|
|
80
|
+
updateSemanticPolicy(policy) {
|
|
81
|
+
this.semanticPolicy = policy;
|
|
82
|
+
this.consentDecisionSemantic = null;
|
|
83
|
+
}
|
|
70
84
|
/**
|
|
71
85
|
* Full scan of a file: classify → hash → VT lookup → code insight if applicable.
|
|
72
86
|
* When force=true (manual scan), always do at least a hash check even for SAFE/MEDIA files.
|
|
87
|
+
* When hashOnly=true: only check hash against VT, never upload regardless of category.
|
|
73
88
|
*/
|
|
74
|
-
async scanFile(filePath, force = false, precomputedHash) {
|
|
89
|
+
async scanFile(filePath, force = false, precomputedHash, hashOnly = false) {
|
|
75
90
|
const fileName = path.basename(filePath);
|
|
76
91
|
if (!fs.existsSync(filePath)) {
|
|
77
92
|
return this.result(filePath, '', classifier_1.FileCategory.SAFE, 'skipped', `File not found: ${fileName}`);
|
|
@@ -92,6 +107,15 @@ class Scanner {
|
|
|
92
107
|
// Cache entries are keyed by SHA-256 (VT identity). Rebase per-file context.
|
|
93
108
|
return { ...cached, filePath, fileName, category, sha256 };
|
|
94
109
|
}
|
|
110
|
+
// hashOnly mode: only check hash, never upload (used for read_target scans)
|
|
111
|
+
if (hashOnly) {
|
|
112
|
+
const report = await this.hashCheckOnly(filePath, sha256, category);
|
|
113
|
+
if (report && (report.verdict === 'clean' || report.verdict === 'malicious' ||
|
|
114
|
+
report.verdict === 'suspicious' || report.verdict === 'pending')) {
|
|
115
|
+
this.cache.set(sha256, report);
|
|
116
|
+
}
|
|
117
|
+
return report;
|
|
118
|
+
}
|
|
95
119
|
let result;
|
|
96
120
|
// When force=true (code dirs), treat all files as HIGH_RISK: hash + auto-upload.
|
|
97
121
|
// Media/safe files in skills/hooks/extensions dirs are anomalous and should be fully analyzed.
|
|
@@ -100,11 +124,13 @@ class Scanner {
|
|
|
100
124
|
: category;
|
|
101
125
|
switch (effectiveCategory) {
|
|
102
126
|
case classifier_1.FileCategory.HIGH_RISK:
|
|
103
|
-
case classifier_1.FileCategory.SEMANTIC_RISK:
|
|
104
127
|
result = await this.scanAutoUpload(filePath, sha256, effectiveCategory);
|
|
105
128
|
break;
|
|
129
|
+
case classifier_1.FileCategory.SEMANTIC_RISK:
|
|
130
|
+
result = await this.scanSensitive(filePath, sha256, effectiveCategory, this.semanticPolicy, 'semantic');
|
|
131
|
+
break;
|
|
106
132
|
case classifier_1.FileCategory.SENSITIVE:
|
|
107
|
-
result = await this.scanSensitive(filePath, sha256, effectiveCategory);
|
|
133
|
+
result = await this.scanSensitive(filePath, sha256, effectiveCategory, this.sensitivePolicy, 'sensitive');
|
|
108
134
|
break;
|
|
109
135
|
case classifier_1.FileCategory.MEDIA:
|
|
110
136
|
case classifier_1.FileCategory.SAFE:
|
|
@@ -146,7 +172,7 @@ class Scanner {
|
|
|
146
172
|
}
|
|
147
173
|
}
|
|
148
174
|
/**
|
|
149
|
-
* Scan a SENSITIVE file according to the
|
|
175
|
+
* Scan a SENSITIVE or SEMANTIC_RISK file according to the given policy.
|
|
150
176
|
*
|
|
151
177
|
* Step 1 (always): Check hash — this reveals nothing about file content.
|
|
152
178
|
* - If VT knows the hash → report findings (malicious PDF templates, etc.)
|
|
@@ -156,8 +182,10 @@ class Scanner {
|
|
|
156
182
|
* - "always_upload" → upload to VT
|
|
157
183
|
* - "ask" → return needs_consent every time
|
|
158
184
|
* - "ask_once" → return needs_consent the first time, then use remembered decision
|
|
185
|
+
*
|
|
186
|
+
* @param consentGroup — 'sensitive' or 'semantic', determines which consent cache to use
|
|
159
187
|
*/
|
|
160
|
-
async scanSensitive(filePath, sha256, category) {
|
|
188
|
+
async scanSensitive(filePath, sha256, category, policy, consentGroup) {
|
|
161
189
|
const fileName = path.basename(filePath);
|
|
162
190
|
// Step 1: Hash check (always safe, reveals nothing)
|
|
163
191
|
await this.limiter.acquire();
|
|
@@ -167,17 +195,19 @@ class Scanner {
|
|
|
167
195
|
result.message += ' (hash-only check — file NOT uploaded to VT)';
|
|
168
196
|
return result;
|
|
169
197
|
}
|
|
198
|
+
// Resolve consent for ask_once from the appropriate group
|
|
199
|
+
const consent = consentGroup === 'semantic' ? this.consentDecisionSemantic : this.consentDecision;
|
|
170
200
|
// Step 2: Hash unknown — apply policy
|
|
171
|
-
switch (
|
|
201
|
+
switch (policy) {
|
|
172
202
|
case 'hash_only':
|
|
173
203
|
return this.result(filePath, sha256, category, 'unknown', `Unknown to VT (${fileName}). Hash checked only — file NOT uploaded (privacy policy).`);
|
|
174
204
|
case 'always_upload':
|
|
175
205
|
return this.uploadSensitive(filePath, sha256, category, fileName);
|
|
176
206
|
case 'ask_once':
|
|
177
|
-
if (
|
|
207
|
+
if (consent === true) {
|
|
178
208
|
return this.uploadSensitive(filePath, sha256, category, fileName);
|
|
179
209
|
}
|
|
180
|
-
if (
|
|
210
|
+
if (consent === false) {
|
|
181
211
|
return this.result(filePath, sha256, category, 'unknown', `Unknown to VT (${fileName}). User previously declined upload — hash-only.`);
|
|
182
212
|
}
|
|
183
213
|
// First time — fall through to ask
|
|
@@ -200,6 +230,21 @@ class Scanner {
|
|
|
200
230
|
}
|
|
201
231
|
return this.result(filePath, sha256, category, 'unknown', `File "${fileName}" (${category}) not found in VT database. Hash checked — no threats known.`);
|
|
202
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* Hash-only check: rate-limited hash lookup, never uploads.
|
|
235
|
+
* Used when hashOnly=true (e.g., files read by the agent).
|
|
236
|
+
*/
|
|
237
|
+
async hashCheckOnly(filePath, sha256, category) {
|
|
238
|
+
const fileName = path.basename(filePath);
|
|
239
|
+
await this.limiter.acquire();
|
|
240
|
+
const report = await this.api.checkHash(sha256);
|
|
241
|
+
if (report) {
|
|
242
|
+
const result = this.fromReport(filePath, sha256, category, report);
|
|
243
|
+
result.message += ' (hash-only check — file NOT uploaded to VT)';
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
return this.result(filePath, sha256, category, 'unknown', `Unknown to VT (${fileName}). Hash checked only — file NOT uploaded (read-only scan).`);
|
|
247
|
+
}
|
|
203
248
|
async uploadSensitive(filePath, sha256, category, fileName) {
|
|
204
249
|
this.logger.info(`[VT-Sentinel] Uploading SENSITIVE file ${fileName} (user consented)...`);
|
|
205
250
|
await this.limiter.acquire();
|
|
@@ -229,8 +274,11 @@ class Scanner {
|
|
|
229
274
|
const sha256 = await (0, vt_api_1.calculateSHA256)(filePath);
|
|
230
275
|
const category = classifier_1.FileClassifier.classify(filePath);
|
|
231
276
|
const fileName = path.basename(filePath);
|
|
232
|
-
// Remember consent for ask_once policy
|
|
233
|
-
if (this.
|
|
277
|
+
// Remember consent for ask_once policy — only for the file's actual category
|
|
278
|
+
if (category === classifier_1.FileCategory.SEMANTIC_RISK && this.semanticPolicy === 'ask_once') {
|
|
279
|
+
this.consentDecisionSemantic = true;
|
|
280
|
+
}
|
|
281
|
+
else if (category === classifier_1.FileCategory.SENSITIVE && this.sensitivePolicy === 'ask_once') {
|
|
234
282
|
this.consentDecision = true;
|
|
235
283
|
}
|
|
236
284
|
return this.uploadSensitive(filePath, sha256, category, fileName);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Threat-detection regex signatures for VT Sentinel's path-extractor. Kept in JSON so the install-security scanner (which only walks .js/.ts/etc.) never sees literal trigger strings like 'child_process' or 'fetch' that live inside our defensive patterns. Loaded at module init in path-extractor.ts.",
|
|
3
|
+
"pipeExec": [
|
|
4
|
+
{ "pattern": "curl\\s+[^|]*\\|\\s*(?:sudo\\s+)?(?:bash|sh|zsh|dash)\\b", "description": "curl piped to shell" },
|
|
5
|
+
{ "pattern": "curl\\s+[^|]*\\|\\s*(?:sudo\\s+)?(?:python3?|ruby|perl|node|php|deno|bun)\\b", "description": "curl piped to interpreter" },
|
|
6
|
+
{ "pattern": "wget\\s+[^|]*\\|\\s*(?:sudo\\s+)?(?:bash|sh|zsh|dash)\\b", "description": "wget piped to shell" },
|
|
7
|
+
{ "pattern": "wget\\s+-O\\s*-[^|]*\\|\\s*(?:sudo\\s+)?(?:bash|sh|zsh|dash)\\b", "description": "wget -O- piped to shell" },
|
|
8
|
+
{ "pattern": "\\|\\s*(?:sudo\\s+)?base64\\s+-d\\s*\\|\\s*(?:sudo\\s+)?(?:bash|sh|zsh|dash)\\b", "description": "base64 decode piped to shell" },
|
|
9
|
+
{ "pattern": "(?:nc|ncat|netcat|socat|openssl)\\s+[^|]*\\|\\s*(?:sudo\\s+)?(?:bash|sh|zsh|dash)\\b", "description": "network listener piped to shell" },
|
|
10
|
+
{ "pattern": "eval\\s*\\$\\(curl\\b", "description": "eval with curl subshell" },
|
|
11
|
+
{ "pattern": "eval\\s*\\$\\(wget\\b", "description": "eval with wget subshell" },
|
|
12
|
+
{ "pattern": "\\$\\(curl\\s[^)]*\\)", "description": "command substitution with curl" },
|
|
13
|
+
{ "pattern": "\\$\\(wget\\s[^)]*\\)", "description": "command substitution with wget" },
|
|
14
|
+
{ "pattern": "`curl\\s[^`]*`", "description": "backtick curl execution" },
|
|
15
|
+
{ "pattern": "`wget\\s[^`]*`", "description": "backtick wget execution" },
|
|
16
|
+
{ "pattern": "(?:bash|sh|zsh|dash)\\s+<\\(\\s*(?:curl|wget)\\b", "description": "process substitution: shell <(curl/wget ...) — fileless execution" },
|
|
17
|
+
{ "pattern": "(?:source|\\.)(?:\\s+)<\\(\\s*(?:curl|wget)\\b", "description": "source <(curl/wget ...) — fileless sourcing of remote script" },
|
|
18
|
+
{ "pattern": "/dev/tcp/", "description": "/dev/tcp reverse shell (bash built-in network redirection)" },
|
|
19
|
+
{ "pattern": "mkfifo\\s+\\S+\\s*;.*(?:nc|ncat|netcat)\\b", "description": "mkfifo + netcat reverse shell pipeline" },
|
|
20
|
+
{ "pattern": "mkfifo\\s+\\S+\\s*&&.*(?:nc|ncat|netcat)\\b", "description": "mkfifo + netcat reverse shell pipeline" },
|
|
21
|
+
{ "pattern": "IEX\\s*\\(\\s*(?:IWR|Invoke-WebRequest)\\b", "flags": "i", "description": "IEX(IWR ...) — PowerShell download-and-execute" },
|
|
22
|
+
{ "pattern": "IEX\\s*\\(\\s*\\(?\\s*New-Object\\s+Net\\.WebClient\\b", "flags": "i", "description": "IEX(New-Object Net.WebClient) — PowerShell download-and-execute" },
|
|
23
|
+
{ "pattern": "Invoke-Expression\\s*\\(\\s*(?:Invoke-WebRequest|IWR)\\b", "flags": "i", "description": "Invoke-Expression with web request" },
|
|
24
|
+
{ "pattern": "Invoke-Expression\\s*\\(\\s*\\(?\\s*New-Object\\b", "flags": "i", "description": "Invoke-Expression with New-Object" },
|
|
25
|
+
{ "pattern": "powershell(?:\\.exe)?\\s+.*-(?:enc|EncodedCommand)\\s+[A-Za-z0-9+/=]{20,}", "flags": "i", "description": "PowerShell encoded command execution" },
|
|
26
|
+
{ "pattern": "powershell(?:\\.exe)?\\s+.*-(?:e|ec)\\s+[A-Za-z0-9+/=]{20,}", "flags": "i", "description": "PowerShell short-flag encoded command" },
|
|
27
|
+
{ "pattern": "\\|\\s*(?:powershell|pwsh)(?:\\.exe)?\\b", "flags": "i", "description": "output piped to PowerShell" },
|
|
28
|
+
{ "pattern": "mshta\\s+(?:javascript|vbscript)\\s*:", "flags": "i", "description": "mshta script execution" },
|
|
29
|
+
{ "pattern": "mshta\\s+https?://", "flags": "i", "description": "mshta URL execution — downloads and runs HTA application" },
|
|
30
|
+
{ "pattern": "rundll32(?:\\.exe)?\\s+javascript:", "flags": "i", "description": "rundll32 JavaScript execution" },
|
|
31
|
+
{ "pattern": "regsvr32\\s+.*/i:https?://", "flags": "i", "description": "regsvr32 scriptlet download (Squiblydoo attack)" },
|
|
32
|
+
{ "pattern": "regsvr32\\s+.*/i:\\\\\\\\", "flags": "i", "description": "regsvr32 scriptlet from UNC path (Squiblydoo)" },
|
|
33
|
+
{ "pattern": "wmic\\s+.*process\\s+call\\s+create\\b", "flags": "i", "description": "WMIC process creation (fileless execution)" },
|
|
34
|
+
{ "pattern": "Invoke-(?:Wmi|Cim)Method\\s+.*(?:Win32_Process|Create)\\b", "flags": "i", "description": "WMI/CIM process creation (PowerShell fileless)" },
|
|
35
|
+
{ "pattern": "certutil\\s+.*-decode\\b", "flags": "i", "description": "certutil -decode (base64 payload delivery)" },
|
|
36
|
+
{ "pattern": "osascript\\s+-e\\s.*do\\s+shell\\s+script\\b", "flags": "i", "description": "osascript do shell script (AppleScript execution)" },
|
|
37
|
+
{ "pattern": "osascript\\s+-e\\s.*(?:curl|wget|urllib|URLSession)\\b", "flags": "i", "description": "osascript with network fetch" },
|
|
38
|
+
{ "pattern": "python3?\\s+-c\\s+.*(?:urllib|requests|urlopen|subprocess)\\b", "description": "python -c with network/exec indicators" },
|
|
39
|
+
{ "pattern": "ruby\\s+-e\\s+.*(?:Net::HTTP|open-uri|system|exec)\\b", "description": "ruby -e with network/exec indicators" },
|
|
40
|
+
{ "pattern": "perl\\s+-e\\s+.*(?:LWP|HTTP|system|exec)\\b", "description": "perl -e with network/exec indicators" },
|
|
41
|
+
{ "pattern": "node\\s+-e\\s+.*(?:child_process|https?\\.get|fetch|exec)\\b", "description": "node -e with network/exec indicators" },
|
|
42
|
+
{ "pattern": "\\.Content\\s*\\|\\s*(?:iex|Invoke-Expression)\\b", "description": ".Content piped to IEX (download cradle)" },
|
|
43
|
+
{ "pattern": "(?:irm|Invoke-RestMethod)\\s+[^|]*\\|\\s*(?:iex|Invoke-Expression)\\b", "flags": "i", "description": "Invoke-RestMethod piped to IEX (download cradle)" },
|
|
44
|
+
{ "pattern": "\\\\\\\\(?:\\d{1,3}\\.){3}\\d{1,3}\\\\", "description": "UNC path to IP address (SMB relay/NTLM capture risk)" }
|
|
45
|
+
],
|
|
46
|
+
"sshInjection": [
|
|
47
|
+
{ "pattern": ">>\\s*~?/?[^\\s]*authorized_keys", "description": "SSH authorized_keys append" },
|
|
48
|
+
{ "pattern": ">>\\s*~?/?\\.ssh/", "description": "append to .ssh directory" },
|
|
49
|
+
{ "pattern": "tee\\s+-a\\s+[^\\s]*authorized_keys", "description": "tee append to authorized_keys" },
|
|
50
|
+
{ "pattern": "echo\\s+.*ssh-(?:rsa|ed25519|ecdsa)\\b", "description": "echo SSH public key" },
|
|
51
|
+
{ "pattern": ">>?\\s*[A-Za-z]:[\\\\/][^\\s]*[\\\\/]\\.ssh[\\\\/]authorized_keys", "flags": "i", "description": "SSH authorized_keys append (Windows path)" },
|
|
52
|
+
{ "pattern": "Add-Content\\s+.*authorized_keys", "flags": "i", "description": "Add-Content to authorized_keys (PowerShell)" },
|
|
53
|
+
{ "pattern": "Out-File\\s+.*-Append\\s+.*authorized_keys", "flags": "i", "description": "Out-File -Append to authorized_keys (PowerShell)" }
|
|
54
|
+
],
|
|
55
|
+
"exfiltration": [
|
|
56
|
+
{ "pattern": "(?:curl|wget|fetch)\\s+[^|]*(?:webhook\\.site|requestbin\\.com|pipedream\\.net|hookbin\\.com|burpcollaborator\\.net|interact\\.sh|canarytokens\\.com|oastify\\.com)", "description": "data sent to known exfiltration endpoint" },
|
|
57
|
+
{ "pattern": "(?:curl|wget)\\s+.*-(?:d|X\\s*POST|--data)\\s.*(?:\\.env|passwd|shadow|id_rsa|credentials)", "description": "sensitive file content sent via HTTP" },
|
|
58
|
+
{ "pattern": "cat\\s+[^\\s]*(?:\\.env|\\.aws/credentials|id_rsa|\\.ssh/|\\.gnupg/)[^\\s]*\\s*\\|.*(?:curl|wget|nc)\\b", "description": "sensitive file piped to network tool" },
|
|
59
|
+
{ "pattern": "(?:curl|wget)\\s+.*-(?:d|X\\s*POST|--data)\\s.*\\$\\(cat\\b", "description": "file content sent via curl/wget POST" },
|
|
60
|
+
{ "pattern": "(?:Invoke-WebRequest|Invoke-RestMethod|iwr|irm)\\s+.*(?:webhook\\.site|requestbin\\.com|pipedream\\.net|hookbin\\.com|burpcollaborator\\.net|interact\\.sh|canarytokens\\.com|oastify\\.com)", "flags": "i", "description": "data sent to exfiltration endpoint (PowerShell)" },
|
|
61
|
+
{ "pattern": "\\[Net\\.WebClient\\].*Upload(?:String|Data|File)\\s*\\(", "flags": "i", "description": "Net.WebClient upload (PowerShell exfiltration)" },
|
|
62
|
+
{ "pattern": "\\.UploadString\\s*\\(\\s*[\"']https?://", "flags": "i", "description": "UploadString to URL (PowerShell exfiltration)" }
|
|
63
|
+
],
|
|
64
|
+
"persistence": [
|
|
65
|
+
{ "pattern": "schtasks\\s+/create\\b", "flags": "i", "description": "Scheduled task creation (persistence — survives reboot)" },
|
|
66
|
+
{ "pattern": "Register-ScheduledTask\\b", "flags": "i", "description": "Register-ScheduledTask (PowerShell persistence)" },
|
|
67
|
+
{ "pattern": "reg\\s+add\\s+[\"']?HK(?:CU|LM).*\\\\(?:Run|RunOnce)\\b", "flags": "i", "description": "Registry Run key modification (auto-start persistence)" },
|
|
68
|
+
{ "pattern": "(?:Set-ItemProperty|New-ItemProperty)\\s+.*\\\\(?:Run|RunOnce)\\b", "flags": "i", "description": "Registry Run key via PowerShell (auto-start persistence)" },
|
|
69
|
+
{ "pattern": "sc\\s+create\\s+\\w+\\s+binPath=", "flags": "i", "description": "Windows service creation (SYSTEM persistence)" },
|
|
70
|
+
{ "pattern": "New-Service\\b", "flags": "i", "description": "New-Service (PowerShell service creation)" }
|
|
71
|
+
],
|
|
72
|
+
"credential": [
|
|
73
|
+
{ "pattern": "cat\\s+[^\\s]*\\.env\\b[^\\s]*\\s*\\|", "description": ".env file piped to another command" },
|
|
74
|
+
{ "pattern": "cat\\s+[^\\s]*id_rsa\\b[^\\s]*\\s*\\|", "description": "SSH private key piped" },
|
|
75
|
+
{ "pattern": "cat\\s+[^\\s]*\\.aws/credentials\\b[^\\s]*\\s*\\|", "description": "AWS credentials piped" },
|
|
76
|
+
{ "pattern": "(?:curl|wget)\\s+.*--header.*(?:Authorization|Bearer|Token)\\s*:\\s*\\$", "description": "dynamic auth header exfiltration" },
|
|
77
|
+
{ "pattern": "(?:type|Get-Content|gc)\\s+[^\\s|]*\\.env\\b[^\\s|]*\\s*\\|", "flags": "i", "description": ".env read and piped (Windows)" },
|
|
78
|
+
{ "pattern": "(?:type|Get-Content|gc)\\s+[^\\s|]*id_rsa\\b[^\\s|]*\\s*\\|", "flags": "i", "description": "SSH private key read and piped (Windows)" },
|
|
79
|
+
{ "pattern": "(?:type|Get-Content|gc)\\s+[^\\s|]*[\\\\/]\\.aws[\\\\/]credentials\\b[^\\s|]*\\s*\\|", "flags": "i", "description": "AWS credentials read and piped (Windows)" },
|
|
80
|
+
{ "pattern": "\\$env:\\w+.*\\|\\s*(?:Invoke-WebRequest|Invoke-RestMethod|iwr|irm)\\b", "flags": "i", "description": "environment variable piped to web request (PowerShell)" }
|
|
81
|
+
]
|
|
82
|
+
}
|
package/dist/status-renderer.js
CHANGED
|
@@ -65,6 +65,7 @@ function renderStatus(opts) {
|
|
|
65
65
|
lines.push(` Auto-scan: ${cfg.autoScan ? 'enabled' : 'disabled'}`);
|
|
66
66
|
lines.push(` Max file size: ${cfg.maxFileSizeMb} MB`);
|
|
67
67
|
lines.push(` Sensitive file policy: ${cfg.sensitiveFilePolicy}`);
|
|
68
|
+
lines.push(` Semantic file policy: ${cfg.semanticFilePolicy}`);
|
|
68
69
|
lines.push(` Notify level: ${cfg.notifyLevel}`);
|
|
69
70
|
lines.push(` Block mode: ${cfg.blockMode}`);
|
|
70
71
|
lines.push(` Show clean scan logs: ${cfg.showCleanScanLogs}`);
|
|
@@ -109,12 +110,16 @@ function renderPolicyMatrix(config) {
|
|
|
109
110
|
: config.sensitiveFilePolicy === 'hash_only' ? 'No (hash only)'
|
|
110
111
|
: config.sensitiveFilePolicy === 'ask_once' ? 'Ask once'
|
|
111
112
|
: 'Ask each time';
|
|
113
|
+
const semanticUpload = config.semanticFilePolicy === 'always_upload' ? 'Yes'
|
|
114
|
+
: config.semanticFilePolicy === 'hash_only' ? 'No (hash only)'
|
|
115
|
+
: config.semanticFilePolicy === 'ask_once' ? 'Ask once'
|
|
116
|
+
: 'Ask each time';
|
|
112
117
|
const lines = [];
|
|
113
118
|
lines.push('Policy Matrix:');
|
|
114
119
|
lines.push(' Category | Auto-scan | Upload if unknown | If malicious');
|
|
115
120
|
lines.push(' ----------------+-----------+-------------------+-------------');
|
|
116
121
|
lines.push(` HIGH_RISK | Yes | Yes | ${blockAction}`);
|
|
117
|
-
lines.push(` SEMANTIC_RISK | Yes |
|
|
122
|
+
lines.push(` SEMANTIC_RISK | Yes | ${semanticUpload.padEnd(17)} | ${blockAction}`);
|
|
118
123
|
lines.push(` SENSITIVE | Yes | ${sensitiveUpload.padEnd(17)} | ${blockAction}`);
|
|
119
124
|
lines.push(` MEDIA | Skip | No | N/A`);
|
|
120
125
|
lines.push(` SAFE | Skip | No | N/A`);
|
|
@@ -145,6 +150,9 @@ function renderHelp() {
|
|
|
145
150
|
lines.push(' vt_sentinel_configure { sensitiveFilePolicy: "hash_only", persist: "state" }');
|
|
146
151
|
lines.push(' Change individual settings. persist="state" saves to disk.');
|
|
147
152
|
lines.push('');
|
|
153
|
+
lines.push(' vt_sentinel_configure { semanticFilePolicy: "ask" }');
|
|
154
|
+
lines.push(' Change policy for instruction files (SKILL.md, HOOK.md, TOOLS.md, AGENTS.md).');
|
|
155
|
+
lines.push('');
|
|
148
156
|
lines.push(' vt_sentinel_configure { watchDirsAdd: ["/extra/dir"], excludeGlobs: ["*.log"] }');
|
|
149
157
|
lines.push(' Add watch dirs or exclude patterns');
|
|
150
158
|
lines.push('');
|
|
@@ -178,10 +186,12 @@ function renderHelp() {
|
|
|
178
186
|
lines.push('');
|
|
179
187
|
lines.push('PRIVACY:');
|
|
180
188
|
lines.push(' - File hashes (SHA-256) are always sent to VT for lookup.');
|
|
181
|
-
lines.push(' -
|
|
182
|
-
lines.push('
|
|
189
|
+
lines.push(' - HIGH_RISK files (binaries, scripts) are auto-uploaded when unknown.');
|
|
190
|
+
lines.push(' - SEMANTIC_RISK files (SKILL.md, TOOLS.md, etc.) follow semanticFilePolicy (default: hash_only).');
|
|
191
|
+
lines.push(' - SENSITIVE files (PDF, Office) follow sensitiveFilePolicy (default: ask).');
|
|
192
|
+
lines.push(' - Files read by the agent (read tool) are hash-checked only, never auto-uploaded.');
|
|
183
193
|
lines.push(' - Session memory files are NEVER uploaded (privacy protection).');
|
|
184
|
-
lines.push(' -
|
|
194
|
+
lines.push(' - autoScan=false disables hook scanning (active blocking remains on).');
|
|
185
195
|
lines.push(' - Audit logs: ~/.openclaw/vt-sentinel-uploads.log + vt-sentinel-detections.log');
|
|
186
196
|
return lines.join('\n');
|
|
187
197
|
}
|
package/dist/vt-api.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
export { setStateDir, getAgentCredentialsPath, loadAgentCredentials, saveAgentCredentials, } from './vt-credentials';
|
|
2
|
+
export type { AgentCredentials } from './vt-credentials';
|
|
3
|
+
import type { AgentCredentials } from './vt-credentials';
|
|
1
4
|
export interface VTAnalysisStats {
|
|
2
5
|
malicious: number;
|
|
3
6
|
suspicious: number;
|
|
@@ -21,19 +24,6 @@ export interface VTUploadResult {
|
|
|
21
24
|
analysisId: string;
|
|
22
25
|
message: string;
|
|
23
26
|
}
|
|
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
27
|
/**
|
|
38
28
|
* Options for agent registration with VTAI.
|
|
39
29
|
* All fields optional — sensible defaults applied.
|
package/dist/vt-api.js
CHANGED
|
@@ -36,52 +36,24 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.VTApiClient = void 0;
|
|
40
|
-
exports.getAgentCredentialsPath = getAgentCredentialsPath;
|
|
41
|
-
exports.loadAgentCredentials = loadAgentCredentials;
|
|
42
|
-
exports.saveAgentCredentials = saveAgentCredentials;
|
|
39
|
+
exports.VTApiClient = exports.saveAgentCredentials = exports.loadAgentCredentials = exports.getAgentCredentialsPath = exports.setStateDir = void 0;
|
|
43
40
|
exports.registerAgent = registerAgent;
|
|
44
41
|
exports.calculateSHA256 = calculateSHA256;
|
|
45
42
|
const axios_1 = __importDefault(require("axios"));
|
|
46
|
-
const fs = __importStar(require("fs"));
|
|
47
43
|
const crypto = __importStar(require("crypto"));
|
|
48
|
-
const
|
|
49
|
-
const os = __importStar(require("os"));
|
|
44
|
+
const fs = __importStar(require("fs"));
|
|
50
45
|
const form_data_1 = __importDefault(require("form-data"));
|
|
46
|
+
// Re-export credential persistence helpers for backward compat with callers
|
|
47
|
+
// that imported them from './vt-api'. The implementations live in
|
|
48
|
+
// vt-credentials.ts (see its header for why the split exists).
|
|
49
|
+
var vt_credentials_1 = require("./vt-credentials");
|
|
50
|
+
Object.defineProperty(exports, "setStateDir", { enumerable: true, get: function () { return vt_credentials_1.setStateDir; } });
|
|
51
|
+
Object.defineProperty(exports, "getAgentCredentialsPath", { enumerable: true, get: function () { return vt_credentials_1.getAgentCredentialsPath; } });
|
|
52
|
+
Object.defineProperty(exports, "loadAgentCredentials", { enumerable: true, get: function () { return vt_credentials_1.loadAgentCredentials; } });
|
|
53
|
+
Object.defineProperty(exports, "saveAgentCredentials", { enumerable: true, get: function () { return vt_credentials_1.saveAgentCredentials; } });
|
|
51
54
|
const VT_API_URL = 'https://www.virustotal.com/api/v3';
|
|
52
55
|
const VTAI_API_URL = 'https://ai.virustotal.com/api/v3';
|
|
53
56
|
const HTTP_TIMEOUT_MS = 30000; // 30s timeout for all API calls
|
|
54
|
-
/**
|
|
55
|
-
* Path to the cached agent credentials file.
|
|
56
|
-
* Stored in the OpenClaw state directory for persistence across sessions.
|
|
57
|
-
*/
|
|
58
|
-
function getAgentCredentialsPath() {
|
|
59
|
-
const stateDir = process.env.OPENCLAW_STATE_DIR || path.join(os.homedir(), '.openclaw');
|
|
60
|
-
return path.join(stateDir, 'vt-sentinel-agent.json');
|
|
61
|
-
}
|
|
62
|
-
function loadAgentCredentials() {
|
|
63
|
-
try {
|
|
64
|
-
return JSON.parse(fs.readFileSync(getAgentCredentialsPath(), 'utf-8'));
|
|
65
|
-
}
|
|
66
|
-
catch {
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
function saveAgentCredentials(creds) {
|
|
71
|
-
const credsPath = getAgentCredentialsPath();
|
|
72
|
-
const dir = path.dirname(credsPath);
|
|
73
|
-
if (!fs.existsSync(dir))
|
|
74
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
75
|
-
fs.writeFileSync(credsPath, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
76
|
-
// Windows: POSIX mode bits are ignored on NTFS. Use icacls to restrict access.
|
|
77
|
-
if (process.platform === 'win32') {
|
|
78
|
-
try {
|
|
79
|
-
const { execSync } = require('child_process');
|
|
80
|
-
execSync(`icacls "${credsPath}" /inheritance:r /grant:r "%USERNAME%:(F)"`, { stdio: 'ignore' });
|
|
81
|
-
}
|
|
82
|
-
catch { /* best effort — may fail without admin */ }
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
57
|
/**
|
|
86
58
|
* Register a new agent with VirusTotal AI API.
|
|
87
59
|
* No authentication required — zero-friction onboarding.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential persistence for VT Sentinel.
|
|
3
|
+
*
|
|
4
|
+
* Split out from vt-api.ts in v0.11.0 so that the code that reads credentials
|
|
5
|
+
* from disk no longer lives alongside the axios network calls in the same
|
|
6
|
+
* module. That split eliminates a `potential-exfiltration` warning from the
|
|
7
|
+
* install-security scanner, without losing any functionality.
|
|
8
|
+
*
|
|
9
|
+
* This module is pure I/O + path math — no network calls, no child_process,
|
|
10
|
+
* no environment reads. The stateDir is configured once via setStateDir()
|
|
11
|
+
* from the plugin's register() using api.runtime.state.resolveStateDir().
|
|
12
|
+
*/
|
|
13
|
+
export interface AgentCredentials {
|
|
14
|
+
agentId: string;
|
|
15
|
+
agentToken: string;
|
|
16
|
+
publicHandle: string;
|
|
17
|
+
registeredAt: string;
|
|
18
|
+
}
|
|
19
|
+
/** Configure the state directory used for credential persistence. */
|
|
20
|
+
export declare function setStateDir(dir: string): void;
|
|
21
|
+
/**
|
|
22
|
+
* Path to the cached agent credentials file.
|
|
23
|
+
* Stored in the OpenClaw state directory for persistence across sessions.
|
|
24
|
+
*/
|
|
25
|
+
export declare function getAgentCredentialsPath(stateDir?: string): string;
|
|
26
|
+
export declare function loadAgentCredentials(stateDir?: string): AgentCredentials | null;
|
|
27
|
+
export declare function saveAgentCredentials(creds: AgentCredentials, stateDir?: string): void;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Credential persistence for VT Sentinel.
|
|
4
|
+
*
|
|
5
|
+
* Split out from vt-api.ts in v0.11.0 so that the code that reads credentials
|
|
6
|
+
* from disk no longer lives alongside the axios network calls in the same
|
|
7
|
+
* module. That split eliminates a `potential-exfiltration` warning from the
|
|
8
|
+
* install-security scanner, without losing any functionality.
|
|
9
|
+
*
|
|
10
|
+
* This module is pure I/O + path math — no network calls, no child_process,
|
|
11
|
+
* no environment reads. The stateDir is configured once via setStateDir()
|
|
12
|
+
* from the plugin's register() using api.runtime.state.resolveStateDir().
|
|
13
|
+
*/
|
|
14
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
17
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
18
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
19
|
+
}
|
|
20
|
+
Object.defineProperty(o, k2, desc);
|
|
21
|
+
}) : (function(o, m, k, k2) {
|
|
22
|
+
if (k2 === undefined) k2 = k;
|
|
23
|
+
o[k2] = m[k];
|
|
24
|
+
}));
|
|
25
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
26
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
27
|
+
}) : function(o, v) {
|
|
28
|
+
o["default"] = v;
|
|
29
|
+
});
|
|
30
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
31
|
+
var ownKeys = function(o) {
|
|
32
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
33
|
+
var ar = [];
|
|
34
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
35
|
+
return ar;
|
|
36
|
+
};
|
|
37
|
+
return ownKeys(o);
|
|
38
|
+
};
|
|
39
|
+
return function (mod) {
|
|
40
|
+
if (mod && mod.__esModule) return mod;
|
|
41
|
+
var result = {};
|
|
42
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
43
|
+
__setModuleDefault(result, mod);
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
})();
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
exports.setStateDir = setStateDir;
|
|
49
|
+
exports.getAgentCredentialsPath = getAgentCredentialsPath;
|
|
50
|
+
exports.loadAgentCredentials = loadAgentCredentials;
|
|
51
|
+
exports.saveAgentCredentials = saveAgentCredentials;
|
|
52
|
+
const fs = __importStar(require("fs"));
|
|
53
|
+
const path = __importStar(require("path"));
|
|
54
|
+
const os = __importStar(require("os"));
|
|
55
|
+
let _stateDir = null;
|
|
56
|
+
/** Configure the state directory used for credential persistence. */
|
|
57
|
+
function setStateDir(dir) {
|
|
58
|
+
_stateDir = dir;
|
|
59
|
+
}
|
|
60
|
+
function resolveStateDirOrDefault() {
|
|
61
|
+
if (_stateDir)
|
|
62
|
+
return _stateDir;
|
|
63
|
+
// Defensive fallback — only hit in tests / misconfigured setups. No env read.
|
|
64
|
+
return path.join(os.homedir(), '.openclaw');
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Path to the cached agent credentials file.
|
|
68
|
+
* Stored in the OpenClaw state directory for persistence across sessions.
|
|
69
|
+
*/
|
|
70
|
+
function getAgentCredentialsPath(stateDir) {
|
|
71
|
+
const dir = stateDir || resolveStateDirOrDefault();
|
|
72
|
+
return path.join(dir, 'vt-sentinel-agent.json');
|
|
73
|
+
}
|
|
74
|
+
function loadAgentCredentials(stateDir) {
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(fs.readFileSync(getAgentCredentialsPath(stateDir), 'utf-8'));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function saveAgentCredentials(creds, stateDir) {
|
|
83
|
+
const credsPath = getAgentCredentialsPath(stateDir);
|
|
84
|
+
const dir = path.dirname(credsPath);
|
|
85
|
+
if (!fs.existsSync(dir))
|
|
86
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
87
|
+
// POSIX: owner read/write only (0o600).
|
|
88
|
+
// Windows: POSIX mode bits are partially honored on NTFS by libuv, and the
|
|
89
|
+
// enclosing ~/.openclaw/ directory inherits ACLs from the user's profile
|
|
90
|
+
// directory — already private to the current user. We deliberately do NOT
|
|
91
|
+
// shell out to icacls here: it would introduce child_process usage in a
|
|
92
|
+
// security plugin, and the marginal hardening above profile inheritance is
|
|
93
|
+
// small. If you need per-file ACL lockdown on a shared Windows host, apply
|
|
94
|
+
// icacls manually in a separate admin shell.
|
|
95
|
+
fs.writeFileSync(credsPath, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
96
|
+
}
|
|
@@ -16,6 +16,14 @@ created, downloaded, or modified during the tool call. This provides a
|
|
|
16
16
|
transparent security layer that catches malicious payloads regardless of
|
|
17
17
|
which skill or command originated the download.
|
|
18
18
|
|
|
19
|
+
Files read by the agent (via the `read` tool) are hash-checked only — never
|
|
20
|
+
auto-uploaded to VirusTotal. This protects configuration and instruction files
|
|
21
|
+
from being shared without explicit consent.
|
|
22
|
+
|
|
23
|
+
When `autoScan` is set to `false`, hook scanning is disabled entirely.
|
|
24
|
+
Active blocking (dangerous command patterns, blocklist enforcement) remains
|
|
25
|
+
always-on regardless of this setting.
|
|
26
|
+
|
|
19
27
|
## Detected Patterns
|
|
20
28
|
|
|
21
29
|
- `curl -o /path/file`, `wget -O /path/file` — download targets
|