@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.
@@ -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 };