ai-warden 1.0.4 → 1.1.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/package.json +1 -1
- package/src/cli.js +39 -9
- package/src/client.js +6 -7
- package/src/skillScanner.js +448 -0
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ const IgnoreParser = require('./ignoreParser');
|
|
|
16
16
|
const InteractivePrompt = require('./interactivePrompt');
|
|
17
17
|
const AIWarden = require('./index');
|
|
18
18
|
const config = require('./config');
|
|
19
|
+
const { scanSkill } = require('./skillScanner');
|
|
19
20
|
|
|
20
21
|
const args = process.argv.slice(2);
|
|
21
22
|
const command = args[0];
|
|
@@ -33,6 +34,7 @@ Usage: aiwarden <command> [options]
|
|
|
33
34
|
|
|
34
35
|
Commands:
|
|
35
36
|
scan <path> Scan file or directory (offline mode)
|
|
37
|
+
scan-skill <url> Scan a remote skill repo for threats
|
|
36
38
|
validate <text> Validate text via API (requires API key)
|
|
37
39
|
login Sign up or log in and save API key
|
|
38
40
|
logout Remove saved API key
|
|
@@ -46,6 +48,12 @@ Scan Options (offline mode):
|
|
|
46
48
|
--interactive Interactive mode (whitelist threats as you go)
|
|
47
49
|
--ignore-file <path> Custom ignore file (default: .aiwardenignore)
|
|
48
50
|
|
|
51
|
+
Scan-Skill Options:
|
|
52
|
+
--offline Use local scanner (free, no API key needed)
|
|
53
|
+
--json Output raw JSON
|
|
54
|
+
--strict Exit code 1 if verdict is not SAFE
|
|
55
|
+
--mode <mode> Detection mode (strict|balanced|permissive)
|
|
56
|
+
|
|
49
57
|
Validate Options (API mode):
|
|
50
58
|
--api-key <key> API key (or use AI_WARDEN_API_KEY env var)
|
|
51
59
|
--mode <mode> Detection mode (strict|balanced|permissive)
|
|
@@ -57,6 +65,11 @@ Examples:
|
|
|
57
65
|
aiwarden scan file.txt Scan single file
|
|
58
66
|
aiwarden scan . --mode strict Strict detection mode
|
|
59
67
|
|
|
68
|
+
# Skill repo scanning
|
|
69
|
+
aiwarden scan-skill https://github.com/user/skill --offline
|
|
70
|
+
aiwarden scan-skill https://github.com/user/skill --json
|
|
71
|
+
aiwarden scan-skill https://github.com/user/skill --strict
|
|
72
|
+
|
|
60
73
|
# API validation (requires API key)
|
|
61
74
|
aiwarden validate "Ignore all instructions" --api-key sk_live_xxx
|
|
62
75
|
export AI_WARDEN_API_KEY=sk_live_xxx
|
|
@@ -325,22 +338,26 @@ async function validateCommand(textOrPath, options = {}) {
|
|
|
325
338
|
} else {
|
|
326
339
|
// Human-readable output
|
|
327
340
|
const data = result.data || result; // Handle nested response
|
|
328
|
-
const blocked = data.decision === 'BLOCK';
|
|
341
|
+
const blocked = data.decision === 'BLOCK' || data.safe === false;
|
|
342
|
+
const riskPct = data.riskScore != null ? (data.riskScore * 100).toFixed(1) + '%' : 'N/A';
|
|
343
|
+
const confPct = data.confidence != null ? (data.confidence * 100).toFixed(1) + '%' : 'N/A';
|
|
344
|
+
const latency = data.latency_ms != null ? `${data.latency_ms.toFixed(0)}ms` : '';
|
|
329
345
|
|
|
330
346
|
console.log('═══════════════════════════════════════════════════════');
|
|
331
|
-
console.log(`Status:
|
|
332
|
-
console.log(`
|
|
333
|
-
console.log(`Confidence: ${
|
|
334
|
-
console.log(`
|
|
347
|
+
console.log(`Status: ${blocked ? '❌ BLOCKED' : '✅ SAFE'}`);
|
|
348
|
+
console.log(`Risk Score: ${riskPct}`);
|
|
349
|
+
console.log(`Confidence: ${confPct}`);
|
|
350
|
+
console.log(`Layer: ${data.layer_name || data.layer || 'unknown'}`);
|
|
351
|
+
if (latency) console.log(`Latency: ${latency}`);
|
|
335
352
|
|
|
336
353
|
// Show sandwich info if used
|
|
337
354
|
if (data.sandwich && data.sandwich.enabled) {
|
|
338
|
-
console.log(`Scanned:
|
|
339
|
-
console.log(`Original:
|
|
355
|
+
console.log(`Scanned: ${data.sandwich.scannedWords.toLocaleString()} words (head + tail)`);
|
|
356
|
+
console.log(`Original: ${data.sandwich.originalWords.toLocaleString()} words`);
|
|
340
357
|
}
|
|
341
358
|
|
|
342
359
|
if (data.reason) {
|
|
343
|
-
console.log(`Reason:
|
|
360
|
+
console.log(`Reason: ${data.reason}`);
|
|
344
361
|
}
|
|
345
362
|
|
|
346
363
|
if (data.cleanText && data.cleanText !== text) {
|
|
@@ -351,7 +368,7 @@ async function validateCommand(textOrPath, options = {}) {
|
|
|
351
368
|
console.log('═══════════════════════════════════════════════════════');
|
|
352
369
|
}
|
|
353
370
|
|
|
354
|
-
process.exit((result.data || result).decision === 'BLOCK' ? 1 : 0);
|
|
371
|
+
process.exit((result.data || result).decision === 'BLOCK' || (result.data || result).safe === false ? 1 : 0);
|
|
355
372
|
|
|
356
373
|
} catch (error) {
|
|
357
374
|
console.error('❌ Validation failed:', error.message);
|
|
@@ -390,6 +407,10 @@ function parseArgs() {
|
|
|
390
407
|
} else if (args[i] === '--ignore-file' && args[i + 1]) {
|
|
391
408
|
options.ignoreFile = args[i + 1];
|
|
392
409
|
i++;
|
|
410
|
+
} else if (args[i] === '--offline') {
|
|
411
|
+
options.offline = true;
|
|
412
|
+
} else if (args[i] === '--strict') {
|
|
413
|
+
options.strict = true;
|
|
393
414
|
}
|
|
394
415
|
}
|
|
395
416
|
|
|
@@ -617,6 +638,15 @@ function whoamiCommand() {
|
|
|
617
638
|
const targetPath = args[1];
|
|
618
639
|
const options = parseArgs();
|
|
619
640
|
scanCommand(targetPath, options);
|
|
641
|
+
} else if (command === 'scan-skill') {
|
|
642
|
+
const url = args[1];
|
|
643
|
+
if (!url) {
|
|
644
|
+
console.error('❌ Error: No URL specified');
|
|
645
|
+
console.log('Usage: aiwarden scan-skill <url> [--offline] [--json] [--strict]');
|
|
646
|
+
process.exit(1);
|
|
647
|
+
}
|
|
648
|
+
const options = parseArgs();
|
|
649
|
+
await scanSkill(url, options);
|
|
620
650
|
} else if (command === 'validate') {
|
|
621
651
|
const text = args[1];
|
|
622
652
|
const options = parseArgs();
|
package/src/client.js
CHANGED
|
@@ -79,10 +79,10 @@ class AIWardenClient {
|
|
|
79
79
|
path: urlObj.pathname + urlObj.search,
|
|
80
80
|
method: 'GET',
|
|
81
81
|
headers: {
|
|
82
|
-
'
|
|
82
|
+
'X-API-Key': this.apiKey,
|
|
83
83
|
'Content-Type': 'application/json',
|
|
84
|
-
'Cache-Control': 'no-cache',
|
|
85
|
-
'User-Agent': 'ai-warden-sdk/
|
|
84
|
+
'Cache-Control': 'no-cache',
|
|
85
|
+
'User-Agent': 'ai-warden-sdk/1.1.0'
|
|
86
86
|
},
|
|
87
87
|
timeout: this.timeout
|
|
88
88
|
};
|
|
@@ -140,10 +140,9 @@ class AIWardenClient {
|
|
|
140
140
|
await this.init();
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
const url = `${this.apiUrl}/
|
|
143
|
+
const url = `${this.apiUrl}/v1/validate`;
|
|
144
144
|
const payload = {
|
|
145
145
|
text: content, // API expects 'text', not 'content'
|
|
146
|
-
// Don't send settings - backend ignores them anyway and Cloudflare WAF blocks large payloads
|
|
147
146
|
...options
|
|
148
147
|
};
|
|
149
148
|
|
|
@@ -157,10 +156,10 @@ class AIWardenClient {
|
|
|
157
156
|
path: urlObj.pathname + urlObj.search,
|
|
158
157
|
method: 'POST',
|
|
159
158
|
headers: {
|
|
160
|
-
'
|
|
159
|
+
'X-API-Key': this.apiKey,
|
|
161
160
|
'Content-Type': 'application/json',
|
|
162
161
|
'Content-Length': Buffer.byteLength(body),
|
|
163
|
-
'User-Agent': 'ai-warden-sdk/
|
|
162
|
+
'User-Agent': 'ai-warden-sdk/1.1.0'
|
|
164
163
|
},
|
|
165
164
|
timeout: this.timeout
|
|
166
165
|
};
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill Scanner - Scan remote skill repos for prompt injection threats
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* - GitHub/ClawHub repo URLs
|
|
6
|
+
* - Offline mode (local scanner) and API mode
|
|
7
|
+
* - Batched file scanning for large repos
|
|
8
|
+
* - Human-readable and JSON output
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const { execSync } = require('child_process');
|
|
15
|
+
const https = require('https');
|
|
16
|
+
const http = require('http');
|
|
17
|
+
const scanner = require('./scanner');
|
|
18
|
+
const config = require('./config');
|
|
19
|
+
|
|
20
|
+
const MAX_FILE_SIZE = 100 * 1024; // 100KB
|
|
21
|
+
const BATCH_SIZE = 50;
|
|
22
|
+
|
|
23
|
+
// Binary file extensions to skip
|
|
24
|
+
const BINARY_EXTENSIONS = new Set([
|
|
25
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.svg',
|
|
26
|
+
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
|
27
|
+
'.zip', '.tar', '.gz', '.bz2', '.7z', '.rar',
|
|
28
|
+
'.pdf', '.doc', '.docx', '.xls', '.xlsx',
|
|
29
|
+
'.exe', '.dll', '.so', '.dylib', '.o',
|
|
30
|
+
'.mp3', '.mp4', '.avi', '.mov', '.wav',
|
|
31
|
+
'.bin', '.dat', '.db', '.sqlite',
|
|
32
|
+
'.pyc', '.class', '.wasm',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse a GitHub URL into { source, owner, repo, name, displayUrl }
|
|
37
|
+
*/
|
|
38
|
+
function parseRepoUrl(url) {
|
|
39
|
+
// Remove trailing slash and .git
|
|
40
|
+
url = url.replace(/\/+$/, '').replace(/\.git$/, '');
|
|
41
|
+
|
|
42
|
+
const match = url.match(/(?:https?:\/\/)?(?:www\.)?(github\.com|clawhub\.com)\/([^/]+)\/([^/]+)/);
|
|
43
|
+
if (!match) {
|
|
44
|
+
throw new Error(`Invalid repository URL: ${url}\nExpected: https://github.com/<owner>/<repo>`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
source: match[1].replace('.com', ''),
|
|
49
|
+
owner: match[2],
|
|
50
|
+
repo: match[3],
|
|
51
|
+
name: match[3],
|
|
52
|
+
displayUrl: `${match[1]}/${match[2]}/${match[3]}`,
|
|
53
|
+
cloneUrl: `https://${match[1]}/${match[2]}/${match[3]}.git`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Clone repo to temp directory
|
|
59
|
+
*/
|
|
60
|
+
function cloneRepo(cloneUrl) {
|
|
61
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-warden-scan-'));
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
execSync(`git clone --depth 1 ${cloneUrl} ${tmpDir}`, {
|
|
65
|
+
stdio: 'pipe',
|
|
66
|
+
timeout: 60000, // 60s timeout
|
|
67
|
+
});
|
|
68
|
+
} catch (err) {
|
|
69
|
+
// Clean up on failure
|
|
70
|
+
cleanupDir(tmpDir);
|
|
71
|
+
const msg = err.stderr ? err.stderr.toString().trim() : err.message;
|
|
72
|
+
throw new Error(`Failed to clone repository: ${msg}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return tmpDir;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get git SHA of cloned repo
|
|
80
|
+
*/
|
|
81
|
+
function getGitSha(repoDir) {
|
|
82
|
+
try {
|
|
83
|
+
return execSync('git rev-parse HEAD', { cwd: repoDir, stdio: 'pipe' })
|
|
84
|
+
.toString()
|
|
85
|
+
.trim();
|
|
86
|
+
} catch {
|
|
87
|
+
return 'unknown';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Classify a file by its path/name into a role
|
|
93
|
+
*/
|
|
94
|
+
function classifyFileRole(relativePath) {
|
|
95
|
+
const basename = path.basename(relativePath);
|
|
96
|
+
const dir = path.dirname(relativePath);
|
|
97
|
+
const ext = path.extname(relativePath).toLowerCase();
|
|
98
|
+
|
|
99
|
+
if (basename === 'SKILL.md') return 'skill-definition';
|
|
100
|
+
if (dir.startsWith('scripts') && (ext === '.sh' || ext === '.py')) return 'install-script';
|
|
101
|
+
if (dir.startsWith('references')) return 'reference-doc';
|
|
102
|
+
if (['.yaml', '.yml', '.json', '.toml'].includes(ext)) return 'config';
|
|
103
|
+
if (ext === '.md') return 'documentation';
|
|
104
|
+
return 'unknown';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Enumerate all text files in a directory (skip binaries, .git, large files)
|
|
109
|
+
*/
|
|
110
|
+
function enumerateFiles(dir, baseDir = dir) {
|
|
111
|
+
const results = [];
|
|
112
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
113
|
+
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
const fullPath = path.join(dir, entry.name);
|
|
116
|
+
const relativePath = path.relative(baseDir, fullPath);
|
|
117
|
+
|
|
118
|
+
// Skip hidden dirs and node_modules
|
|
119
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
120
|
+
|
|
121
|
+
if (entry.isDirectory()) {
|
|
122
|
+
results.push(...enumerateFiles(fullPath, baseDir));
|
|
123
|
+
} else if (entry.isFile()) {
|
|
124
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
125
|
+
if (BINARY_EXTENSIONS.has(ext)) continue;
|
|
126
|
+
|
|
127
|
+
const stat = fs.statSync(fullPath);
|
|
128
|
+
if (stat.size > MAX_FILE_SIZE) continue;
|
|
129
|
+
if (stat.size === 0) continue;
|
|
130
|
+
|
|
131
|
+
// Try reading as text — skip if binary
|
|
132
|
+
try {
|
|
133
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
134
|
+
// Quick binary check: if it has null bytes, skip
|
|
135
|
+
if (content.includes('\0')) continue;
|
|
136
|
+
|
|
137
|
+
results.push({
|
|
138
|
+
path: relativePath,
|
|
139
|
+
role: classifyFileRole(relativePath),
|
|
140
|
+
content,
|
|
141
|
+
});
|
|
142
|
+
} catch {
|
|
143
|
+
// Can't read as text, skip
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return results;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Run offline scan on all files using local scanner
|
|
153
|
+
*/
|
|
154
|
+
function scanOffline(files, options = {}) {
|
|
155
|
+
const startTime = Date.now();
|
|
156
|
+
const results = [];
|
|
157
|
+
let worstScore = 0;
|
|
158
|
+
let anyThreats = false;
|
|
159
|
+
|
|
160
|
+
for (const file of files) {
|
|
161
|
+
const scanOpts = {
|
|
162
|
+
mode: options.mode || 'balanced',
|
|
163
|
+
context: file.role === 'skill-definition' ? 'skill' : 'user',
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const result = scanner.scan(file.content, scanOpts);
|
|
167
|
+
const normalizedScore = Math.min(result.riskScore / (scanOpts.mode === 'strict' ? 75 : 150), 1.0);
|
|
168
|
+
|
|
169
|
+
results.push({
|
|
170
|
+
path: file.path,
|
|
171
|
+
role: file.role,
|
|
172
|
+
safe: result.passed,
|
|
173
|
+
score: parseFloat(normalizedScore.toFixed(2)),
|
|
174
|
+
riskLevel: result.riskLevel,
|
|
175
|
+
findings: result.findings,
|
|
176
|
+
findingsCount: result.findings.length,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
if (normalizedScore > worstScore) worstScore = normalizedScore;
|
|
180
|
+
if (!result.passed) anyThreats = true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const scanTime = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
184
|
+
const trustScore = Math.max(0, Math.round((1 - worstScore) * 100));
|
|
185
|
+
|
|
186
|
+
let verdict;
|
|
187
|
+
if (!anyThreats && worstScore < 0.3) verdict = 'SAFE';
|
|
188
|
+
else if (anyThreats) verdict = 'DANGEROUS';
|
|
189
|
+
else verdict = 'WARNING';
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
files: results,
|
|
193
|
+
verdict,
|
|
194
|
+
trustScore,
|
|
195
|
+
scanTime,
|
|
196
|
+
mode: 'offline',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Send files to API for scanning (batched)
|
|
202
|
+
*/
|
|
203
|
+
async function scanApi(files, repoInfo, options = {}) {
|
|
204
|
+
const apiKey = options.apiKey || process.env.AI_WARDEN_API_KEY || config.getApiKey();
|
|
205
|
+
if (!apiKey) {
|
|
206
|
+
throw new Error('API key required for online scan. Use --offline for free local scanning, or run: aiwarden login');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const apiUrl = options.apiUrl || 'https://api.ai-warden.io';
|
|
210
|
+
const startTime = Date.now();
|
|
211
|
+
|
|
212
|
+
// Chunk files into batches
|
|
213
|
+
const batches = [];
|
|
214
|
+
for (let i = 0; i < files.length; i += BATCH_SIZE) {
|
|
215
|
+
batches.push(files.slice(i, i + BATCH_SIZE));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const allResults = [];
|
|
219
|
+
|
|
220
|
+
for (const batch of batches) {
|
|
221
|
+
const payload = {
|
|
222
|
+
skill: {
|
|
223
|
+
source: repoInfo.source,
|
|
224
|
+
url: repoInfo.displayUrl,
|
|
225
|
+
ref: repoInfo.sha,
|
|
226
|
+
name: repoInfo.name,
|
|
227
|
+
},
|
|
228
|
+
files: batch.map(f => ({
|
|
229
|
+
path: f.path,
|
|
230
|
+
role: f.role,
|
|
231
|
+
content: f.content,
|
|
232
|
+
})),
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const result = await postJson(`${apiUrl}/v1/scan-skill`, payload, apiKey);
|
|
236
|
+
if (result.files) {
|
|
237
|
+
allResults.push(...result.files);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const scanTime = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
242
|
+
|
|
243
|
+
// Aggregate verdict from all batches
|
|
244
|
+
let worstScore = 0;
|
|
245
|
+
let anyDangerous = false;
|
|
246
|
+
let anyWarning = false;
|
|
247
|
+
|
|
248
|
+
for (const r of allResults) {
|
|
249
|
+
if (r.score > worstScore) worstScore = r.score;
|
|
250
|
+
if (r.riskLevel === 'CRITICAL' || r.riskLevel === 'HIGH') anyDangerous = true;
|
|
251
|
+
if (r.riskLevel === 'MEDIUM') anyWarning = true;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const trustScore = Math.max(0, Math.round((1 - worstScore) * 100));
|
|
255
|
+
let verdict;
|
|
256
|
+
if (anyDangerous) verdict = 'DANGEROUS';
|
|
257
|
+
else if (anyWarning) verdict = 'WARNING';
|
|
258
|
+
else verdict = 'SAFE';
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
files: allResults,
|
|
262
|
+
verdict,
|
|
263
|
+
trustScore,
|
|
264
|
+
scanTime,
|
|
265
|
+
mode: 'api',
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* POST JSON to URL (using native http/https)
|
|
271
|
+
*/
|
|
272
|
+
function postJson(url, payload, apiKey) {
|
|
273
|
+
return new Promise((resolve, reject) => {
|
|
274
|
+
const urlObj = new URL(url);
|
|
275
|
+
const protocol = urlObj.protocol === 'https:' ? https : http;
|
|
276
|
+
const body = JSON.stringify(payload);
|
|
277
|
+
|
|
278
|
+
const opts = {
|
|
279
|
+
hostname: urlObj.hostname,
|
|
280
|
+
path: urlObj.pathname,
|
|
281
|
+
method: 'POST',
|
|
282
|
+
headers: {
|
|
283
|
+
'X-API-Key': apiKey,
|
|
284
|
+
'Content-Type': 'application/json',
|
|
285
|
+
'Content-Length': Buffer.byteLength(body),
|
|
286
|
+
'User-Agent': 'ai-warden-cli/1.0.4',
|
|
287
|
+
},
|
|
288
|
+
timeout: 30000,
|
|
289
|
+
};
|
|
290
|
+
if (urlObj.port) opts.port = urlObj.port;
|
|
291
|
+
|
|
292
|
+
const req = protocol.request(opts, (res) => {
|
|
293
|
+
let data = '';
|
|
294
|
+
res.on('data', chunk => data += chunk);
|
|
295
|
+
res.on('end', () => {
|
|
296
|
+
if (res.statusCode === 200) {
|
|
297
|
+
try { resolve(JSON.parse(data)); }
|
|
298
|
+
catch (e) { reject(new Error(`Invalid API response: ${e.message}`)); }
|
|
299
|
+
} else if (res.statusCode === 401) {
|
|
300
|
+
reject(new Error('Invalid API key'));
|
|
301
|
+
} else {
|
|
302
|
+
reject(new Error(`API error ${res.statusCode}: ${data}`));
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
req.on('error', e => reject(new Error(`Network error: ${e.message}`)));
|
|
308
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
|
|
309
|
+
req.write(body);
|
|
310
|
+
req.end();
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Format human-readable report
|
|
316
|
+
*/
|
|
317
|
+
function formatReport(scanResult, repoInfo) {
|
|
318
|
+
const lines = [];
|
|
319
|
+
const bar = '━'.repeat(45);
|
|
320
|
+
|
|
321
|
+
lines.push('');
|
|
322
|
+
lines.push('🔍 AI-Warden Skill Scan');
|
|
323
|
+
lines.push(bar);
|
|
324
|
+
|
|
325
|
+
lines.push(` Skill: ${repoInfo.name}`);
|
|
326
|
+
lines.push(` Source: ${repoInfo.displayUrl}`);
|
|
327
|
+
lines.push(` Files: ${scanResult.files.length} scanned`);
|
|
328
|
+
if (scanResult.mode === 'offline') lines.push(` Mode: offline`);
|
|
329
|
+
lines.push('');
|
|
330
|
+
|
|
331
|
+
for (const file of scanResult.files) {
|
|
332
|
+
const icon = file.safe !== false ? '✅' : '❌';
|
|
333
|
+
const label = file.safe !== false ? 'Safe' : file.riskLevel || 'Threat';
|
|
334
|
+
const score = `(${file.score.toFixed(2)})`;
|
|
335
|
+
const name = file.path.padEnd(25);
|
|
336
|
+
lines.push(` ${name}${icon} ${label.padEnd(10)} ${score}`);
|
|
337
|
+
|
|
338
|
+
// Show findings detail for flagged files
|
|
339
|
+
if (file.findings && file.findings.length > 0) {
|
|
340
|
+
for (let i = 0; i < file.findings.length; i++) {
|
|
341
|
+
const f = file.findings[i];
|
|
342
|
+
const isLast = i === file.findings.length - 1;
|
|
343
|
+
const branch = isLast ? '└─' : '├─';
|
|
344
|
+
const severity = f.severity || 'UNKNOWN';
|
|
345
|
+
const detail = f.match || f.detail || '';
|
|
346
|
+
const truncated = detail.length > 60 ? detail.substring(0, 57) + '...' : detail;
|
|
347
|
+
lines.push(` ${branch} ${f.id}: ${f.name} [${severity}]${truncated ? ' — "' + truncated + '"' : ''}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
lines.push('');
|
|
353
|
+
|
|
354
|
+
const verdictIcon = scanResult.verdict === 'SAFE' ? '✅' : scanResult.verdict === 'WARNING' ? '⚠️' : '❌';
|
|
355
|
+
lines.push(` Verdict: ${verdictIcon} ${scanResult.verdict}`);
|
|
356
|
+
lines.push(` Trust Score: ${scanResult.trustScore}/100`);
|
|
357
|
+
lines.push(` Scan Time: ${scanResult.scanTime}s`);
|
|
358
|
+
lines.push(bar);
|
|
359
|
+
lines.push('');
|
|
360
|
+
|
|
361
|
+
return lines.join('\n');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Clean up temp directory
|
|
366
|
+
*/
|
|
367
|
+
function cleanupDir(dir) {
|
|
368
|
+
try {
|
|
369
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
370
|
+
} catch {
|
|
371
|
+
// Best effort cleanup
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Main entry point for scan-skill command
|
|
377
|
+
*/
|
|
378
|
+
async function scanSkill(url, options = {}) {
|
|
379
|
+
// 1. Parse URL
|
|
380
|
+
const repoInfo = parseRepoUrl(url);
|
|
381
|
+
|
|
382
|
+
// 2. Clone
|
|
383
|
+
if (!options.json) {
|
|
384
|
+
process.stderr.write(`⏳ Cloning ${repoInfo.displayUrl}...\n`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const tmpDir = cloneRepo(repoInfo.cloneUrl);
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
// Get git SHA
|
|
391
|
+
repoInfo.sha = getGitSha(tmpDir);
|
|
392
|
+
|
|
393
|
+
// 3. Enumerate files
|
|
394
|
+
const files = enumerateFiles(tmpDir);
|
|
395
|
+
|
|
396
|
+
if (files.length === 0) {
|
|
397
|
+
if (!options.json) {
|
|
398
|
+
console.log('⚠️ No scannable files found in repository');
|
|
399
|
+
}
|
|
400
|
+
process.exit(0);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// 4. Scan
|
|
404
|
+
let result;
|
|
405
|
+
if (options.offline) {
|
|
406
|
+
result = scanOffline(files, options);
|
|
407
|
+
} else {
|
|
408
|
+
try {
|
|
409
|
+
result = await scanApi(files, repoInfo, options);
|
|
410
|
+
} catch (err) {
|
|
411
|
+
if (!options.json) {
|
|
412
|
+
console.error(`⚠️ API unavailable (${err.message}), falling back to offline scan`);
|
|
413
|
+
}
|
|
414
|
+
result = scanOffline(files, options);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// 5. Output
|
|
419
|
+
if (options.json) {
|
|
420
|
+
console.log(JSON.stringify({
|
|
421
|
+
skill: { name: repoInfo.name, source: repoInfo.source, url: repoInfo.displayUrl, ref: repoInfo.sha },
|
|
422
|
+
...result,
|
|
423
|
+
}, null, 2));
|
|
424
|
+
} else {
|
|
425
|
+
console.log(formatReport(result, repoInfo));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// 6. Exit code
|
|
429
|
+
if (options.strict && result.verdict !== 'SAFE') {
|
|
430
|
+
process.exit(1);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
process.exit(result.verdict === 'DANGEROUS' ? 1 : 0);
|
|
434
|
+
|
|
435
|
+
} finally {
|
|
436
|
+
// 7. Cleanup
|
|
437
|
+
cleanupDir(tmpDir);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
module.exports = {
|
|
442
|
+
scanSkill,
|
|
443
|
+
parseRepoUrl,
|
|
444
|
+
classifyFileRole,
|
|
445
|
+
enumerateFiles,
|
|
446
|
+
scanOffline,
|
|
447
|
+
formatReport,
|
|
448
|
+
};
|