@guava-parity/guard-scanner 5.1.0 → 8.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 +119 -130
- package/SKILL.md +43 -93
- package/package.json +2 -2
- package/src/asset-auditor.js +508 -0
- package/src/ci-reporter.js +135 -0
- package/src/cli.js +193 -77
- package/src/patterns.js +18 -0
- package/src/scanner.js +1 -1
- package/src/vt-client.js +202 -0
- package/src/watcher.js +170 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* guard-scanner v6.0.0 — Asset Auditor
|
|
4
|
+
*
|
|
5
|
+
* @security-manifest
|
|
6
|
+
* env-read: []
|
|
7
|
+
* env-write: []
|
|
8
|
+
* network: [npm registry API, GitHub REST API]
|
|
9
|
+
* fs-read: []
|
|
10
|
+
* fs-write: []
|
|
11
|
+
* exec: [clawhub CLI (optional)]
|
|
12
|
+
* purpose: Audit npm/GitHub/ClawHub assets for accidental exposure
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const https = require('https');
|
|
16
|
+
const { execSync } = require('child_process');
|
|
17
|
+
|
|
18
|
+
const AUDIT_VERSION = '8.0.0';
|
|
19
|
+
|
|
20
|
+
// ── HTTP helper (no external deps) ─────────────────────────────────
|
|
21
|
+
function httpGet(url, options = {}) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const timeout = options.timeout || 15000;
|
|
24
|
+
const urlObj = new URL(url);
|
|
25
|
+
const reqOptions = {
|
|
26
|
+
hostname: urlObj.hostname,
|
|
27
|
+
port: urlObj.port || 443,
|
|
28
|
+
path: urlObj.pathname + urlObj.search,
|
|
29
|
+
method: 'GET',
|
|
30
|
+
headers: {
|
|
31
|
+
'User-Agent': `guard-scanner/${AUDIT_VERSION}`,
|
|
32
|
+
'Accept': 'application/json',
|
|
33
|
+
...(options.headers || {}),
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const req = https.request(reqOptions, (res) => {
|
|
38
|
+
let data = '';
|
|
39
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
40
|
+
res.on('end', () => {
|
|
41
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
42
|
+
try {
|
|
43
|
+
resolve({ status: res.statusCode, data: JSON.parse(data) });
|
|
44
|
+
} catch (e) {
|
|
45
|
+
resolve({ status: res.statusCode, data: data });
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
reject(new Error(`HTTP ${res.statusCode}: ${data.substring(0, 200)}`));
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
req.on('error', reject);
|
|
54
|
+
req.setTimeout(timeout, () => {
|
|
55
|
+
req.destroy();
|
|
56
|
+
reject(new Error(`Timeout after ${timeout}ms: ${url}`));
|
|
57
|
+
});
|
|
58
|
+
req.end();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Alert severity levels ──────────────────────────────────────────
|
|
63
|
+
const ALERT_SEVERITY = {
|
|
64
|
+
CRITICAL: 'CRITICAL',
|
|
65
|
+
HIGH: 'HIGH',
|
|
66
|
+
MEDIUM: 'MEDIUM',
|
|
67
|
+
LOW: 'LOW',
|
|
68
|
+
INFO: 'INFO',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ── AssetAuditor class ─────────────────────────────────────────────
|
|
72
|
+
class AssetAuditor {
|
|
73
|
+
constructor(options = {}) {
|
|
74
|
+
this.verbose = options.verbose || false;
|
|
75
|
+
this.format = options.format || 'text';
|
|
76
|
+
this.quiet = options.quiet || false;
|
|
77
|
+
this.timeout = options.timeout || 15000;
|
|
78
|
+
this.results = { npm: null, github: null, clawhub: null };
|
|
79
|
+
this.alerts = [];
|
|
80
|
+
this._httpGet = options._httpGet || httpGet; // DI for testing
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── npm Audit ──────────────────────────────────────────────────
|
|
84
|
+
async auditNpm(username) {
|
|
85
|
+
if (!username) throw new Error('npm username is required');
|
|
86
|
+
const results = { packages: [], alerts: [] };
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
// Search by author
|
|
90
|
+
const authorRes = await this._httpGet(
|
|
91
|
+
`https://registry.npmjs.org/-/v1/search?text=author:${encodeURIComponent(username)}&size=250`,
|
|
92
|
+
{ timeout: this.timeout }
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// Search by maintainer (may catch different packages)
|
|
96
|
+
const maintainerRes = await this._httpGet(
|
|
97
|
+
`https://registry.npmjs.org/-/v1/search?text=maintainer:${encodeURIComponent(username)}&size=250`,
|
|
98
|
+
{ timeout: this.timeout }
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Merge and deduplicate
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
const allPackages = [];
|
|
104
|
+
for (const res of [authorRes, maintainerRes]) {
|
|
105
|
+
if (res.data && res.data.objects) {
|
|
106
|
+
for (const obj of res.data.objects) {
|
|
107
|
+
const pkg = obj.package;
|
|
108
|
+
if (!seen.has(pkg.name)) {
|
|
109
|
+
seen.add(pkg.name);
|
|
110
|
+
allPackages.push({
|
|
111
|
+
name: pkg.name,
|
|
112
|
+
version: pkg.version,
|
|
113
|
+
description: pkg.description || '',
|
|
114
|
+
scope: pkg.scope || 'unscoped',
|
|
115
|
+
date: pkg.date,
|
|
116
|
+
links: pkg.links || {},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
results.packages = allPackages;
|
|
124
|
+
|
|
125
|
+
// ── Detect anomalies ───────────────────────────────────
|
|
126
|
+
// 1. Multiple scopes for same base name
|
|
127
|
+
const baseNames = new Map();
|
|
128
|
+
for (const pkg of allPackages) {
|
|
129
|
+
const baseName = pkg.name.replace(/^@[^/]+\//, '');
|
|
130
|
+
if (!baseNames.has(baseName)) {
|
|
131
|
+
baseNames.set(baseName, []);
|
|
132
|
+
}
|
|
133
|
+
baseNames.get(baseName).push(pkg.name);
|
|
134
|
+
}
|
|
135
|
+
for (const [base, names] of baseNames) {
|
|
136
|
+
if (names.length > 1) {
|
|
137
|
+
results.alerts.push({
|
|
138
|
+
severity: ALERT_SEVERITY.HIGH,
|
|
139
|
+
type: 'SCOPE_DUPLICATE',
|
|
140
|
+
message: `Package "${base}" published under multiple scopes: ${names.join(', ')}`,
|
|
141
|
+
affected: names,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 2. Check each package for suspicious patterns
|
|
147
|
+
for (const pkg of allPackages) {
|
|
148
|
+
// Get detailed package info
|
|
149
|
+
try {
|
|
150
|
+
const detailRes = await this._httpGet(
|
|
151
|
+
`https://registry.npmjs.org/${pkg.name}`,
|
|
152
|
+
{ timeout: this.timeout }
|
|
153
|
+
);
|
|
154
|
+
const detail = detailRes.data;
|
|
155
|
+
const latestVersion = detail['dist-tags']?.latest;
|
|
156
|
+
const latestData = latestVersion ? detail.versions?.[latestVersion] : null;
|
|
157
|
+
|
|
158
|
+
if (latestData) {
|
|
159
|
+
// Check if src/ or node_modules/ included
|
|
160
|
+
const files = latestData.files || [];
|
|
161
|
+
const hasNodeModules = files.some(f => f === 'node_modules/' || f.startsWith('node_modules'));
|
|
162
|
+
const hasSrc = files.some(f => f === 'src/' || f.startsWith('src'));
|
|
163
|
+
const hasEnv = files.some(f => f === '.env' || f.includes('.env'));
|
|
164
|
+
|
|
165
|
+
if (hasNodeModules) {
|
|
166
|
+
results.alerts.push({
|
|
167
|
+
severity: ALERT_SEVERITY.CRITICAL,
|
|
168
|
+
type: 'NODE_MODULES_IN_PACKAGE',
|
|
169
|
+
message: `Package "${pkg.name}" includes node_modules/ in published files`,
|
|
170
|
+
affected: [pkg.name],
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (hasEnv) {
|
|
175
|
+
results.alerts.push({
|
|
176
|
+
severity: ALERT_SEVERITY.CRITICAL,
|
|
177
|
+
type: 'ENV_FILE_IN_PACKAGE',
|
|
178
|
+
message: `Package "${pkg.name}" includes .env file in published files`,
|
|
179
|
+
affected: [pkg.name],
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Check publishConfig
|
|
184
|
+
const isPublic = latestData.publishConfig?.access === 'public' ||
|
|
185
|
+
(!latestData.publishConfig?.access && !pkg.name.startsWith('@'));
|
|
186
|
+
if (isPublic && !latestData.private) {
|
|
187
|
+
results.alerts.push({
|
|
188
|
+
severity: ALERT_SEVERITY.INFO,
|
|
189
|
+
type: 'PUBLIC_PACKAGE',
|
|
190
|
+
message: `Package "${pkg.name}" is publicly accessible`,
|
|
191
|
+
affected: [pkg.name],
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} catch (e) {
|
|
196
|
+
// Package detail fetch failed — may be unpublished
|
|
197
|
+
if (this.verbose) {
|
|
198
|
+
results.alerts.push({
|
|
199
|
+
severity: ALERT_SEVERITY.LOW,
|
|
200
|
+
type: 'DETAIL_FETCH_FAILED',
|
|
201
|
+
message: `Could not fetch details for "${pkg.name}": ${e.message}`,
|
|
202
|
+
affected: [pkg.name],
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
} catch (e) {
|
|
208
|
+
results.alerts.push({
|
|
209
|
+
severity: ALERT_SEVERITY.HIGH,
|
|
210
|
+
type: 'API_ERROR',
|
|
211
|
+
message: `npm registry API error: ${e.message}`,
|
|
212
|
+
affected: [],
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
this.results.npm = results;
|
|
217
|
+
this.alerts.push(...results.alerts);
|
|
218
|
+
return results;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── GitHub Audit ───────────────────────────────────────────────
|
|
222
|
+
async auditGithub(username) {
|
|
223
|
+
if (!username) throw new Error('GitHub username is required');
|
|
224
|
+
const results = { repos: [], alerts: [] };
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
// Fetch public repos (paginate up to 300)
|
|
228
|
+
let page = 1;
|
|
229
|
+
let allRepos = [];
|
|
230
|
+
while (page <= 3) {
|
|
231
|
+
const res = await this._httpGet(
|
|
232
|
+
`https://api.github.com/users/${encodeURIComponent(username)}/repos?per_page=100&page=${page}&sort=updated`,
|
|
233
|
+
{
|
|
234
|
+
timeout: this.timeout,
|
|
235
|
+
headers: { 'Accept': 'application/vnd.github+json' },
|
|
236
|
+
}
|
|
237
|
+
);
|
|
238
|
+
if (!res.data || res.data.length === 0) break;
|
|
239
|
+
allRepos = allRepos.concat(res.data);
|
|
240
|
+
if (res.data.length < 100) break;
|
|
241
|
+
page++;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
for (const repo of allRepos) {
|
|
245
|
+
const repoInfo = {
|
|
246
|
+
name: repo.name,
|
|
247
|
+
full_name: repo.full_name,
|
|
248
|
+
visibility: repo.private ? 'private' : 'public',
|
|
249
|
+
size_kb: repo.size,
|
|
250
|
+
fork: repo.fork,
|
|
251
|
+
description: repo.description || '',
|
|
252
|
+
default_branch: repo.default_branch,
|
|
253
|
+
updated_at: repo.updated_at,
|
|
254
|
+
html_url: repo.html_url,
|
|
255
|
+
};
|
|
256
|
+
results.repos.push(repoInfo);
|
|
257
|
+
|
|
258
|
+
// Check for large repo size (potential node_modules committed)
|
|
259
|
+
if (repo.size > 100000) { // 100MB in KB
|
|
260
|
+
results.alerts.push({
|
|
261
|
+
severity: ALERT_SEVERITY.MEDIUM,
|
|
262
|
+
type: 'LARGE_REPO',
|
|
263
|
+
message: `Repository "${repo.full_name}" is unusually large (${Math.round(repo.size / 1024)}MB) — may contain node_modules or binary files`,
|
|
264
|
+
affected: [repo.full_name],
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Check repo contents for suspicious files (top-level only)
|
|
269
|
+
try {
|
|
270
|
+
const contentsRes = await this._httpGet(
|
|
271
|
+
`https://api.github.com/repos/${repo.full_name}/contents/`,
|
|
272
|
+
{
|
|
273
|
+
timeout: this.timeout,
|
|
274
|
+
headers: { 'Accept': 'application/vnd.github+json' },
|
|
275
|
+
}
|
|
276
|
+
);
|
|
277
|
+
if (Array.isArray(contentsRes.data)) {
|
|
278
|
+
const names = contentsRes.data.map(f => f.name);
|
|
279
|
+
|
|
280
|
+
if (names.includes('node_modules')) {
|
|
281
|
+
results.alerts.push({
|
|
282
|
+
severity: ALERT_SEVERITY.CRITICAL,
|
|
283
|
+
type: 'NODE_MODULES_COMMITTED',
|
|
284
|
+
message: `Repository "${repo.full_name}" has node_modules/ committed`,
|
|
285
|
+
affected: [repo.full_name],
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
for (const envFile of ['.env', '.env.local', '.env.production']) {
|
|
290
|
+
if (names.includes(envFile)) {
|
|
291
|
+
results.alerts.push({
|
|
292
|
+
severity: ALERT_SEVERITY.CRITICAL,
|
|
293
|
+
type: 'ENV_FILE_COMMITTED',
|
|
294
|
+
message: `Repository "${repo.full_name}" has ${envFile} committed`,
|
|
295
|
+
affected: [repo.full_name],
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
for (const keyFile of names.filter(n => n.endsWith('.key') || n.endsWith('.pem'))) {
|
|
301
|
+
results.alerts.push({
|
|
302
|
+
severity: ALERT_SEVERITY.CRITICAL,
|
|
303
|
+
type: 'KEY_FILE_COMMITTED',
|
|
304
|
+
message: `Repository "${repo.full_name}" has ${keyFile} committed`,
|
|
305
|
+
affected: [repo.full_name],
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
} catch (e) {
|
|
310
|
+
// Contents fetch failed — empty repo or rate limited
|
|
311
|
+
if (this.verbose) {
|
|
312
|
+
results.alerts.push({
|
|
313
|
+
severity: ALERT_SEVERITY.LOW,
|
|
314
|
+
type: 'CONTENTS_FETCH_FAILED',
|
|
315
|
+
message: `Could not inspect contents of "${repo.full_name}": ${e.message}`,
|
|
316
|
+
affected: [repo.full_name],
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
} catch (e) {
|
|
322
|
+
results.alerts.push({
|
|
323
|
+
severity: ALERT_SEVERITY.HIGH,
|
|
324
|
+
type: 'API_ERROR',
|
|
325
|
+
message: `GitHub API error: ${e.message}`,
|
|
326
|
+
affected: [],
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
this.results.github = results;
|
|
331
|
+
this.alerts.push(...results.alerts);
|
|
332
|
+
return results;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── ClawHub Audit ──────────────────────────────────────────────
|
|
336
|
+
async auditClawHub(query) {
|
|
337
|
+
if (!query) throw new Error('ClawHub search query is required');
|
|
338
|
+
const results = { skills: [], alerts: [] };
|
|
339
|
+
|
|
340
|
+
// Known malicious skill patterns (from IoC research)
|
|
341
|
+
const KNOWN_MALICIOUS_PATTERNS = [
|
|
342
|
+
'atomic-stealer', 'crypto-miner', 'reverse-shell',
|
|
343
|
+
'claw-havoc', 'data-exfil', 'token-steal',
|
|
344
|
+
];
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
// Try clawhub CLI first
|
|
348
|
+
const output = execSync(`clawhub search "${query}" --json 2>/dev/null`, {
|
|
349
|
+
timeout: this.timeout,
|
|
350
|
+
encoding: 'utf-8',
|
|
351
|
+
});
|
|
352
|
+
const parsed = JSON.parse(output);
|
|
353
|
+
if (Array.isArray(parsed)) {
|
|
354
|
+
results.skills = parsed.map(s => ({
|
|
355
|
+
name: s.name || s.title,
|
|
356
|
+
author: s.author || 'unknown',
|
|
357
|
+
downloads: s.downloads || 0,
|
|
358
|
+
stars: s.stars || 0,
|
|
359
|
+
version: s.version || '0.0.0',
|
|
360
|
+
description: s.description || '',
|
|
361
|
+
}));
|
|
362
|
+
}
|
|
363
|
+
} catch (e) {
|
|
364
|
+
// clawhub CLI not available — graceful degradation
|
|
365
|
+
results.alerts.push({
|
|
366
|
+
severity: ALERT_SEVERITY.LOW,
|
|
367
|
+
type: 'CLAWHUB_CLI_UNAVAILABLE',
|
|
368
|
+
message: 'clawhub CLI not found — install with: npm install -g @anthropic-ai/clawhub',
|
|
369
|
+
affected: [],
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Check for known malicious patterns in results
|
|
374
|
+
for (const skill of results.skills) {
|
|
375
|
+
const lowerName = (skill.name || '').toLowerCase();
|
|
376
|
+
const lowerDesc = (skill.description || '').toLowerCase();
|
|
377
|
+
|
|
378
|
+
for (const pattern of KNOWN_MALICIOUS_PATTERNS) {
|
|
379
|
+
if (lowerName.includes(pattern) || lowerDesc.includes(pattern)) {
|
|
380
|
+
results.alerts.push({
|
|
381
|
+
severity: ALERT_SEVERITY.CRITICAL,
|
|
382
|
+
type: 'KNOWN_MALICIOUS_SKILL',
|
|
383
|
+
message: `Skill "${skill.name}" matches known malicious pattern: ${pattern}`,
|
|
384
|
+
affected: [skill.name],
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Suspicious DL/star ratio
|
|
390
|
+
if (skill.downloads > 100 && skill.stars === 0) {
|
|
391
|
+
results.alerts.push({
|
|
392
|
+
severity: ALERT_SEVERITY.MEDIUM,
|
|
393
|
+
type: 'SUSPICIOUS_DL_STAR_RATIO',
|
|
394
|
+
message: `Skill "${skill.name}" has ${skill.downloads} downloads but 0 stars — may indicate automated/suspicious usage`,
|
|
395
|
+
affected: [skill.name],
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
this.results.clawhub = results;
|
|
401
|
+
this.alerts.push(...results.alerts);
|
|
402
|
+
return results;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── Summary & Output ───────────────────────────────────────────
|
|
406
|
+
getAlertCounts() {
|
|
407
|
+
const counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, INFO: 0 };
|
|
408
|
+
for (const alert of this.alerts) {
|
|
409
|
+
counts[alert.severity] = (counts[alert.severity] || 0) + 1;
|
|
410
|
+
}
|
|
411
|
+
return counts;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
getVerdict() {
|
|
415
|
+
const counts = this.getAlertCounts();
|
|
416
|
+
if (counts.CRITICAL > 0) return { label: 'CRITICAL EXPOSURE', exitCode: 2 };
|
|
417
|
+
if (counts.HIGH > 0) return { label: 'HIGH RISK', exitCode: 1 };
|
|
418
|
+
if (counts.MEDIUM > 0) return { label: 'NEEDS ATTENTION', exitCode: 0 };
|
|
419
|
+
return { label: 'ALL CLEAR', exitCode: 0 };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
printSummary() {
|
|
423
|
+
if (this.quiet) return;
|
|
424
|
+
const counts = this.getAlertCounts();
|
|
425
|
+
const verdict = this.getVerdict();
|
|
426
|
+
|
|
427
|
+
console.log('\n🛡️ guard-scanner asset audit');
|
|
428
|
+
console.log('═'.repeat(50));
|
|
429
|
+
|
|
430
|
+
if (this.results.npm) {
|
|
431
|
+
console.log(`\n📦 npm: ${this.results.npm.packages.length} packages found`);
|
|
432
|
+
}
|
|
433
|
+
if (this.results.github) {
|
|
434
|
+
console.log(`🐙 GitHub: ${this.results.github.repos.length} repositories found`);
|
|
435
|
+
}
|
|
436
|
+
if (this.results.clawhub) {
|
|
437
|
+
console.log(`🦀 ClawHub: ${this.results.clawhub.skills.length} skills found`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
console.log(`\n📊 Alerts: ${counts.CRITICAL} CRITICAL | ${counts.HIGH} HIGH | ${counts.MEDIUM} MEDIUM | ${counts.LOW} LOW | ${counts.INFO} INFO`);
|
|
441
|
+
console.log(`\n🏷️ Verdict: ${verdict.label}`);
|
|
442
|
+
|
|
443
|
+
if (this.verbose && this.alerts.length > 0) {
|
|
444
|
+
console.log('\n── Detailed Alerts ──');
|
|
445
|
+
for (const alert of this.alerts) {
|
|
446
|
+
const icon = alert.severity === 'CRITICAL' ? '🚨' :
|
|
447
|
+
alert.severity === 'HIGH' ? '⚠️' :
|
|
448
|
+
alert.severity === 'MEDIUM' ? '🔶' :
|
|
449
|
+
alert.severity === 'LOW' ? 'ℹ️' : '✅';
|
|
450
|
+
console.log(` ${icon} [${alert.severity}] ${alert.type}: ${alert.message}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
console.log('═'.repeat(50));
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
toJSON() {
|
|
458
|
+
return {
|
|
459
|
+
timestamp: new Date().toISOString(),
|
|
460
|
+
scanner: `guard-scanner/${AUDIT_VERSION}`,
|
|
461
|
+
type: 'asset-audit',
|
|
462
|
+
results: this.results,
|
|
463
|
+
alerts: this.alerts,
|
|
464
|
+
counts: this.getAlertCounts(),
|
|
465
|
+
verdict: this.getVerdict(),
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
toSARIF() {
|
|
470
|
+
const verdict = this.getVerdict();
|
|
471
|
+
return {
|
|
472
|
+
version: '2.1.0',
|
|
473
|
+
$schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json',
|
|
474
|
+
runs: [{
|
|
475
|
+
tool: {
|
|
476
|
+
driver: {
|
|
477
|
+
name: 'guard-scanner',
|
|
478
|
+
version: AUDIT_VERSION,
|
|
479
|
+
informationUri: 'https://github.com/koatora20/guard-scanner',
|
|
480
|
+
rules: this.alerts.map(a => ({
|
|
481
|
+
id: a.type,
|
|
482
|
+
shortDescription: { text: a.message },
|
|
483
|
+
defaultConfiguration: {
|
|
484
|
+
level: a.severity === 'CRITICAL' ? 'error' :
|
|
485
|
+
a.severity === 'HIGH' ? 'error' :
|
|
486
|
+
a.severity === 'MEDIUM' ? 'warning' : 'note',
|
|
487
|
+
},
|
|
488
|
+
})),
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
results: this.alerts.map(a => ({
|
|
492
|
+
ruleId: a.type,
|
|
493
|
+
level: a.severity === 'CRITICAL' ? 'error' :
|
|
494
|
+
a.severity === 'HIGH' ? 'error' :
|
|
495
|
+
a.severity === 'MEDIUM' ? 'warning' : 'note',
|
|
496
|
+
message: { text: a.message },
|
|
497
|
+
locations: a.affected.map(name => ({
|
|
498
|
+
physicalLocation: {
|
|
499
|
+
artifactLocation: { uri: name },
|
|
500
|
+
},
|
|
501
|
+
})),
|
|
502
|
+
})),
|
|
503
|
+
}],
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
module.exports = { AssetAuditor, AUDIT_VERSION, ALERT_SEVERITY, httpGet };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* guard-scanner v8 — CI/CD Reporter
|
|
3
|
+
*
|
|
4
|
+
* @security-manifest
|
|
5
|
+
* env-read: [GITHUB_OUTPUT, GITHUB_STEP_SUMMARY]
|
|
6
|
+
* env-write: []
|
|
7
|
+
* network: [webhook URL if configured]
|
|
8
|
+
* fs-read: []
|
|
9
|
+
* fs-write: [GitHub annotations file]
|
|
10
|
+
* exec: none
|
|
11
|
+
* purpose: CI/CD pipeline integration for guard-scanner results
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const https = require('https');
|
|
16
|
+
|
|
17
|
+
class CIReporter {
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
this.format = options.format || 'github'; // github | gitlab | webhook
|
|
20
|
+
this.verbose = options.verbose || false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── GitHub Actions Annotations ────────────────────────────
|
|
24
|
+
toGitHubAnnotations(findings) {
|
|
25
|
+
const annotations = [];
|
|
26
|
+
for (const skill of findings) {
|
|
27
|
+
for (const f of (skill.findings || [])) {
|
|
28
|
+
const level = f.severity === 'CRITICAL' ? 'error' :
|
|
29
|
+
f.severity === 'HIGH' ? 'error' :
|
|
30
|
+
f.severity === 'MEDIUM' ? 'warning' : 'notice';
|
|
31
|
+
annotations.push({
|
|
32
|
+
level,
|
|
33
|
+
message: `[${f.id}] ${f.desc}`,
|
|
34
|
+
file: f.file || skill.skill || 'unknown',
|
|
35
|
+
line: f.line || 1,
|
|
36
|
+
col: 1,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return annotations;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// GitHub Actions workflow commands (stdout format)
|
|
44
|
+
printGitHubAnnotations(findings) {
|
|
45
|
+
const annotations = this.toGitHubAnnotations(findings);
|
|
46
|
+
for (const a of annotations) {
|
|
47
|
+
// ::error file={name},line={line},col={col}::{message}
|
|
48
|
+
console.log(`::${a.level} file=${a.file},line=${a.line},col=${a.col}::${a.message}`);
|
|
49
|
+
}
|
|
50
|
+
return annotations.length;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// GitHub step summary (markdown)
|
|
54
|
+
toGitHubSummary(findings, stats) {
|
|
55
|
+
let md = '## 🛡️ Guard Scanner Report\n\n';
|
|
56
|
+
md += `| Metric | Value |\n|---|---|\n`;
|
|
57
|
+
md += `| Scanned | ${stats.scanned || 0} |\n`;
|
|
58
|
+
md += `| Clean | ${stats.clean || 0} |\n`;
|
|
59
|
+
md += `| Suspicious | ${stats.suspicious || 0} |\n`;
|
|
60
|
+
md += `| Malicious | ${stats.malicious || 0} |\n\n`;
|
|
61
|
+
|
|
62
|
+
if (findings.length > 0) {
|
|
63
|
+
md += '### Findings\n\n';
|
|
64
|
+
md += '| Skill | Verdict | Risk | Findings |\n|---|---|---|---|\n';
|
|
65
|
+
for (const s of findings) {
|
|
66
|
+
md += `| ${s.skill} | ${s.verdict} | ${s.risk} | ${s.findings?.length || 0} |\n`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return md;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Write summary to $GITHUB_STEP_SUMMARY
|
|
73
|
+
writeGitHubSummary(findings, stats) {
|
|
74
|
+
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
|
|
75
|
+
if (summaryPath) {
|
|
76
|
+
const md = this.toGitHubSummary(findings, stats);
|
|
77
|
+
fs.appendFileSync(summaryPath, md);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── GitLab Code Quality ────────────────────────────────────
|
|
84
|
+
toGitLabCodeQuality(findings) {
|
|
85
|
+
const issues = [];
|
|
86
|
+
for (const skill of findings) {
|
|
87
|
+
for (const f of (skill.findings || [])) {
|
|
88
|
+
issues.push({
|
|
89
|
+
type: 'issue',
|
|
90
|
+
check_name: f.id,
|
|
91
|
+
description: f.desc,
|
|
92
|
+
categories: [f.cat],
|
|
93
|
+
severity: f.severity === 'CRITICAL' ? 'blocker' :
|
|
94
|
+
f.severity === 'HIGH' ? 'critical' :
|
|
95
|
+
f.severity === 'MEDIUM' ? 'major' : 'minor',
|
|
96
|
+
location: {
|
|
97
|
+
path: f.file || skill.skill || 'unknown',
|
|
98
|
+
lines: { begin: f.line || 1 },
|
|
99
|
+
},
|
|
100
|
+
fingerprint: `${f.id}-${f.file || skill.skill}-${f.line || 0}`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return issues;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Webhook Notification ───────────────────────────────────
|
|
108
|
+
async sendWebhook(url, payload) {
|
|
109
|
+
if (!url) throw new Error('Webhook URL is required');
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
const data = JSON.stringify(payload);
|
|
112
|
+
const urlObj = new URL(url);
|
|
113
|
+
const req = https.request({
|
|
114
|
+
hostname: urlObj.hostname,
|
|
115
|
+
port: urlObj.port || 443,
|
|
116
|
+
path: urlObj.pathname + urlObj.search,
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: {
|
|
119
|
+
'Content-Type': 'application/json',
|
|
120
|
+
'Content-Length': Buffer.byteLength(data),
|
|
121
|
+
},
|
|
122
|
+
}, (res) => {
|
|
123
|
+
let body = '';
|
|
124
|
+
res.on('data', chunk => { body += chunk; });
|
|
125
|
+
res.on('end', () => resolve({ status: res.statusCode, body }));
|
|
126
|
+
});
|
|
127
|
+
req.on('error', reject);
|
|
128
|
+
req.setTimeout(15000, () => { req.destroy(); reject(new Error('Webhook timeout')); });
|
|
129
|
+
req.write(data);
|
|
130
|
+
req.end();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { CIReporter };
|