@guava-parity/guard-scanner 15.0.0 → 16.0.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/README.md +208 -42
- package/README_ja.md +252 -0
- package/SKILL.md +40 -11
- package/dist/cli.cjs +5997 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.mjs +6003 -0
- package/dist/index.cjs +4825 -0
- package/dist/index.d.mts +17 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.mjs +4798 -0
- package/dist/mcp-server.cjs +4756 -0
- package/dist/mcp-server.d.mts +1 -0
- package/dist/mcp-server.d.ts +1 -0
- package/dist/mcp-server.mjs +4767 -0
- package/dist/openclaw-plugin.cjs +4863 -0
- package/dist/openclaw-plugin.d.mts +11 -0
- package/dist/openclaw-plugin.d.ts +11 -0
- package/dist/openclaw-plugin.mjs +4847 -34
- package/dist/types.cjs +18 -0
- package/dist/types.d.mts +215 -0
- package/dist/types.d.ts +215 -0
- package/dist/types.mjs +1 -0
- package/docs/data/benchmark-ledger.json +1428 -0
- package/docs/data/corpus-metrics.json +3 -3
- package/docs/data/fp-ledger.json +18 -0
- package/docs/data/quality-contract.json +36 -0
- package/docs/generated/openclaw-upstream-status.json +13 -13
- package/docs/openclaw-compatibility-audit.md +3 -2
- package/docs/openclaw-continuous-compatibility-plan.md +2 -1
- package/docs/spec/capabilities.json +137 -5
- package/docs/spec/plugin-trust.json +11 -0
- package/hooks/{context.js → context.ts} +1 -0
- package/openclaw-plugin.mts +21 -5
- package/openclaw.plugin.json +2 -2
- package/package.json +58 -20
- package/src/asset-auditor.js +0 -508
- package/src/ci-reporter.js +0 -135
- package/src/cli.js +0 -434
- package/src/core/content-loader.js +0 -42
- package/src/core/inventory.js +0 -73
- package/src/core/report-adapters.js +0 -171
- package/src/core/risk-engine.js +0 -93
- package/src/core/rule-registry.js +0 -73
- package/src/core/semantic-validators.js +0 -85
- package/src/finding-schema.js +0 -191
- package/src/hooks/context.ts +0 -49
- package/src/html-template.js +0 -239
- package/src/ioc-db.js +0 -54
- package/src/mcp-server.js +0 -653
- package/src/openclaw-upstream.js +0 -128
- package/src/patterns.js +0 -629
- package/src/policy-engine.js +0 -32
- package/src/quarantine.js +0 -41
- package/src/runtime-guard.js +0 -384
- package/src/scanner.js +0 -1042
- package/src/skill-crawler.js +0 -254
- package/src/threat-model.js +0 -50
- package/src/validation-layer.js +0 -39
- package/src/vt-client.js +0 -202
- package/src/watcher.js +0 -170
package/src/skill-crawler.js
DELETED
|
@@ -1,254 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* guard-scanner — Skill Crawler
|
|
4
|
-
*
|
|
5
|
-
* @security-manifest
|
|
6
|
-
* env-read: [GITHUB_TOKEN (optional, for higher rate limits)]
|
|
7
|
-
* env-write: []
|
|
8
|
-
* network: [GitHub REST API, raw.githubusercontent.com, ClawHub registry]
|
|
9
|
-
* fs-read: []
|
|
10
|
-
* fs-write: []
|
|
11
|
-
* exec: none
|
|
12
|
-
* purpose: Crawl ClawHub/GitHub for SKILL.md files and scan for threats
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
const { httpGet } = require('./asset-auditor.js');
|
|
16
|
-
const { GuardScanner } = require('./scanner.js');
|
|
17
|
-
|
|
18
|
-
const CRAWLER_VERSION = '1.0.0';
|
|
19
|
-
|
|
20
|
-
// ClawHub skills repo (openclaw/skills on GitHub)
|
|
21
|
-
const CLAWHUB_OWNER = 'openclaw';
|
|
22
|
-
const CLAWHUB_REPO = 'skills';
|
|
23
|
-
const CLAWHUB_BRANCH = 'main';
|
|
24
|
-
|
|
25
|
-
class SkillCrawler {
|
|
26
|
-
constructor(options = {}) {
|
|
27
|
-
this.verbose = options.verbose || false;
|
|
28
|
-
this.quiet = options.quiet || false;
|
|
29
|
-
this.concurrency = options.concurrency || 5;
|
|
30
|
-
this.scanner = new GuardScanner({
|
|
31
|
-
verbose: false,
|
|
32
|
-
soulLock: true,
|
|
33
|
-
quiet: true,
|
|
34
|
-
});
|
|
35
|
-
this._httpGet = options._httpGet || httpGet;
|
|
36
|
-
this.results = [];
|
|
37
|
-
this.errors = [];
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Crawl ClawHub (openclaw/skills) for SKILL.md files
|
|
42
|
-
* Uses GitHub tree API to list all SKILL.md paths, then fetches each
|
|
43
|
-
*/
|
|
44
|
-
async crawlClawHub(opts = {}) {
|
|
45
|
-
const maxSkills = opts.maxSkills || 50;
|
|
46
|
-
if (!this.quiet) console.log(`\n🔍 Crawling ClawHub (${CLAWHUB_OWNER}/${CLAWHUB_REPO})...`);
|
|
47
|
-
|
|
48
|
-
try {
|
|
49
|
-
// Get recursive tree to find all SKILL.md files
|
|
50
|
-
const treeUrl = `https://api.github.com/repos/${CLAWHUB_OWNER}/${CLAWHUB_REPO}/git/trees/${CLAWHUB_BRANCH}?recursive=1`;
|
|
51
|
-
const response = await this._httpGet(treeUrl, {
|
|
52
|
-
headers: this._getHeaders(),
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
if (response.status !== 200) {
|
|
56
|
-
this.errors.push({ source: 'clawhub', error: `API returned ${response.status}` });
|
|
57
|
-
return this.results;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const tree = response.data.tree || [];
|
|
61
|
-
const skillMds = tree
|
|
62
|
-
.filter(item => item.type === 'blob' && /SKILL\.md$/i.test(item.path))
|
|
63
|
-
.slice(0, maxSkills);
|
|
64
|
-
|
|
65
|
-
if (!this.quiet) console.log(`📦 Found ${skillMds.length} SKILL.md files`);
|
|
66
|
-
|
|
67
|
-
// Batch fetch and scan
|
|
68
|
-
await this._batchProcess(skillMds.map(item => ({
|
|
69
|
-
source: 'clawhub',
|
|
70
|
-
path: item.path,
|
|
71
|
-
rawUrl: `https://raw.githubusercontent.com/${CLAWHUB_OWNER}/${CLAWHUB_REPO}/${CLAWHUB_BRANCH}/${item.path}`,
|
|
72
|
-
name: this._extractSkillName(item.path),
|
|
73
|
-
})));
|
|
74
|
-
|
|
75
|
-
} catch (e) {
|
|
76
|
-
this.errors.push({ source: 'clawhub', error: e.message });
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return this.results;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Crawl GitHub code search for SKILL.md files matching a query
|
|
84
|
-
* e.g. query "polymarket" finds gambling/trading skills
|
|
85
|
-
*/
|
|
86
|
-
async crawlGitHub(query, opts = {}) {
|
|
87
|
-
const maxResults = opts.maxResults || 20;
|
|
88
|
-
if (!this.quiet) console.log(`\n🔍 GitHub code search: "${query}" + SKILL.md...`);
|
|
89
|
-
|
|
90
|
-
try {
|
|
91
|
-
const searchUrl = `https://api.github.com/search/code?q=${encodeURIComponent(query)}+filename:SKILL.md&per_page=${maxResults}`;
|
|
92
|
-
const response = await this._httpGet(searchUrl, {
|
|
93
|
-
headers: this._getHeaders(),
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
if (response.status !== 200) {
|
|
97
|
-
this.errors.push({ source: 'github', error: `Search API returned ${response.status}` });
|
|
98
|
-
return this.results;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const items = (response.data.items || []).slice(0, maxResults);
|
|
102
|
-
if (!this.quiet) console.log(`📦 Found ${items.length} SKILL.md matches`);
|
|
103
|
-
|
|
104
|
-
await this._batchProcess(items.map(item => ({
|
|
105
|
-
source: 'github',
|
|
106
|
-
path: item.path,
|
|
107
|
-
rawUrl: item.html_url
|
|
108
|
-
.replace('github.com', 'raw.githubusercontent.com')
|
|
109
|
-
.replace('/blob/', '/'),
|
|
110
|
-
name: item.repository?.full_name || item.path,
|
|
111
|
-
repo: item.repository?.full_name,
|
|
112
|
-
})));
|
|
113
|
-
|
|
114
|
-
} catch (e) {
|
|
115
|
-
this.errors.push({ source: 'github', error: e.message });
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return this.results;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Scan a single SKILL.md URL
|
|
123
|
-
*/
|
|
124
|
-
async scanUrl(url, name = 'unknown') {
|
|
125
|
-
try {
|
|
126
|
-
const response = await this._httpGet(url);
|
|
127
|
-
if (response.status !== 200) {
|
|
128
|
-
this.errors.push({ source: 'url', url, error: `HTTP ${response.status}` });
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const content = typeof response.data === 'string'
|
|
133
|
-
? response.data
|
|
134
|
-
: JSON.stringify(response.data);
|
|
135
|
-
|
|
136
|
-
const scanResult = this.scanner.scanText(content);
|
|
137
|
-
|
|
138
|
-
const result = {
|
|
139
|
-
name,
|
|
140
|
-
url,
|
|
141
|
-
content_length: content.length,
|
|
142
|
-
safe: scanResult.safe,
|
|
143
|
-
risk: scanResult.risk,
|
|
144
|
-
detection_count: scanResult.detections.length,
|
|
145
|
-
detections: scanResult.detections,
|
|
146
|
-
scanned_at: new Date().toISOString(),
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
this.results.push(result);
|
|
150
|
-
return result;
|
|
151
|
-
|
|
152
|
-
} catch (e) {
|
|
153
|
-
this.errors.push({ source: 'url', url, error: e.message });
|
|
154
|
-
return null;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Process items in batches with concurrency control
|
|
160
|
-
*/
|
|
161
|
-
async _batchProcess(items) {
|
|
162
|
-
for (let i = 0; i < items.length; i += this.concurrency) {
|
|
163
|
-
const batch = items.slice(i, i + this.concurrency);
|
|
164
|
-
const promises = batch.map(item => this.scanUrl(item.rawUrl, item.name));
|
|
165
|
-
const results = await Promise.allSettled(promises);
|
|
166
|
-
|
|
167
|
-
// Log progress
|
|
168
|
-
if (!this.quiet) {
|
|
169
|
-
for (let j = 0; j < batch.length; j++) {
|
|
170
|
-
const r = results[j];
|
|
171
|
-
if (r.status === 'fulfilled' && r.value) {
|
|
172
|
-
const icon = r.value.safe ? '🟢' : '🔴';
|
|
173
|
-
console.log(`${icon} ${batch[j].name} — risk: ${r.value.risk} (${r.value.detection_count} findings)`);
|
|
174
|
-
} else {
|
|
175
|
-
console.log(`⚠️ ${batch[j].name} — fetch failed`);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Extract skill name from path like "skills/author/skill-name/SKILL.md"
|
|
184
|
-
*/
|
|
185
|
-
_extractSkillName(filePath) {
|
|
186
|
-
const parts = filePath.split('/');
|
|
187
|
-
// typically: skills/<author>/<skill-name>/SKILL.md
|
|
188
|
-
if (parts.length >= 3) {
|
|
189
|
-
return `${parts[parts.length - 3]}/${parts[parts.length - 2]}`;
|
|
190
|
-
}
|
|
191
|
-
return parts.slice(0, -1).join('/');
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
_getHeaders() {
|
|
195
|
-
const headers = { 'User-Agent': `guard-scanner-crawler/${CRAWLER_VERSION}` };
|
|
196
|
-
if (process.env.GITHUB_TOKEN) {
|
|
197
|
-
headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`;
|
|
198
|
-
}
|
|
199
|
-
return headers;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// ── Output ────────────────────────────────────────────────────
|
|
203
|
-
|
|
204
|
-
getSummary() {
|
|
205
|
-
const total = this.results.length;
|
|
206
|
-
const safe = this.results.filter(r => r.safe).length;
|
|
207
|
-
const unsafe = total - safe;
|
|
208
|
-
const highRisk = this.results.filter(r => r.risk >= 80).length;
|
|
209
|
-
|
|
210
|
-
return {
|
|
211
|
-
total,
|
|
212
|
-
safe,
|
|
213
|
-
unsafe,
|
|
214
|
-
highRisk,
|
|
215
|
-
errors: this.errors.length,
|
|
216
|
-
results: this.results.sort((a, b) => b.risk - a.risk),
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
toJSON() {
|
|
221
|
-
return {
|
|
222
|
-
scanner: `guard-scanner-crawler/${CRAWLER_VERSION}`,
|
|
223
|
-
timestamp: new Date().toISOString(),
|
|
224
|
-
...this.getSummary(),
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
printSummary() {
|
|
229
|
-
const s = this.getSummary();
|
|
230
|
-
console.log(`\n${'═'.repeat(54)}`);
|
|
231
|
-
console.log(`📊 Crawler Scan Summary`);
|
|
232
|
-
console.log(`${'─'.repeat(54)}`);
|
|
233
|
-
console.log(` Scanned: ${s.total}`);
|
|
234
|
-
console.log(` 🟢 Safe: ${s.safe}`);
|
|
235
|
-
console.log(` 🔴 Unsafe: ${s.unsafe}`);
|
|
236
|
-
console.log(` 💀 High Risk: ${s.highRisk}`);
|
|
237
|
-
if (s.errors > 0) console.log(` ⚠️ Errors: ${s.errors}`);
|
|
238
|
-
console.log(`${'═'.repeat(54)}\n`);
|
|
239
|
-
|
|
240
|
-
if (s.unsafe > 0) {
|
|
241
|
-
console.log(`⚠️ Unsafe skills detected:`);
|
|
242
|
-
for (const r of s.results.filter(r => !r.safe)) {
|
|
243
|
-
console.log(` 🔴 ${r.name} (risk: ${r.risk}, ${r.detection_count} findings)`);
|
|
244
|
-
if (this.verbose) {
|
|
245
|
-
for (const d of r.detections.slice(0, 5)) {
|
|
246
|
-
console.log(` └─ [${d.severity}] ${d.desc}`);
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
module.exports = { SkillCrawler, CRAWLER_VERSION };
|
package/src/threat-model.js
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Threat Model Layer
|
|
3
|
-
* Generates a threat model by identifying capabilities (network, exec, fs, etc.)
|
|
4
|
-
* within a given context/codebase to contextualize heuristic pattern findings.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
const CAPABILITY_PATTERNS = {
|
|
8
|
-
network: /(?:fetch|axios|http\.get|https\.request|XMLHttpRequest|WebSocket)/i,
|
|
9
|
-
exec: /(?:exec|spawn|child_process|eval|Function|system)/i,
|
|
10
|
-
fs_read: /(?:readFileSync|readFile|createReadStream)/i,
|
|
11
|
-
fs_write: /(?:writeFileSync|writeFile|createWriteStream|appendFile)/i,
|
|
12
|
-
env_access: /(?:process\.env)/i
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
function generateModel(codeContent) {
|
|
16
|
-
const capabilities = {
|
|
17
|
-
network: false,
|
|
18
|
-
exec: false,
|
|
19
|
-
fs_read: false,
|
|
20
|
-
fs_write: false,
|
|
21
|
-
env_access: false
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
let riskScore = 0;
|
|
25
|
-
|
|
26
|
-
for (const [cap, regex] of Object.entries(CAPABILITY_PATTERNS)) {
|
|
27
|
-
if (regex.test(codeContent)) {
|
|
28
|
-
capabilities[cap] = true;
|
|
29
|
-
riskScore += 10; // Base score for having a risky capability
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Capability compounding (e.g. read + network = exfil risk)
|
|
34
|
-
if (capabilities.fs_read && capabilities.network) {
|
|
35
|
-
riskScore += 20;
|
|
36
|
-
}
|
|
37
|
-
if (capabilities.env_access && capabilities.network) {
|
|
38
|
-
riskScore += 30; // High risk of credential exfiltration
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return {
|
|
42
|
-
capabilities,
|
|
43
|
-
riskScore,
|
|
44
|
-
summary: `Capabilities detected: ${Object.keys(capabilities).filter(k => capabilities[k]).join(', ')}`
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
module.exports = {
|
|
49
|
-
generateModel
|
|
50
|
-
};
|
package/src/validation-layer.js
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Validation Layer
|
|
3
|
-
* Evaluates heuristic findings against contextual evidence to separate
|
|
4
|
-
* "validated" threats from "heuristic-only" (potential false positives).
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
function validateFindings(findings, context) {
|
|
8
|
-
return findings.map(finding => {
|
|
9
|
-
let status = 'heuristic-only';
|
|
10
|
-
|
|
11
|
-
// Contextual Validation Rules
|
|
12
|
-
|
|
13
|
-
// 1. If it's a prompt injection but found inside a code block, it might be a false positive
|
|
14
|
-
// (e.g., someone writing an article about prompt injection)
|
|
15
|
-
if (finding.id.startsWith('PI_')) {
|
|
16
|
-
if (context.isInCodeBlock(finding.text)) {
|
|
17
|
-
status = 'heuristic-only'; // False positive likely
|
|
18
|
-
} else {
|
|
19
|
-
status = 'validated';
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// 2. If it's malicious code, verify if the execution environment allows it
|
|
24
|
-
if (finding.id.startsWith('MAL_')) {
|
|
25
|
-
if (context.isExecutable(finding.text)) {
|
|
26
|
-
status = 'validated';
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
return {
|
|
31
|
-
...finding,
|
|
32
|
-
status
|
|
33
|
-
};
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
module.exports = {
|
|
38
|
-
validateFindings
|
|
39
|
-
};
|
package/src/vt-client.js
DELETED
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* guard-scanner v7 — VirusTotal API v3 Client
|
|
3
|
-
*
|
|
4
|
-
* @security-manifest
|
|
5
|
-
* env-read: [VT_API_KEY]
|
|
6
|
-
* env-write: []
|
|
7
|
-
* network: [virustotal.com API v3]
|
|
8
|
-
* fs-read: [files for SHA256 hashing]
|
|
9
|
-
* fs-write: []
|
|
10
|
-
* exec: none
|
|
11
|
-
* purpose: VirusTotal threat intelligence integration
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
const https = require('https');
|
|
15
|
-
const crypto = require('crypto');
|
|
16
|
-
const fs = require('fs');
|
|
17
|
-
|
|
18
|
-
const VT_API_BASE = 'https://www.virustotal.com/api/v3';
|
|
19
|
-
const VT_RATE_LIMIT = 4; // requests per minute (free tier)
|
|
20
|
-
|
|
21
|
-
class VTClient {
|
|
22
|
-
constructor(apiKey, options = {}) {
|
|
23
|
-
if (!apiKey) throw new Error('VirusTotal API key is required. Set VT_API_KEY environment variable.');
|
|
24
|
-
this.apiKey = apiKey;
|
|
25
|
-
this.timeout = options.timeout || 15000;
|
|
26
|
-
this.verbose = options.verbose || false;
|
|
27
|
-
this._requestCount = 0;
|
|
28
|
-
this._windowStart = Date.now();
|
|
29
|
-
this._httpGet = options._httpGet || null; // DI for testing
|
|
30
|
-
this._httpPost = options._httpPost || null;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// ── Rate limiter (4 req/min free tier) ──────────────────────
|
|
34
|
-
async _throttle() {
|
|
35
|
-
const now = Date.now();
|
|
36
|
-
const elapsed = now - this._windowStart;
|
|
37
|
-
if (elapsed >= 60000) {
|
|
38
|
-
this._requestCount = 0;
|
|
39
|
-
this._windowStart = now;
|
|
40
|
-
}
|
|
41
|
-
if (this._requestCount >= VT_RATE_LIMIT) {
|
|
42
|
-
const waitMs = 60000 - elapsed + 100;
|
|
43
|
-
if (this.verbose) console.log(`⏳ VT rate limit: waiting ${Math.ceil(waitMs / 1000)}s`);
|
|
44
|
-
await new Promise(r => setTimeout(r, waitMs));
|
|
45
|
-
this._requestCount = 0;
|
|
46
|
-
this._windowStart = Date.now();
|
|
47
|
-
}
|
|
48
|
-
this._requestCount++;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// ── HTTP helpers ────────────────────────────────────────────
|
|
52
|
-
async _get(path) {
|
|
53
|
-
await this._throttle();
|
|
54
|
-
if (this._httpGet) return this._httpGet(`${VT_API_BASE}${path}`);
|
|
55
|
-
|
|
56
|
-
return new Promise((resolve, reject) => {
|
|
57
|
-
const req = https.request({
|
|
58
|
-
hostname: 'www.virustotal.com',
|
|
59
|
-
path: `/api/v3${path}`,
|
|
60
|
-
method: 'GET',
|
|
61
|
-
headers: {
|
|
62
|
-
'x-apikey': this.apiKey,
|
|
63
|
-
'Accept': 'application/json',
|
|
64
|
-
},
|
|
65
|
-
}, (res) => {
|
|
66
|
-
let data = '';
|
|
67
|
-
res.on('data', chunk => { data += chunk; });
|
|
68
|
-
res.on('end', () => {
|
|
69
|
-
try {
|
|
70
|
-
const parsed = JSON.parse(data);
|
|
71
|
-
if (res.statusCode === 429) {
|
|
72
|
-
reject(new Error('VT rate limit exceeded'));
|
|
73
|
-
} else if (res.statusCode === 404) {
|
|
74
|
-
resolve({ found: false, data: null });
|
|
75
|
-
} else if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
76
|
-
resolve({ found: true, data: parsed });
|
|
77
|
-
} else {
|
|
78
|
-
reject(new Error(`VT API error ${res.statusCode}: ${JSON.stringify(parsed).substring(0, 200)}`));
|
|
79
|
-
}
|
|
80
|
-
} catch (e) {
|
|
81
|
-
reject(new Error(`VT response parse error: ${e.message}`));
|
|
82
|
-
}
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
req.on('error', reject);
|
|
86
|
-
req.setTimeout(this.timeout, () => { req.destroy(); reject(new Error('VT API timeout')); });
|
|
87
|
-
req.end();
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ── File Hash Lookup ───────────────────────────────────────
|
|
92
|
-
async lookupHash(hash) {
|
|
93
|
-
if (!hash || hash.length < 32) throw new Error('Invalid hash: provide MD5, SHA1, or SHA256');
|
|
94
|
-
const result = await this._get(`/files/${hash}`);
|
|
95
|
-
|
|
96
|
-
if (!result.found) {
|
|
97
|
-
return { found: false, hash, malicious: 0, suspicious: 0, harmless: 0, undetected: 0, engines: {} };
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const attrs = result.data.data?.attributes || {};
|
|
101
|
-
const stats = attrs.last_analysis_stats || {};
|
|
102
|
-
const results = attrs.last_analysis_results || {};
|
|
103
|
-
|
|
104
|
-
// Extract detected engines
|
|
105
|
-
const detectedEngines = {};
|
|
106
|
-
for (const [engine, info] of Object.entries(results)) {
|
|
107
|
-
if (info.category === 'malicious' || info.category === 'suspicious') {
|
|
108
|
-
detectedEngines[engine] = { category: info.category, result: info.result };
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return {
|
|
113
|
-
found: true,
|
|
114
|
-
hash,
|
|
115
|
-
malicious: stats.malicious || 0,
|
|
116
|
-
suspicious: stats.suspicious || 0,
|
|
117
|
-
harmless: stats.harmless || 0,
|
|
118
|
-
undetected: stats.undetected || 0,
|
|
119
|
-
engines: detectedEngines,
|
|
120
|
-
reputation: attrs.reputation || 0,
|
|
121
|
-
tags: attrs.tags || [],
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// ── URL Scan ───────────────────────────────────────────────
|
|
126
|
-
async scanURL(url) {
|
|
127
|
-
if (!url) throw new Error('URL is required');
|
|
128
|
-
// URL ID = base64url of the URL
|
|
129
|
-
const urlId = Buffer.from(url).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
|
130
|
-
const result = await this._get(`/urls/${urlId}`);
|
|
131
|
-
|
|
132
|
-
if (!result.found) {
|
|
133
|
-
return { found: false, url, malicious: 0, suspicious: 0, harmless: 0 };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const attrs = result.data.data?.attributes || {};
|
|
137
|
-
const stats = attrs.last_analysis_stats || {};
|
|
138
|
-
|
|
139
|
-
return {
|
|
140
|
-
found: true,
|
|
141
|
-
url,
|
|
142
|
-
malicious: stats.malicious || 0,
|
|
143
|
-
suspicious: stats.suspicious || 0,
|
|
144
|
-
harmless: stats.harmless || 0,
|
|
145
|
-
categories: attrs.categories || {},
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// ── Domain Report ──────────────────────────────────────────
|
|
150
|
-
async checkDomain(domain) {
|
|
151
|
-
if (!domain) throw new Error('Domain is required');
|
|
152
|
-
const result = await this._get(`/domains/${domain}`);
|
|
153
|
-
|
|
154
|
-
if (!result.found) {
|
|
155
|
-
return { found: false, domain, reputation: 0, malicious: 0 };
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const attrs = result.data.data?.attributes || {};
|
|
159
|
-
const stats = attrs.last_analysis_stats || {};
|
|
160
|
-
|
|
161
|
-
return {
|
|
162
|
-
found: true,
|
|
163
|
-
domain,
|
|
164
|
-
reputation: attrs.reputation || 0,
|
|
165
|
-
malicious: stats.malicious || 0,
|
|
166
|
-
suspicious: stats.suspicious || 0,
|
|
167
|
-
categories: attrs.categories || {},
|
|
168
|
-
registrar: attrs.registrar || 'unknown',
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ── IP Report ──────────────────────────────────────────────
|
|
173
|
-
async checkIP(ip) {
|
|
174
|
-
if (!ip) throw new Error('IP address is required');
|
|
175
|
-
const result = await this._get(`/ip_addresses/${ip}`);
|
|
176
|
-
|
|
177
|
-
if (!result.found) {
|
|
178
|
-
return { found: false, ip, reputation: 0, malicious: 0 };
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const attrs = result.data.data?.attributes || {};
|
|
182
|
-
const stats = attrs.last_analysis_stats || {};
|
|
183
|
-
|
|
184
|
-
return {
|
|
185
|
-
found: true,
|
|
186
|
-
ip,
|
|
187
|
-
reputation: attrs.reputation || 0,
|
|
188
|
-
malicious: stats.malicious || 0,
|
|
189
|
-
suspicious: stats.suspicious || 0,
|
|
190
|
-
country: attrs.country || 'unknown',
|
|
191
|
-
as_owner: attrs.as_owner || 'unknown',
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// ── File SHA256 helper ──────────────────────────────────────
|
|
196
|
-
static hashFile(filePath) {
|
|
197
|
-
const content = fs.readFileSync(filePath);
|
|
198
|
-
return crypto.createHash('sha256').update(content).digest('hex');
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
module.exports = { VTClient, VT_API_BASE, VT_RATE_LIMIT };
|