@runhalo/cli 0.1.0 → 0.4.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 +97 -0
- package/dist/index.d.ts +154 -4
- package/dist/index.js +1889 -12
- package/dist/index.js.map +1 -1
- package/package.json +7 -4
package/dist/index.js
CHANGED
|
@@ -37,19 +37,47 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
37
37
|
return result;
|
|
38
38
|
};
|
|
39
39
|
})();
|
|
40
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
41
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
42
|
+
};
|
|
40
43
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
44
|
+
exports.RULES_CACHE_PATH = exports.MAX_HISTORY_ENTRIES = exports.HALO_HISTORY_PATH = exports.HALO_CONFIG_PATH = exports.HALO_CONFIG_DIR = exports.FREE_SCAN_LIMIT = void 0;
|
|
41
45
|
exports.scan = scan;
|
|
46
|
+
exports.fix = fix;
|
|
47
|
+
exports.init = init;
|
|
42
48
|
exports.scanFile = scanFile;
|
|
43
49
|
exports.scanDirectory = scanDirectory;
|
|
44
50
|
exports.createEngine = createEngine;
|
|
45
51
|
exports.formatSARIF = formatSARIF;
|
|
46
52
|
exports.formatJSON = formatJSON;
|
|
47
53
|
exports.formatText = formatText;
|
|
54
|
+
exports.loadConfig = loadConfig;
|
|
55
|
+
exports.saveConfig = saveConfig;
|
|
56
|
+
exports.firstRunPrompt = firstRunPrompt;
|
|
57
|
+
exports.loadHistory = loadHistory;
|
|
58
|
+
exports.saveHistory = saveHistory;
|
|
59
|
+
exports.formatTrend = formatTrend;
|
|
60
|
+
exports.generateHtmlReport = generateHtmlReport;
|
|
61
|
+
exports.generatePdfReport = generatePdfReport;
|
|
62
|
+
exports.escapeHtml = escapeHtml;
|
|
63
|
+
exports.validateLicenseKey = validateLicenseKey;
|
|
64
|
+
exports.activateLicense = activateLicense;
|
|
65
|
+
exports.checkScanLimit = checkScanLimit;
|
|
66
|
+
exports.checkProFeature = checkProFeature;
|
|
67
|
+
exports.resolvePacks = resolvePacks;
|
|
68
|
+
exports.resolveRules = resolveRules;
|
|
69
|
+
exports.fetchRulesFromAPI = fetchRulesFromAPI;
|
|
70
|
+
exports.readRulesCache = readRulesCache;
|
|
71
|
+
exports.writeRulesCache = writeRulesCache;
|
|
72
|
+
exports.loadBaselineRules = loadBaselineRules;
|
|
48
73
|
const commander_1 = require("commander");
|
|
49
74
|
const glob_1 = require("glob");
|
|
50
75
|
const fs = __importStar(require("fs"));
|
|
51
76
|
const path = __importStar(require("path"));
|
|
77
|
+
const os = __importStar(require("os"));
|
|
78
|
+
const readline = __importStar(require("readline"));
|
|
52
79
|
const engine_1 = require("@runhalo/engine");
|
|
80
|
+
const pdfkit_1 = __importDefault(require("pdfkit"));
|
|
53
81
|
// CLI configuration
|
|
54
82
|
const program = new commander_1.Command();
|
|
55
83
|
/**
|
|
@@ -75,7 +103,12 @@ function getDefaultPatterns() {
|
|
|
75
103
|
'**/*.h',
|
|
76
104
|
'**/*.hpp',
|
|
77
105
|
'**/*.cs',
|
|
78
|
-
'**/*.qml'
|
|
106
|
+
'**/*.qml',
|
|
107
|
+
// Added P3-0: Go, Ruby, XML for multi-language coverage
|
|
108
|
+
'**/*.go',
|
|
109
|
+
'**/*.rb',
|
|
110
|
+
'**/*.xml',
|
|
111
|
+
'**/*.erb'
|
|
79
112
|
];
|
|
80
113
|
}
|
|
81
114
|
/**
|
|
@@ -175,7 +208,7 @@ function formatSARIF(results, rules) {
|
|
|
175
208
|
/**
|
|
176
209
|
* Format violations as JSON output
|
|
177
210
|
*/
|
|
178
|
-
function formatJSON(results) {
|
|
211
|
+
function formatJSON(results, scoreResult) {
|
|
179
212
|
const output = {
|
|
180
213
|
version: '1.0.0',
|
|
181
214
|
scannedAt: new Date().toISOString(),
|
|
@@ -183,6 +216,15 @@ function formatJSON(results) {
|
|
|
183
216
|
totalViolations: results.reduce((sum, r) => sum + r.violations.length, 0),
|
|
184
217
|
results: results
|
|
185
218
|
};
|
|
219
|
+
if (scoreResult) {
|
|
220
|
+
output.complianceScore = {
|
|
221
|
+
score: scoreResult.score,
|
|
222
|
+
grade: scoreResult.grade,
|
|
223
|
+
pointsDeducted: scoreResult.pointsDeducted,
|
|
224
|
+
bySeverity: scoreResult.bySeverity,
|
|
225
|
+
rulesTriggered: scoreResult.rulesTriggered,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
186
228
|
return JSON.stringify(output, null, 2);
|
|
187
229
|
}
|
|
188
230
|
// ANSI color codes for terminal output
|
|
@@ -199,6 +241,7 @@ const colors = {
|
|
|
199
241
|
bgYellow: '\x1b[43m',
|
|
200
242
|
bgBlue: '\x1b[44m',
|
|
201
243
|
magenta: '\x1b[35m',
|
|
244
|
+
green: '\x1b[32m',
|
|
202
245
|
};
|
|
203
246
|
// Detect if color should be used (respect NO_COLOR env and pipe detection)
|
|
204
247
|
const useColor = !process.env.NO_COLOR && process.stdout.isTTY !== false;
|
|
@@ -208,7 +251,7 @@ function c(color, text) {
|
|
|
208
251
|
/**
|
|
209
252
|
* Format violations as human-readable text
|
|
210
253
|
*/
|
|
211
|
-
function formatText(results, verbose = false, fileCount = 0) {
|
|
254
|
+
function formatText(results, verbose = false, fileCount = 0, scoreResult) {
|
|
212
255
|
let output = '';
|
|
213
256
|
let totalViolations = 0;
|
|
214
257
|
let criticalCount = 0;
|
|
@@ -270,7 +313,12 @@ function formatText(results, verbose = false, fileCount = 0) {
|
|
|
270
313
|
}
|
|
271
314
|
if (totalViolations === 0) {
|
|
272
315
|
const scannedMsg = fileCount > 0 ? ` (${fileCount} files scanned)` : '';
|
|
273
|
-
|
|
316
|
+
let cleanOutput = `${c(colors.bold, '✅ No COPPA issues detected!')}${scannedMsg}\n`;
|
|
317
|
+
// Show perfect score for clean repos
|
|
318
|
+
if (scoreResult) {
|
|
319
|
+
cleanOutput += `\n${formatScoreLine(scoreResult)}\n`;
|
|
320
|
+
}
|
|
321
|
+
return cleanOutput;
|
|
274
322
|
}
|
|
275
323
|
// Summary header
|
|
276
324
|
let header = `\n${c(colors.bold, `⚠ Found ${totalViolations} issue(s)`)}`;
|
|
@@ -290,8 +338,664 @@ function formatText(results, verbose = false, fileCount = 0) {
|
|
|
290
338
|
if (lowCount > 0)
|
|
291
339
|
parts.push(c(colors.dim, `${lowCount} low`));
|
|
292
340
|
header += ` ${parts.join(c(colors.dim, ' · '))}\n`;
|
|
341
|
+
// Compliance score line
|
|
342
|
+
if (scoreResult) {
|
|
343
|
+
header += `\n${formatScoreLine(scoreResult)}\n`;
|
|
344
|
+
}
|
|
293
345
|
return header + output;
|
|
294
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* Format the compliance score line with grade coloring
|
|
349
|
+
*/
|
|
350
|
+
function formatScoreLine(scoreResult) {
|
|
351
|
+
const { score, grade } = scoreResult;
|
|
352
|
+
// Color the grade based on value
|
|
353
|
+
let gradeColor;
|
|
354
|
+
switch (grade) {
|
|
355
|
+
case 'A':
|
|
356
|
+
gradeColor = '\x1b[32m'; // green
|
|
357
|
+
break;
|
|
358
|
+
case 'B':
|
|
359
|
+
gradeColor = '\x1b[36m'; // cyan
|
|
360
|
+
break;
|
|
361
|
+
case 'C':
|
|
362
|
+
gradeColor = '\x1b[33m'; // yellow
|
|
363
|
+
break;
|
|
364
|
+
case 'D':
|
|
365
|
+
gradeColor = '\x1b[33m'; // yellow
|
|
366
|
+
break;
|
|
367
|
+
default:
|
|
368
|
+
gradeColor = '\x1b[31m'; // red for F
|
|
369
|
+
}
|
|
370
|
+
const gradeStr = c(gradeColor + colors.bold, grade);
|
|
371
|
+
const scoreStr = c(colors.bold, `${score}/100`);
|
|
372
|
+
return `${c(colors.bold, '📊 COPPA Compliance Score:')} ${scoreStr} (${gradeStr})`;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Format trend line comparing current score to last scan for the same project.
|
|
376
|
+
* Returns empty string if no prior history exists.
|
|
377
|
+
*/
|
|
378
|
+
function formatTrend(currentScore, projectPath) {
|
|
379
|
+
const history = loadHistory();
|
|
380
|
+
const projectHistory = history.filter(h => h.projectPath === projectPath);
|
|
381
|
+
if (projectHistory.length === 0)
|
|
382
|
+
return '';
|
|
383
|
+
const last = projectHistory[projectHistory.length - 1];
|
|
384
|
+
const diff = currentScore - last.score;
|
|
385
|
+
if (diff > 0) {
|
|
386
|
+
return ` ${c('\x1b[32m', `↑ ${last.score}% → ${currentScore}% (+${diff} since last scan)`)}`;
|
|
387
|
+
}
|
|
388
|
+
else if (diff < 0) {
|
|
389
|
+
return ` ${c(colors.red, `↓ ${last.score}% → ${currentScore}% (${diff} since last scan)`)}`;
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
return ` ${c(colors.dim, `→ ${currentScore}% (no change since last scan)`)}`;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// ==================== HTML Report Generator ====================
|
|
396
|
+
/**
|
|
397
|
+
* Generate a self-contained HTML compliance report.
|
|
398
|
+
* Light theme for email-ability. All CSS inline. No external dependencies.
|
|
399
|
+
*/
|
|
400
|
+
function generateHtmlReport(results, scoreResult, fileCount, projectPath, history) {
|
|
401
|
+
const scanDate = new Date().toLocaleDateString('en-US', {
|
|
402
|
+
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
|
|
403
|
+
});
|
|
404
|
+
const scanTime = new Date().toLocaleTimeString('en-US', {
|
|
405
|
+
hour: '2-digit', minute: '2-digit'
|
|
406
|
+
});
|
|
407
|
+
const totalViolations = results.reduce((sum, r) => sum + r.violations.length, 0);
|
|
408
|
+
const allViolations = results.flatMap(r => r.violations);
|
|
409
|
+
// Grade color
|
|
410
|
+
const gradeColorMap = {
|
|
411
|
+
A: '#22c55e', B: '#3b82f6', C: '#eab308', D: '#f97316', F: '#ef4444'
|
|
412
|
+
};
|
|
413
|
+
const gradeColor = gradeColorMap[scoreResult.grade] || '#6b7280';
|
|
414
|
+
// Trend HTML
|
|
415
|
+
let trendHtml = '';
|
|
416
|
+
if (history && history.length > 0) {
|
|
417
|
+
const projectHistory = history.filter(h => h.projectPath === projectPath);
|
|
418
|
+
if (projectHistory.length > 0) {
|
|
419
|
+
const last = projectHistory[projectHistory.length - 1];
|
|
420
|
+
const diff = scoreResult.score - last.score;
|
|
421
|
+
if (diff > 0) {
|
|
422
|
+
trendHtml = `<div style="margin-top:8px;color:#22c55e;font-size:14px;">↑ ${last.score}% → ${scoreResult.score}% (+${diff} since last scan)</div>`;
|
|
423
|
+
}
|
|
424
|
+
else if (diff < 0) {
|
|
425
|
+
trendHtml = `<div style="margin-top:8px;color:#ef4444;font-size:14px;">↓ ${last.score}% → ${scoreResult.score}% (${diff} since last scan)</div>`;
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
trendHtml = `<div style="margin-top:8px;color:#6b7280;font-size:14px;">→ ${scoreResult.score}% (no change since last scan)</div>`;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
// Severity bar segments
|
|
433
|
+
const { critical = 0, high = 0, medium = 0, low = 0 } = scoreResult.bySeverity || {};
|
|
434
|
+
const severityTotal = critical + high + medium + low;
|
|
435
|
+
const severityBar = severityTotal > 0 ? `
|
|
436
|
+
<div style="display:flex;height:8px;border-radius:4px;overflow:hidden;margin:12px 0;">
|
|
437
|
+
${critical > 0 ? `<div style="width:${(critical / severityTotal * 100).toFixed(1)}%;background:#ef4444;"></div>` : ''}
|
|
438
|
+
${high > 0 ? `<div style="width:${(high / severityTotal * 100).toFixed(1)}%;background:#f97316;"></div>` : ''}
|
|
439
|
+
${medium > 0 ? `<div style="width:${(medium / severityTotal * 100).toFixed(1)}%;background:#eab308;"></div>` : ''}
|
|
440
|
+
${low > 0 ? `<div style="width:${(low / severityTotal * 100).toFixed(1)}%;background:#3b82f6;"></div>` : ''}
|
|
441
|
+
</div>` : '';
|
|
442
|
+
// Violations by file
|
|
443
|
+
const violationsHtml = results.map(result => {
|
|
444
|
+
if (result.violations.length === 0)
|
|
445
|
+
return '';
|
|
446
|
+
const relPath = path.relative(projectPath, result.filePath) || result.filePath;
|
|
447
|
+
const fileViolations = result.violations.map(v => {
|
|
448
|
+
const sevColors = {
|
|
449
|
+
critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#3b82f6'
|
|
450
|
+
};
|
|
451
|
+
const sevColor = sevColors[v.severity] || '#6b7280';
|
|
452
|
+
const sevBg = {
|
|
453
|
+
critical: '#fef2f2', high: '#fff7ed', medium: '#fefce8', low: '#eff6ff'
|
|
454
|
+
};
|
|
455
|
+
const bg = sevBg[v.severity] || '#f9fafb';
|
|
456
|
+
const snippet = v.codeSnippet
|
|
457
|
+
? `<pre style="background:#f1f5f9;border:1px solid #e2e8f0;border-radius:4px;padding:8px;font-size:12px;overflow-x:auto;margin:4px 0 0 0;">${escapeHtml(v.codeSnippet)}</pre>`
|
|
458
|
+
: '';
|
|
459
|
+
return `
|
|
460
|
+
<div style="padding:12px;margin:8px 0;background:${bg};border-left:3px solid ${sevColor};border-radius:4px;">
|
|
461
|
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
|
|
462
|
+
<span style="display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600;color:white;background:${sevColor};text-transform:uppercase;">${v.severity}</span>
|
|
463
|
+
<code style="font-size:12px;color:#6366f1;">${v.ruleId}</code>
|
|
464
|
+
<span style="font-size:12px;color:#6b7280;">Line ${v.line}</span>
|
|
465
|
+
</div>
|
|
466
|
+
<div style="font-size:13px;color:#1e293b;margin-bottom:4px;">${escapeHtml(v.message)}</div>
|
|
467
|
+
${snippet}
|
|
468
|
+
${v.fixSuggestion ? `<div style="font-size:12px;color:#059669;margin-top:6px;">💡 ${escapeHtml(v.fixSuggestion)}</div>` : ''}
|
|
469
|
+
${v.penalty ? `<div style="font-size:11px;color:#dc2626;margin-top:4px;">⚠ Penalty: ${escapeHtml(v.penalty)}</div>` : ''}
|
|
470
|
+
</div>`;
|
|
471
|
+
}).join('');
|
|
472
|
+
return `
|
|
473
|
+
<details style="margin:8px 0;border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;">
|
|
474
|
+
<summary style="padding:12px 16px;background:#f8fafc;cursor:pointer;font-weight:500;font-size:14px;display:flex;justify-content:space-between;align-items:center;">
|
|
475
|
+
<span style="font-family:monospace;color:#1e293b;">${escapeHtml(relPath)}</span>
|
|
476
|
+
<span style="background:#fee2e2;color:#dc2626;padding:2px 8px;border-radius:12px;font-size:12px;font-weight:600;">${result.violations.length} issue${result.violations.length !== 1 ? 's' : ''}</span>
|
|
477
|
+
</summary>
|
|
478
|
+
<div style="padding:8px 16px 16px 16px;">
|
|
479
|
+
${fileViolations}
|
|
480
|
+
</div>
|
|
481
|
+
</details>`;
|
|
482
|
+
}).filter(Boolean).join('');
|
|
483
|
+
// Auto-fixable section
|
|
484
|
+
const autoFixable = allViolations.filter(v => ['coppa-sec-006', 'coppa-sec-010', 'coppa-sec-015', 'coppa-default-020'].includes(v.ruleId));
|
|
485
|
+
const autoFixHtml = autoFixable.length > 0 ? `
|
|
486
|
+
<div style="margin:24px 0;padding:16px;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;">
|
|
487
|
+
<h3 style="margin:0 0 8px 0;color:#166534;font-size:16px;">🔧 Auto-Fixable Issues (${autoFixable.length})</h3>
|
|
488
|
+
<p style="margin:0 0 12px 0;color:#15803d;font-size:13px;">These violations can be automatically fixed. Run:</p>
|
|
489
|
+
<code style="display:block;background:#166534;color:#bbf7d0;padding:10px 14px;border-radius:6px;font-size:13px;">npx runhalo fix .</code>
|
|
490
|
+
</div>` : '';
|
|
491
|
+
// Recommendations
|
|
492
|
+
const recommendations = [];
|
|
493
|
+
if (critical > 0)
|
|
494
|
+
recommendations.push(`<li style="color:#dc2626;"><strong>Fix ${critical} critical issue${critical !== 1 ? 's' : ''} immediately</strong> — these represent the highest compliance risk and largest potential penalties.</li>`);
|
|
495
|
+
if (high > 0)
|
|
496
|
+
recommendations.push(`<li style="color:#ea580c;"><strong>Address ${high} high-severity issue${high !== 1 ? 's' : ''}</strong> — these are significant compliance gaps that should be resolved before release.</li>`);
|
|
497
|
+
if (autoFixable.length > 0)
|
|
498
|
+
recommendations.push(`<li style="color:#059669;"><strong>Run <code>npx runhalo fix .</code></strong> to automatically resolve ${autoFixable.length} issue${autoFixable.length !== 1 ? 's' : ''} (HTTP→HTTPS, default privacy settings, input sanitization).</li>`);
|
|
499
|
+
if (medium > 0)
|
|
500
|
+
recommendations.push(`<li style="color:#ca8a04;">Review ${medium} medium-severity issue${medium !== 1 ? 's' : ''} — these may require design changes or policy updates.</li>`);
|
|
501
|
+
if (totalViolations === 0)
|
|
502
|
+
recommendations.push(`<li style="color:#22c55e;"><strong>No issues detected!</strong> Your codebase passes all current COPPA compliance checks.</li>`);
|
|
503
|
+
return `<!DOCTYPE html>
|
|
504
|
+
<html lang="en">
|
|
505
|
+
<head>
|
|
506
|
+
<meta charset="UTF-8">
|
|
507
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
508
|
+
<title>Halo COPPA Compliance Report — ${scanDate}</title>
|
|
509
|
+
<style>
|
|
510
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
511
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; color: #1e293b; background: #ffffff; line-height: 1.6; }
|
|
512
|
+
.container { max-width: 800px; margin: 0 auto; padding: 32px 24px; }
|
|
513
|
+
h1 { font-size: 24px; font-weight: 700; margin-bottom: 4px; }
|
|
514
|
+
h2 { font-size: 18px; font-weight: 600; margin: 32px 0 16px 0; color: #0f172a; border-bottom: 2px solid #e2e8f0; padding-bottom: 8px; }
|
|
515
|
+
.header { border-bottom: 3px solid #6366f1; padding-bottom: 24px; margin-bottom: 32px; }
|
|
516
|
+
.score-section { display: flex; align-items: center; gap: 32px; margin: 24px 0; }
|
|
517
|
+
.score-circle { width: 120px; height: 120px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
|
518
|
+
.score-inner { width: 96px; height: 96px; border-radius: 50%; background: white; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
|
519
|
+
.score-value { font-size: 28px; font-weight: 700; }
|
|
520
|
+
.score-label { font-size: 11px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
521
|
+
.grade-badge { display: inline-block; font-size: 36px; font-weight: 800; padding: 4px 16px; border-radius: 8px; }
|
|
522
|
+
.severity-table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
|
523
|
+
.severity-table td, .severity-table th { padding: 8px 12px; text-align: left; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
|
|
524
|
+
.severity-table th { color: #6b7280; font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
525
|
+
.severity-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; }
|
|
526
|
+
.recommendations { margin: 16px 0; }
|
|
527
|
+
.recommendations li { margin: 8px 0; font-size: 14px; line-height: 1.5; }
|
|
528
|
+
.footer { margin-top: 48px; padding-top: 24px; border-top: 1px solid #e2e8f0; color: #94a3b8; font-size: 12px; }
|
|
529
|
+
details summary::-webkit-details-marker { display: none; }
|
|
530
|
+
details summary::before { content: '▶ '; font-size: 10px; color: #94a3b8; }
|
|
531
|
+
details[open] summary::before { content: '▼ '; }
|
|
532
|
+
@media (max-width: 600px) { .score-section { flex-direction: column; align-items: flex-start; } }
|
|
533
|
+
@media print { details { open: true; } details[open] summary { display: none; } }
|
|
534
|
+
</style>
|
|
535
|
+
</head>
|
|
536
|
+
<body>
|
|
537
|
+
<div class="container">
|
|
538
|
+
<div class="header">
|
|
539
|
+
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;">
|
|
540
|
+
<div style="width:32px;height:32px;border-radius:8px;background:linear-gradient(135deg,#6366f1,#a855f7);display:flex;align-items:center;justify-content:center;">
|
|
541
|
+
<span style="color:white;font-weight:700;font-size:14px;">H</span>
|
|
542
|
+
</div>
|
|
543
|
+
<h1>COPPA Compliance Report</h1>
|
|
544
|
+
</div>
|
|
545
|
+
<div style="font-size:13px;color:#6b7280;">
|
|
546
|
+
<span>${scanDate} at ${scanTime}</span>
|
|
547
|
+
<span style="margin:0 8px;">·</span>
|
|
548
|
+
<span>${fileCount} files scanned</span>
|
|
549
|
+
<span style="margin:0 8px;">·</span>
|
|
550
|
+
<span style="font-family:monospace;">${escapeHtml(projectPath)}</span>
|
|
551
|
+
</div>
|
|
552
|
+
</div>
|
|
553
|
+
|
|
554
|
+
<div class="score-section">
|
|
555
|
+
<div class="score-circle" style="background:conic-gradient(${gradeColor} ${scoreResult.score * 3.6}deg, #e2e8f0 0);">
|
|
556
|
+
<div class="score-inner">
|
|
557
|
+
<div class="score-value">${scoreResult.score}</div>
|
|
558
|
+
<div class="score-label">out of 100</div>
|
|
559
|
+
</div>
|
|
560
|
+
</div>
|
|
561
|
+
<div>
|
|
562
|
+
<div class="grade-badge" style="color:${gradeColor};background:${gradeColor}15;">Grade ${scoreResult.grade}</div>
|
|
563
|
+
<div style="margin-top:8px;font-size:14px;color:#475569;">${totalViolations} violation${totalViolations !== 1 ? 's' : ''} found across ${results.filter(r => r.violations.length > 0).length} file${results.filter(r => r.violations.length > 0).length !== 1 ? 's' : ''}</div>
|
|
564
|
+
${trendHtml}
|
|
565
|
+
</div>
|
|
566
|
+
</div>
|
|
567
|
+
|
|
568
|
+
<h2>Severity Breakdown</h2>
|
|
569
|
+
${severityBar}
|
|
570
|
+
<table class="severity-table">
|
|
571
|
+
<tr><th>Severity</th><th>Count</th><th>Points Deducted</th></tr>
|
|
572
|
+
<tr><td><span class="severity-dot" style="background:#ef4444;"></span>Critical</td><td><strong>${critical}</strong></td><td>${critical > 0 ? '-' + (critical * 10) : '—'}</td></tr>
|
|
573
|
+
<tr><td><span class="severity-dot" style="background:#f97316;"></span>High</td><td><strong>${high}</strong></td><td>${high > 0 ? '-' + (high * 5) : '—'}</td></tr>
|
|
574
|
+
<tr><td><span class="severity-dot" style="background:#eab308;"></span>Medium</td><td><strong>${medium}</strong></td><td>${medium > 0 ? '-' + (medium * 2) : '—'}</td></tr>
|
|
575
|
+
<tr><td><span class="severity-dot" style="background:#3b82f6;"></span>Low</td><td><strong>${low}</strong></td><td>${low > 0 ? '-' + low : '—'}</td></tr>
|
|
576
|
+
</table>
|
|
577
|
+
|
|
578
|
+
${autoFixHtml}
|
|
579
|
+
|
|
580
|
+
${totalViolations > 0 ? `<h2>Violations by File</h2>${violationsHtml}` : ''}
|
|
581
|
+
|
|
582
|
+
${recommendations.length > 0 ? `
|
|
583
|
+
<h2>Recommendations</h2>
|
|
584
|
+
<ol class="recommendations">
|
|
585
|
+
${recommendations.join('\n ')}
|
|
586
|
+
</ol>` : ''}
|
|
587
|
+
|
|
588
|
+
<div class="footer">
|
|
589
|
+
<p><strong>Disclaimer:</strong> Halo is a developer tool designed to assist with code analysis and identifying potential privacy issues. It is not legal advice and does not guarantee compliance with COPPA, GDPR, or any other regulation. Always consult with qualified legal counsel regarding your specific compliance obligations.</p>
|
|
590
|
+
<p style="margin-top:8px;">Generated by <strong>Halo</strong> v${CLI_VERSION} · <a href="https://runhalo.dev" style="color:#6366f1;">runhalo.dev</a></p>
|
|
591
|
+
</div>
|
|
592
|
+
</div>
|
|
593
|
+
</body>
|
|
594
|
+
</html>`;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Escape HTML special characters
|
|
598
|
+
*/
|
|
599
|
+
function escapeHtml(text) {
|
|
600
|
+
return text
|
|
601
|
+
.replace(/&/g, '&')
|
|
602
|
+
.replace(/</g, '<')
|
|
603
|
+
.replace(/>/g, '>')
|
|
604
|
+
.replace(/"/g, '"')
|
|
605
|
+
.replace(/'/g, ''');
|
|
606
|
+
}
|
|
607
|
+
// ==================== PDF Report Generator (P3-2) ====================
|
|
608
|
+
// PDF color constants
|
|
609
|
+
const PDF_COLORS = {
|
|
610
|
+
primary: '#6366f1', // Halo accent (indigo)
|
|
611
|
+
purple: '#a855f7',
|
|
612
|
+
green: '#22c55e',
|
|
613
|
+
cyan: '#3b82f6',
|
|
614
|
+
yellow: '#eab308',
|
|
615
|
+
orange: '#f97316',
|
|
616
|
+
red: '#ef4444',
|
|
617
|
+
darkText: '#0f172a',
|
|
618
|
+
bodyText: '#1e293b',
|
|
619
|
+
mutedText: '#64748b',
|
|
620
|
+
lightText: '#94a3b8',
|
|
621
|
+
border: '#e2e8f0',
|
|
622
|
+
lightBg: '#f8fafc',
|
|
623
|
+
white: '#ffffff',
|
|
624
|
+
};
|
|
625
|
+
function gradeColor(grade) {
|
|
626
|
+
switch (grade) {
|
|
627
|
+
case 'A': return PDF_COLORS.green;
|
|
628
|
+
case 'B': return PDF_COLORS.cyan;
|
|
629
|
+
case 'C': return PDF_COLORS.yellow;
|
|
630
|
+
case 'D': return PDF_COLORS.orange;
|
|
631
|
+
default: return PDF_COLORS.red;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
function severityColor(severity) {
|
|
635
|
+
switch (severity) {
|
|
636
|
+
case 'critical': return PDF_COLORS.red;
|
|
637
|
+
case 'high': return PDF_COLORS.orange;
|
|
638
|
+
case 'medium': return PDF_COLORS.yellow;
|
|
639
|
+
default: return PDF_COLORS.cyan;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Generate a government-procurement-grade PDF compliance report.
|
|
644
|
+
* Uses PDFKit — pure JS, no browser dependencies, CI-safe.
|
|
645
|
+
*/
|
|
646
|
+
function generatePdfReport(results, scoreResult, fileCount, projectPath, history) {
|
|
647
|
+
return new Promise((resolve, reject) => {
|
|
648
|
+
const doc = new pdfkit_1.default({
|
|
649
|
+
size: 'LETTER',
|
|
650
|
+
margins: { top: 60, bottom: 60, left: 60, right: 60 },
|
|
651
|
+
info: {
|
|
652
|
+
Title: 'COPPA 2.0 Compliance Report',
|
|
653
|
+
Author: 'Halo by Mindful Media',
|
|
654
|
+
Subject: `Compliance scan of ${projectPath}`,
|
|
655
|
+
Creator: `Halo v${CLI_VERSION}`,
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
const chunks = [];
|
|
659
|
+
doc.on('data', (chunk) => chunks.push(chunk));
|
|
660
|
+
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
|
661
|
+
doc.on('error', reject);
|
|
662
|
+
const pageWidth = doc.page.width - 120; // 60px margins each side
|
|
663
|
+
const scanDate = new Date().toLocaleDateString('en-US', {
|
|
664
|
+
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
|
|
665
|
+
});
|
|
666
|
+
const scanTime = new Date().toLocaleTimeString('en-US', {
|
|
667
|
+
hour: '2-digit', minute: '2-digit'
|
|
668
|
+
});
|
|
669
|
+
const totalViolations = results.reduce((sum, r) => sum + r.violations.length, 0);
|
|
670
|
+
const allViolations = results.flatMap(r => r.violations);
|
|
671
|
+
const { critical = 0, high = 0, medium = 0, low = 0 } = scoreResult.bySeverity || {};
|
|
672
|
+
// ═══════════════ HELPER: Page footer ═══════════════
|
|
673
|
+
let pageNum = 0;
|
|
674
|
+
function addFooter() {
|
|
675
|
+
pageNum++;
|
|
676
|
+
const y = doc.page.height - 40;
|
|
677
|
+
doc.save();
|
|
678
|
+
doc.fontSize(7).fillColor(PDF_COLORS.lightText);
|
|
679
|
+
doc.text(`Generated by Halo v${CLI_VERSION} — runhalo.dev`, 60, y, { width: pageWidth / 2, align: 'left' });
|
|
680
|
+
doc.text(`Page ${pageNum}`, 60, y, { width: pageWidth, align: 'right' });
|
|
681
|
+
doc.restore();
|
|
682
|
+
}
|
|
683
|
+
// ═══════════════ COVER PAGE ═══════════════
|
|
684
|
+
doc.save();
|
|
685
|
+
// Logo block
|
|
686
|
+
const logoX = 60;
|
|
687
|
+
const logoY = 100;
|
|
688
|
+
doc.roundedRect(logoX, logoY, 48, 48, 10).fill(PDF_COLORS.primary);
|
|
689
|
+
doc.fontSize(24).fillColor(PDF_COLORS.white).text('H', logoX + 14, logoY + 10, { width: 48 });
|
|
690
|
+
// Title
|
|
691
|
+
doc.fontSize(32).fillColor(PDF_COLORS.darkText).text('COPPA 2.0', 60, 180, { width: pageWidth });
|
|
692
|
+
doc.fontSize(32).fillColor(PDF_COLORS.darkText).text('Compliance Report', 60, 220, { width: pageWidth });
|
|
693
|
+
// Divider
|
|
694
|
+
doc.moveTo(60, 270).lineTo(60 + pageWidth, 270).lineWidth(2).strokeColor(PDF_COLORS.primary).stroke();
|
|
695
|
+
// Metadata
|
|
696
|
+
doc.fontSize(11).fillColor(PDF_COLORS.mutedText);
|
|
697
|
+
doc.text(`Project: ${projectPath}`, 60, 290);
|
|
698
|
+
doc.text(`Date: ${scanDate} at ${scanTime}`, 60, 308);
|
|
699
|
+
doc.text(`Files: ${fileCount} files scanned`, 60, 326);
|
|
700
|
+
doc.text(`Scanner: Halo v${CLI_VERSION}`, 60, 344);
|
|
701
|
+
// Score display (centered, large)
|
|
702
|
+
const scoreY = 420;
|
|
703
|
+
const scoreCenterX = 60 + pageWidth / 2;
|
|
704
|
+
// Score circle background
|
|
705
|
+
doc.circle(scoreCenterX, scoreY, 60).lineWidth(8).strokeColor(PDF_COLORS.border).stroke();
|
|
706
|
+
// Score arc (proportional to score)
|
|
707
|
+
if (scoreResult.score > 0) {
|
|
708
|
+
const startAngle = -Math.PI / 2;
|
|
709
|
+
const endAngle = startAngle + (scoreResult.score / 100) * 2 * Math.PI;
|
|
710
|
+
// Draw score arc
|
|
711
|
+
doc.save();
|
|
712
|
+
doc.circle(scoreCenterX, scoreY, 60).lineWidth(8).strokeColor(gradeColor(scoreResult.grade)).stroke();
|
|
713
|
+
doc.restore();
|
|
714
|
+
}
|
|
715
|
+
// Score number
|
|
716
|
+
doc.fontSize(36).fillColor(PDF_COLORS.darkText);
|
|
717
|
+
const scoreText = `${scoreResult.score}`;
|
|
718
|
+
doc.text(scoreText, scoreCenterX - 30, scoreY - 20, { width: 60, align: 'center' });
|
|
719
|
+
doc.fontSize(10).fillColor(PDF_COLORS.mutedText);
|
|
720
|
+
doc.text('out of 100', scoreCenterX - 30, scoreY + 20, { width: 60, align: 'center' });
|
|
721
|
+
// Grade badge
|
|
722
|
+
doc.fontSize(48).fillColor(gradeColor(scoreResult.grade));
|
|
723
|
+
doc.text(`Grade ${scoreResult.grade}`, 60, scoreY + 80, { width: pageWidth, align: 'center' });
|
|
724
|
+
// Summary line
|
|
725
|
+
doc.fontSize(12).fillColor(PDF_COLORS.bodyText);
|
|
726
|
+
doc.text(`${totalViolations} violation${totalViolations !== 1 ? 's' : ''} found across ${results.filter(r => r.violations.length > 0).length} file${results.filter(r => r.violations.length > 0).length !== 1 ? 's' : ''}`, 60, scoreY + 140, { width: pageWidth, align: 'center' });
|
|
727
|
+
// Confidentiality notice
|
|
728
|
+
doc.fontSize(8).fillColor(PDF_COLORS.lightText);
|
|
729
|
+
doc.text('CONFIDENTIAL — FOR INTERNAL COMPLIANCE USE ONLY', 60, doc.page.height - 80, { width: pageWidth, align: 'center' });
|
|
730
|
+
addFooter();
|
|
731
|
+
doc.restore();
|
|
732
|
+
// ═══════════════ EXECUTIVE SUMMARY ═══════════════
|
|
733
|
+
doc.addPage();
|
|
734
|
+
doc.fontSize(20).fillColor(PDF_COLORS.darkText).text('Executive Summary', 60, 60);
|
|
735
|
+
doc.moveTo(60, 88).lineTo(60 + pageWidth, 88).lineWidth(1).strokeColor(PDF_COLORS.border).stroke();
|
|
736
|
+
let y = 100;
|
|
737
|
+
// Score + grade inline
|
|
738
|
+
doc.fontSize(14).fillColor(PDF_COLORS.bodyText);
|
|
739
|
+
doc.text(`Compliance Score: ${scoreResult.score}/100 (Grade ${scoreResult.grade})`, 60, y);
|
|
740
|
+
y += 30;
|
|
741
|
+
// Trend (if available)
|
|
742
|
+
if (history && history.length > 0) {
|
|
743
|
+
const projectHistory = history.filter((h) => h.projectPath === projectPath);
|
|
744
|
+
if (projectHistory.length > 0) {
|
|
745
|
+
const last = projectHistory[projectHistory.length - 1];
|
|
746
|
+
const diff = scoreResult.score - last.score;
|
|
747
|
+
const arrow = diff > 0 ? '↑' : diff < 0 ? '↓' : '→';
|
|
748
|
+
const trendColor = diff > 0 ? PDF_COLORS.green : diff < 0 ? PDF_COLORS.red : PDF_COLORS.mutedText;
|
|
749
|
+
doc.fontSize(11).fillColor(trendColor);
|
|
750
|
+
doc.text(`${arrow} ${last.score}% → ${scoreResult.score}% (${diff > 0 ? '+' : ''}${diff} since last scan)`, 60, y);
|
|
751
|
+
y += 25;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
// Severity breakdown table
|
|
755
|
+
y += 10;
|
|
756
|
+
doc.fontSize(14).fillColor(PDF_COLORS.darkText).text('Severity Breakdown', 60, y);
|
|
757
|
+
y += 25;
|
|
758
|
+
// Table header
|
|
759
|
+
const col1 = 60, col2 = 220, col3 = 320, col4 = 420;
|
|
760
|
+
doc.fontSize(9).fillColor(PDF_COLORS.mutedText);
|
|
761
|
+
doc.text('SEVERITY', col1, y);
|
|
762
|
+
doc.text('COUNT', col2, y);
|
|
763
|
+
doc.text('POINTS DEDUCTED', col3, y);
|
|
764
|
+
doc.text('% OF TOTAL', col4, y);
|
|
765
|
+
y += 18;
|
|
766
|
+
doc.moveTo(60, y).lineTo(60 + pageWidth, y).lineWidth(0.5).strokeColor(PDF_COLORS.border).stroke();
|
|
767
|
+
y += 8;
|
|
768
|
+
const severities = [
|
|
769
|
+
{ label: 'Critical', count: critical, points: critical * 10, color: PDF_COLORS.red },
|
|
770
|
+
{ label: 'High', count: high, points: high * 5, color: PDF_COLORS.orange },
|
|
771
|
+
{ label: 'Medium', count: medium, points: medium * 2, color: PDF_COLORS.yellow },
|
|
772
|
+
{ label: 'Low', count: low, points: low * 1, color: PDF_COLORS.cyan },
|
|
773
|
+
];
|
|
774
|
+
for (const sev of severities) {
|
|
775
|
+
// Colored dot
|
|
776
|
+
doc.circle(col1 + 5, y + 5, 4).fill(sev.color);
|
|
777
|
+
doc.fontSize(10).fillColor(PDF_COLORS.bodyText);
|
|
778
|
+
doc.text(sev.label, col1 + 16, y);
|
|
779
|
+
doc.text(`${sev.count}`, col2, y);
|
|
780
|
+
doc.text(sev.count > 0 ? `-${sev.points}` : '—', col3, y);
|
|
781
|
+
const pct = totalViolations > 0 ? ((sev.count / totalViolations) * 100).toFixed(0) : '0';
|
|
782
|
+
doc.text(`${pct}%`, col4, y);
|
|
783
|
+
y += 20;
|
|
784
|
+
}
|
|
785
|
+
// Total row
|
|
786
|
+
doc.moveTo(60, y).lineTo(60 + pageWidth, y).lineWidth(0.5).strokeColor(PDF_COLORS.border).stroke();
|
|
787
|
+
y += 8;
|
|
788
|
+
doc.fontSize(10).fillColor(PDF_COLORS.darkText);
|
|
789
|
+
doc.text('Total', col1 + 16, y, { bold: true });
|
|
790
|
+
doc.text(`${totalViolations}`, col2, y);
|
|
791
|
+
doc.text(`-${scoreResult.pointsDeducted}`, col3, y);
|
|
792
|
+
y += 30;
|
|
793
|
+
// Key findings
|
|
794
|
+
doc.fontSize(14).fillColor(PDF_COLORS.darkText).text('Key Findings', 60, y);
|
|
795
|
+
y += 25;
|
|
796
|
+
doc.fontSize(10).fillColor(PDF_COLORS.bodyText);
|
|
797
|
+
if (totalViolations === 0) {
|
|
798
|
+
doc.text('No COPPA compliance issues were detected in this codebase. All 20 rules passed.', 60, y, { width: pageWidth });
|
|
799
|
+
}
|
|
800
|
+
else {
|
|
801
|
+
if (critical > 0) {
|
|
802
|
+
doc.fillColor(PDF_COLORS.red);
|
|
803
|
+
doc.text(`• ${critical} critical issue${critical !== 1 ? 's' : ''} require immediate attention — these represent the highest compliance risk.`, 60, y, { width: pageWidth });
|
|
804
|
+
y += 18;
|
|
805
|
+
}
|
|
806
|
+
if (high > 0) {
|
|
807
|
+
doc.fillColor(PDF_COLORS.orange);
|
|
808
|
+
doc.text(`• ${high} high-severity issue${high !== 1 ? 's' : ''} should be resolved before production release.`, 60, y, { width: pageWidth });
|
|
809
|
+
y += 18;
|
|
810
|
+
}
|
|
811
|
+
// Auto-fixable count
|
|
812
|
+
const autoFixable = allViolations.filter(v => ['coppa-sec-006', 'coppa-sec-010', 'coppa-sec-015', 'coppa-default-020'].includes(v.ruleId));
|
|
813
|
+
if (autoFixable.length > 0) {
|
|
814
|
+
doc.fillColor(PDF_COLORS.green);
|
|
815
|
+
doc.text(`• ${autoFixable.length} issue${autoFixable.length !== 1 ? 's' : ''} can be auto-fixed by running: npx runhalo fix .`, 60, y, { width: pageWidth });
|
|
816
|
+
y += 18;
|
|
817
|
+
}
|
|
818
|
+
// Unique rules triggered
|
|
819
|
+
const uniqueRules = [...new Set(allViolations.map(v => v.ruleId))];
|
|
820
|
+
doc.fillColor(PDF_COLORS.bodyText);
|
|
821
|
+
doc.text(`• ${uniqueRules.length} unique COPPA rule${uniqueRules.length !== 1 ? 's' : ''} triggered across ${fileCount} scanned files.`, 60, y, { width: pageWidth });
|
|
822
|
+
}
|
|
823
|
+
addFooter();
|
|
824
|
+
// ═══════════════ DETAILED FINDINGS ═══════════════
|
|
825
|
+
if (totalViolations > 0) {
|
|
826
|
+
doc.addPage();
|
|
827
|
+
doc.fontSize(20).fillColor(PDF_COLORS.darkText).text('Detailed Findings', 60, 60);
|
|
828
|
+
doc.moveTo(60, 88).lineTo(60 + pageWidth, 88).lineWidth(1).strokeColor(PDF_COLORS.border).stroke();
|
|
829
|
+
y = 100;
|
|
830
|
+
// Group violations by severity
|
|
831
|
+
const bySeverity = {
|
|
832
|
+
critical: [], high: [], medium: [], low: []
|
|
833
|
+
};
|
|
834
|
+
for (const result of results) {
|
|
835
|
+
for (const v of result.violations) {
|
|
836
|
+
const relPath = path.relative(projectPath, result.filePath) || result.filePath;
|
|
837
|
+
bySeverity[v.severity]?.push({ file: relPath, violation: v });
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
// PDF Cap: Show max 25 violations to keep PDF under ~10 pages
|
|
841
|
+
// Priority: ALL critical/high first, then fill remaining slots with medium/low
|
|
842
|
+
const PDF_VIOLATION_CAP = 25;
|
|
843
|
+
let violationsShown = 0;
|
|
844
|
+
let violationsOmitted = 0;
|
|
845
|
+
const totalAllItems = Object.values(bySeverity).reduce((sum, items) => sum + items.length, 0);
|
|
846
|
+
for (const severity of ['critical', 'high', 'medium', 'low']) {
|
|
847
|
+
const items = bySeverity[severity];
|
|
848
|
+
if (!items || items.length === 0)
|
|
849
|
+
continue;
|
|
850
|
+
// Determine how many of this severity to show
|
|
851
|
+
const remainingSlots = PDF_VIOLATION_CAP - violationsShown;
|
|
852
|
+
if (remainingSlots <= 0) {
|
|
853
|
+
violationsOmitted += items.length;
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
const itemsToShow = items.slice(0, remainingSlots);
|
|
857
|
+
const itemsSkipped = items.length - itemsToShow.length;
|
|
858
|
+
violationsOmitted += itemsSkipped;
|
|
859
|
+
// Check if we need a new page
|
|
860
|
+
if (y > doc.page.height - 150) {
|
|
861
|
+
addFooter();
|
|
862
|
+
doc.addPage();
|
|
863
|
+
y = 60;
|
|
864
|
+
}
|
|
865
|
+
// Severity header
|
|
866
|
+
doc.fontSize(14).fillColor(severityColor(severity));
|
|
867
|
+
doc.text(`${severity.charAt(0).toUpperCase() + severity.slice(1)} (${items.length}${itemsSkipped > 0 ? `, showing ${itemsToShow.length}` : ''})`, 60, y);
|
|
868
|
+
y += 22;
|
|
869
|
+
for (const item of itemsToShow) {
|
|
870
|
+
// Check page break
|
|
871
|
+
if (y > doc.page.height - 120) {
|
|
872
|
+
addFooter();
|
|
873
|
+
doc.addPage();
|
|
874
|
+
y = 60;
|
|
875
|
+
}
|
|
876
|
+
// Violation entry
|
|
877
|
+
doc.fontSize(9).fillColor(severityColor(severity));
|
|
878
|
+
doc.text(severity.toUpperCase(), 60, y);
|
|
879
|
+
doc.fillColor(PDF_COLORS.primary);
|
|
880
|
+
doc.text(item.violation.ruleId, 120, y);
|
|
881
|
+
doc.fillColor(PDF_COLORS.mutedText);
|
|
882
|
+
doc.text(`${item.file}:${item.violation.line}`, 260, y);
|
|
883
|
+
y += 14;
|
|
884
|
+
// Message
|
|
885
|
+
doc.fontSize(9).fillColor(PDF_COLORS.bodyText);
|
|
886
|
+
const msgHeight = doc.heightOfString(item.violation.message, { width: pageWidth - 20 });
|
|
887
|
+
doc.text(item.violation.message, 70, y, { width: pageWidth - 20 });
|
|
888
|
+
y += msgHeight + 4;
|
|
889
|
+
// Code snippet (if available)
|
|
890
|
+
if (item.violation.codeSnippet) {
|
|
891
|
+
const snippet = item.violation.codeSnippet.length > 120
|
|
892
|
+
? item.violation.codeSnippet.substring(0, 117) + '...'
|
|
893
|
+
: item.violation.codeSnippet;
|
|
894
|
+
doc.rect(70, y, pageWidth - 20, 16).fill(PDF_COLORS.lightBg);
|
|
895
|
+
doc.fontSize(7).fillColor(PDF_COLORS.mutedText);
|
|
896
|
+
doc.text(snippet, 74, y + 4, { width: pageWidth - 28 });
|
|
897
|
+
y += 22;
|
|
898
|
+
}
|
|
899
|
+
// Fix suggestion
|
|
900
|
+
if (item.violation.fixSuggestion) {
|
|
901
|
+
doc.fontSize(8).fillColor(PDF_COLORS.green);
|
|
902
|
+
const fixText = `Fix: ${item.violation.fixSuggestion}`;
|
|
903
|
+
const fixHeight = doc.heightOfString(fixText, { width: pageWidth - 20 });
|
|
904
|
+
doc.text(fixText, 70, y, { width: pageWidth - 20 });
|
|
905
|
+
y += fixHeight + 4;
|
|
906
|
+
}
|
|
907
|
+
// Penalty
|
|
908
|
+
if (item.violation.penalty) {
|
|
909
|
+
doc.fontSize(7).fillColor(PDF_COLORS.red);
|
|
910
|
+
doc.text(`Penalty: ${item.violation.penalty}`, 70, y);
|
|
911
|
+
y += 12;
|
|
912
|
+
}
|
|
913
|
+
y += 8; // spacing between violations
|
|
914
|
+
violationsShown++;
|
|
915
|
+
}
|
|
916
|
+
y += 10; // spacing between severity groups
|
|
917
|
+
}
|
|
918
|
+
// Dashboard CTA for omitted violations
|
|
919
|
+
if (violationsOmitted > 0) {
|
|
920
|
+
if (y > doc.page.height - 120) {
|
|
921
|
+
addFooter();
|
|
922
|
+
doc.addPage();
|
|
923
|
+
y = 60;
|
|
924
|
+
}
|
|
925
|
+
y += 10;
|
|
926
|
+
doc.rect(60, y, pageWidth, 60).fill('#f0f0ff');
|
|
927
|
+
doc.fontSize(11).fillColor(PDF_COLORS.primary);
|
|
928
|
+
doc.text(`${violationsOmitted} additional violation(s) not shown in this report.`, 70, y + 12, { width: pageWidth - 20 });
|
|
929
|
+
doc.fontSize(10).fillColor(PDF_COLORS.bodyText);
|
|
930
|
+
doc.text('View all violations on your Halo Dashboard: https://runhalo.dev/app/dashboard.html', 70, y + 30, { width: pageWidth - 20 });
|
|
931
|
+
y += 70;
|
|
932
|
+
}
|
|
933
|
+
addFooter();
|
|
934
|
+
}
|
|
935
|
+
// ═══════════════ RECOMMENDATIONS ═══════════════
|
|
936
|
+
doc.addPage();
|
|
937
|
+
doc.fontSize(20).fillColor(PDF_COLORS.darkText).text('Recommendations', 60, 60);
|
|
938
|
+
doc.moveTo(60, 88).lineTo(60 + pageWidth, 88).lineWidth(1).strokeColor(PDF_COLORS.border).stroke();
|
|
939
|
+
y = 100;
|
|
940
|
+
let recNum = 1;
|
|
941
|
+
doc.fontSize(10).fillColor(PDF_COLORS.bodyText);
|
|
942
|
+
if (totalViolations === 0) {
|
|
943
|
+
doc.text('No issues detected. This codebase passes all current COPPA 2.0 compliance checks.', 60, y, { width: pageWidth });
|
|
944
|
+
y += 20;
|
|
945
|
+
doc.text('Recommended next steps:', 60, y, { width: pageWidth });
|
|
946
|
+
y += 16;
|
|
947
|
+
doc.text(`${recNum++}. Schedule regular scans as part of your CI/CD pipeline.`, 70, y, { width: pageWidth - 20 });
|
|
948
|
+
y += 16;
|
|
949
|
+
doc.text(`${recNum++}. Enable ethical design rules with --ethical-preview for proactive compliance.`, 70, y, { width: pageWidth - 20 });
|
|
950
|
+
y += 16;
|
|
951
|
+
doc.text(`${recNum++}. Run "npx runhalo init --ide" to teach your AI coding assistants COPPA rules.`, 70, y, { width: pageWidth - 20 });
|
|
952
|
+
}
|
|
953
|
+
else {
|
|
954
|
+
if (critical > 0) {
|
|
955
|
+
doc.fillColor(PDF_COLORS.red);
|
|
956
|
+
doc.text(`${recNum++}. Fix ${critical} critical issue${critical !== 1 ? 's' : ''} immediately — these represent the highest compliance risk and largest potential penalties.`, 70, y, { width: pageWidth - 20 });
|
|
957
|
+
y += 22;
|
|
958
|
+
}
|
|
959
|
+
if (high > 0) {
|
|
960
|
+
doc.fillColor(PDF_COLORS.orange);
|
|
961
|
+
doc.text(`${recNum++}. Address ${high} high-severity issue${high !== 1 ? 's' : ''} before production release — these are significant compliance gaps.`, 70, y, { width: pageWidth - 20 });
|
|
962
|
+
y += 22;
|
|
963
|
+
}
|
|
964
|
+
const autoFixable = allViolations.filter(v => ['coppa-sec-006', 'coppa-sec-010', 'coppa-sec-015', 'coppa-default-020'].includes(v.ruleId));
|
|
965
|
+
if (autoFixable.length > 0) {
|
|
966
|
+
doc.fillColor(PDF_COLORS.green);
|
|
967
|
+
doc.text(`${recNum++}. Run "npx runhalo fix ." to automatically resolve ${autoFixable.length} issue${autoFixable.length !== 1 ? 's' : ''}.`, 70, y, { width: pageWidth - 20 });
|
|
968
|
+
y += 22;
|
|
969
|
+
}
|
|
970
|
+
if (medium > 0) {
|
|
971
|
+
doc.fillColor(PDF_COLORS.yellow);
|
|
972
|
+
doc.text(`${recNum++}. Review ${medium} medium-severity issue${medium !== 1 ? 's' : ''} — these may require design changes or policy updates.`, 70, y, { width: pageWidth - 20 });
|
|
973
|
+
y += 22;
|
|
974
|
+
}
|
|
975
|
+
doc.fillColor(PDF_COLORS.bodyText);
|
|
976
|
+
doc.text(`${recNum++}. Integrate Halo into your CI pipeline: uses: runhalo/action@v1 in GitHub Actions.`, 70, y, { width: pageWidth - 20 });
|
|
977
|
+
y += 22;
|
|
978
|
+
doc.text(`${recNum++}. Run "npx runhalo init --ide" to teach AI coding assistants COPPA compliance patterns.`, 70, y, { width: pageWidth - 20 });
|
|
979
|
+
y += 22;
|
|
980
|
+
doc.text(`${recNum++}. Schedule re-scan after remediations to track compliance improvement.`, 70, y, { width: pageWidth - 20 });
|
|
981
|
+
}
|
|
982
|
+
// COPPA 2.0 context
|
|
983
|
+
y += 30;
|
|
984
|
+
doc.fontSize(14).fillColor(PDF_COLORS.darkText).text('Regulatory Context', 60, y);
|
|
985
|
+
y += 22;
|
|
986
|
+
doc.fontSize(9).fillColor(PDF_COLORS.bodyText);
|
|
987
|
+
doc.text('The COPPA 2.0 Final Rule (published April 22, 2025) updates the Children\'s Online Privacy Protection Act with new requirements for data retention, biometric data, push notifications, and advertising. The 12-month compliance grace period ends April 22, 2026, after which enforcement begins with penalties up to $54,540 per violation per child per day.', 60, y, { width: pageWidth });
|
|
988
|
+
// Disclaimer
|
|
989
|
+
y = doc.page.height - 130;
|
|
990
|
+
doc.moveTo(60, y).lineTo(60 + pageWidth, y).lineWidth(0.5).strokeColor(PDF_COLORS.border).stroke();
|
|
991
|
+
y += 10;
|
|
992
|
+
doc.fontSize(7).fillColor(PDF_COLORS.lightText);
|
|
993
|
+
doc.text('DISCLAIMER: Halo is a developer tool designed to assist with code analysis and identifying potential privacy issues. It is not legal advice and does not guarantee compliance with COPPA, GDPR, or any other regulation. Always consult with qualified legal counsel regarding your specific compliance obligations. This report is generated automatically and should be reviewed by a qualified compliance professional.', 60, y, { width: pageWidth });
|
|
994
|
+
addFooter();
|
|
995
|
+
// Finalize
|
|
996
|
+
doc.end();
|
|
997
|
+
});
|
|
998
|
+
}
|
|
295
999
|
/**
|
|
296
1000
|
* Load .haloignore from a directory (walks up to find it)
|
|
297
1001
|
*/
|
|
@@ -377,6 +1081,449 @@ async function scanDirectory(dirPath, config) {
|
|
|
377
1081
|
}
|
|
378
1082
|
return results;
|
|
379
1083
|
}
|
|
1084
|
+
// ==================== First-Run Email Prompt ====================
|
|
1085
|
+
const HALO_CONFIG_DIR = path.join(os.homedir(), '.halo');
|
|
1086
|
+
exports.HALO_CONFIG_DIR = HALO_CONFIG_DIR;
|
|
1087
|
+
const HALO_CONFIG_PATH = path.join(HALO_CONFIG_DIR, 'config.json');
|
|
1088
|
+
exports.HALO_CONFIG_PATH = HALO_CONFIG_PATH;
|
|
1089
|
+
const HALO_HISTORY_PATH = path.join(HALO_CONFIG_DIR, 'history.json');
|
|
1090
|
+
exports.HALO_HISTORY_PATH = HALO_HISTORY_PATH;
|
|
1091
|
+
const MAX_HISTORY_ENTRIES = 100;
|
|
1092
|
+
exports.MAX_HISTORY_ENTRIES = MAX_HISTORY_ENTRIES;
|
|
1093
|
+
const CLI_VERSION = '0.2.1';
|
|
1094
|
+
const SUPABASE_URL = 'https://wrfwcmyxxbafcdvxlmug.supabase.co';
|
|
1095
|
+
const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6IndyZndjbXl4eGJhZmNkdnhsbXVnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzIzNDc5MzIsImV4cCI6MjA4NzkyMzkzMn0.6Wj58QDuojPAY_ArVbZvjhcFVuX5VvzqjaEg0FkoYJI';
|
|
1096
|
+
// Rules Engine API
|
|
1097
|
+
const RULES_API_BASE = `${SUPABASE_URL}/functions/v1`;
|
|
1098
|
+
const RULES_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
1099
|
+
const RULES_CACHE_PATH = path.join(os.homedir(), '.halo', 'rules-cache.json');
|
|
1100
|
+
exports.RULES_CACHE_PATH = RULES_CACHE_PATH;
|
|
1101
|
+
const RULES_FETCH_TIMEOUT_MS = 5000; // 5 second timeout
|
|
1102
|
+
/**
|
|
1103
|
+
* Fetch rules from the Supabase rules-fetch edge function.
|
|
1104
|
+
* Returns raw JSON rules (not compiled) or null on failure.
|
|
1105
|
+
*/
|
|
1106
|
+
async function fetchRulesFromAPI(packs, verbose) {
|
|
1107
|
+
try {
|
|
1108
|
+
const url = `${RULES_API_BASE}/rules-fetch?packs=${packs.join(',')}`;
|
|
1109
|
+
const cachedEtag = readRulesCache()?.etag;
|
|
1110
|
+
const headers = {};
|
|
1111
|
+
if (cachedEtag) {
|
|
1112
|
+
headers['If-None-Match'] = cachedEtag;
|
|
1113
|
+
}
|
|
1114
|
+
const controller = new AbortController();
|
|
1115
|
+
const timeout = setTimeout(() => controller.abort(), RULES_FETCH_TIMEOUT_MS);
|
|
1116
|
+
const res = await fetch(url, { headers, signal: controller.signal });
|
|
1117
|
+
clearTimeout(timeout);
|
|
1118
|
+
if (res.status === 304) {
|
|
1119
|
+
if (verbose)
|
|
1120
|
+
console.error('📡 Rules API: 304 Not Modified (cache hit)');
|
|
1121
|
+
return null; // Caller should use cache
|
|
1122
|
+
}
|
|
1123
|
+
if (!res.ok) {
|
|
1124
|
+
if (verbose)
|
|
1125
|
+
console.error(`📡 Rules API: ${res.status} ${res.statusText}`);
|
|
1126
|
+
return null;
|
|
1127
|
+
}
|
|
1128
|
+
const data = await res.json();
|
|
1129
|
+
const etag = res.headers.get('etag');
|
|
1130
|
+
if (verbose)
|
|
1131
|
+
console.error(`📡 Rules API: fetched ${data.rules?.length || 0} rules`);
|
|
1132
|
+
return { rules: data.rules || [], etag };
|
|
1133
|
+
}
|
|
1134
|
+
catch (err) {
|
|
1135
|
+
if (verbose)
|
|
1136
|
+
console.error(`📡 Rules API: fetch failed (${err.name === 'AbortError' ? 'timeout' : err.message})`);
|
|
1137
|
+
return null;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Read the local rules cache.
|
|
1142
|
+
*/
|
|
1143
|
+
function readRulesCache() {
|
|
1144
|
+
try {
|
|
1145
|
+
if (fs.existsSync(RULES_CACHE_PATH)) {
|
|
1146
|
+
const cache = JSON.parse(fs.readFileSync(RULES_CACHE_PATH, 'utf-8'));
|
|
1147
|
+
return cache;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
catch {
|
|
1151
|
+
// Corrupt cache — ignore
|
|
1152
|
+
}
|
|
1153
|
+
return null;
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Write rules to the local cache.
|
|
1157
|
+
*/
|
|
1158
|
+
function writeRulesCache(etag, packs, rules) {
|
|
1159
|
+
try {
|
|
1160
|
+
const cacheDir = path.dirname(RULES_CACHE_PATH);
|
|
1161
|
+
if (!fs.existsSync(cacheDir)) {
|
|
1162
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
1163
|
+
}
|
|
1164
|
+
const cache = {
|
|
1165
|
+
etag,
|
|
1166
|
+
packs,
|
|
1167
|
+
rules,
|
|
1168
|
+
fetchedAt: new Date().toISOString(),
|
|
1169
|
+
};
|
|
1170
|
+
fs.writeFileSync(RULES_CACHE_PATH, JSON.stringify(cache, null, 2), 'utf-8');
|
|
1171
|
+
}
|
|
1172
|
+
catch {
|
|
1173
|
+
// Silent failure — never block scan
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Load bundled baseline rules from @runhalo/engine's rules.json.
|
|
1178
|
+
*/
|
|
1179
|
+
function loadBaselineRules(packs) {
|
|
1180
|
+
try {
|
|
1181
|
+
const rulesJsonPath = require.resolve('@runhalo/engine/rules/rules.json');
|
|
1182
|
+
const data = JSON.parse(fs.readFileSync(rulesJsonPath, 'utf-8'));
|
|
1183
|
+
return (data.rules || []).filter((r) => r.packs.some(p => packs.includes(p)));
|
|
1184
|
+
}
|
|
1185
|
+
catch {
|
|
1186
|
+
return null;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Map CLI options to pack IDs.
|
|
1191
|
+
* --pack takes precedence. Legacy flags (--ethical-preview, --ai-audit, --sector-au-sbd) are mapped.
|
|
1192
|
+
*/
|
|
1193
|
+
function resolvePacks(options) {
|
|
1194
|
+
// Explicit --pack flag takes priority
|
|
1195
|
+
if (options.pack && options.pack.length > 0) {
|
|
1196
|
+
return options.pack;
|
|
1197
|
+
}
|
|
1198
|
+
// Legacy boolean flags
|
|
1199
|
+
const packs = ['coppa'];
|
|
1200
|
+
if (options.ethicalPreview)
|
|
1201
|
+
packs.push('ethical');
|
|
1202
|
+
if (options.aiAudit)
|
|
1203
|
+
packs.push('ai-audit');
|
|
1204
|
+
if (options.sectorAuSbd)
|
|
1205
|
+
packs.push('au-sbd');
|
|
1206
|
+
return packs;
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Resolve rules with fallback chain:
|
|
1210
|
+
* API (fresh) → 304 cache hit → local cache (stale OK) → bundled baseline → null
|
|
1211
|
+
*/
|
|
1212
|
+
async function resolveRules(packs, offline, verbose) {
|
|
1213
|
+
// 1. Try API (unless offline)
|
|
1214
|
+
if (!offline) {
|
|
1215
|
+
const apiResult = await fetchRulesFromAPI(packs, verbose);
|
|
1216
|
+
if (apiResult && apiResult.rules.length > 0) {
|
|
1217
|
+
// Fresh rules from API — cache and use
|
|
1218
|
+
writeRulesCache(apiResult.etag, packs, apiResult.rules);
|
|
1219
|
+
return apiResult.rules;
|
|
1220
|
+
}
|
|
1221
|
+
// apiResult === null could mean 304 (use cache) or failure (also try cache)
|
|
1222
|
+
}
|
|
1223
|
+
// 2. Try local cache
|
|
1224
|
+
const cache = readRulesCache();
|
|
1225
|
+
if (cache && cache.rules.length > 0) {
|
|
1226
|
+
const cacheAge = Date.now() - new Date(cache.fetchedAt).getTime();
|
|
1227
|
+
const isFresh = cacheAge < RULES_CACHE_TTL_MS;
|
|
1228
|
+
const packsMatch = packs.every(p => cache.packs.includes(p));
|
|
1229
|
+
if (packsMatch) {
|
|
1230
|
+
if (verbose) {
|
|
1231
|
+
console.error(`📦 Using cached rules (${isFresh ? 'fresh' : 'stale'}, ${cache.rules.length} rules)`);
|
|
1232
|
+
}
|
|
1233
|
+
return cache.rules;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
// 3. Try bundled baseline
|
|
1237
|
+
const baseline = loadBaselineRules(packs);
|
|
1238
|
+
if (baseline && baseline.length > 0) {
|
|
1239
|
+
if (verbose) {
|
|
1240
|
+
console.error(`📦 Using bundled baseline rules (${baseline.length} rules)`);
|
|
1241
|
+
}
|
|
1242
|
+
return baseline;
|
|
1243
|
+
}
|
|
1244
|
+
// 4. Return null — engine will use hardcoded fallback
|
|
1245
|
+
if (verbose) {
|
|
1246
|
+
console.error('📦 No cached/baseline rules found, engine will use hardcoded fallback');
|
|
1247
|
+
}
|
|
1248
|
+
return null;
|
|
1249
|
+
}
|
|
1250
|
+
function loadConfig() {
|
|
1251
|
+
try {
|
|
1252
|
+
if (fs.existsSync(HALO_CONFIG_PATH)) {
|
|
1253
|
+
return JSON.parse(fs.readFileSync(HALO_CONFIG_PATH, 'utf-8'));
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
catch {
|
|
1257
|
+
// Ignore corrupt config
|
|
1258
|
+
}
|
|
1259
|
+
return null;
|
|
1260
|
+
}
|
|
1261
|
+
function saveConfig(config) {
|
|
1262
|
+
try {
|
|
1263
|
+
if (!fs.existsSync(HALO_CONFIG_DIR)) {
|
|
1264
|
+
fs.mkdirSync(HALO_CONFIG_DIR, { recursive: true });
|
|
1265
|
+
}
|
|
1266
|
+
fs.writeFileSync(HALO_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
|
|
1267
|
+
}
|
|
1268
|
+
catch {
|
|
1269
|
+
// Silent failure — never block scan
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
// ==================== Scan History ====================
|
|
1273
|
+
function loadHistory() {
|
|
1274
|
+
try {
|
|
1275
|
+
if (fs.existsSync(HALO_HISTORY_PATH)) {
|
|
1276
|
+
const data = JSON.parse(fs.readFileSync(HALO_HISTORY_PATH, 'utf-8'));
|
|
1277
|
+
return Array.isArray(data) ? data : [];
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
catch {
|
|
1281
|
+
// Silent failure — never block scan
|
|
1282
|
+
}
|
|
1283
|
+
return [];
|
|
1284
|
+
}
|
|
1285
|
+
function saveHistory(entry) {
|
|
1286
|
+
try {
|
|
1287
|
+
if (!fs.existsSync(HALO_CONFIG_DIR)) {
|
|
1288
|
+
fs.mkdirSync(HALO_CONFIG_DIR, { recursive: true });
|
|
1289
|
+
}
|
|
1290
|
+
const history = loadHistory();
|
|
1291
|
+
history.push(entry);
|
|
1292
|
+
// FIFO: keep last MAX_HISTORY_ENTRIES
|
|
1293
|
+
const trimmed = history.slice(-MAX_HISTORY_ENTRIES);
|
|
1294
|
+
fs.writeFileSync(HALO_HISTORY_PATH, JSON.stringify(trimmed, null, 2), 'utf-8');
|
|
1295
|
+
}
|
|
1296
|
+
catch {
|
|
1297
|
+
// Silent failure — never block scan
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
async function submitCliLead(email) {
|
|
1301
|
+
try {
|
|
1302
|
+
const res = await fetch(`${SUPABASE_URL}/rest/v1/halo_leads`, {
|
|
1303
|
+
method: 'POST',
|
|
1304
|
+
headers: {
|
|
1305
|
+
'apikey': SUPABASE_ANON_KEY,
|
|
1306
|
+
'Authorization': `Bearer ${SUPABASE_ANON_KEY}`,
|
|
1307
|
+
'Content-Type': 'application/json',
|
|
1308
|
+
'Prefer': 'return=minimal',
|
|
1309
|
+
},
|
|
1310
|
+
body: JSON.stringify({
|
|
1311
|
+
email,
|
|
1312
|
+
source: 'cli',
|
|
1313
|
+
cli_version: CLI_VERSION,
|
|
1314
|
+
node_version: process.version,
|
|
1315
|
+
os: os.platform(),
|
|
1316
|
+
consent_given: true,
|
|
1317
|
+
consent_text: 'Opted in via Halo CLI first-run prompt',
|
|
1318
|
+
}),
|
|
1319
|
+
});
|
|
1320
|
+
// Silent — don't care about response
|
|
1321
|
+
}
|
|
1322
|
+
catch {
|
|
1323
|
+
// Silent failure — never block scan
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
// ==================== License Validation & Scan Limits (P3-1) ====================
|
|
1327
|
+
const FREE_SCAN_LIMIT = 5;
|
|
1328
|
+
exports.FREE_SCAN_LIMIT = FREE_SCAN_LIMIT;
|
|
1329
|
+
/**
|
|
1330
|
+
* Validate a license key against Supabase validate-license edge function.
|
|
1331
|
+
* Returns license info or null on failure.
|
|
1332
|
+
*/
|
|
1333
|
+
async function validateLicenseKey(licenseKey) {
|
|
1334
|
+
try {
|
|
1335
|
+
const res = await fetch(`${SUPABASE_URL}/functions/v1/validate-license`, {
|
|
1336
|
+
method: 'POST',
|
|
1337
|
+
headers: {
|
|
1338
|
+
'Content-Type': 'application/json',
|
|
1339
|
+
'Authorization': `Bearer ${SUPABASE_ANON_KEY}`,
|
|
1340
|
+
},
|
|
1341
|
+
body: JSON.stringify({ license_key: licenseKey }),
|
|
1342
|
+
});
|
|
1343
|
+
const data = await res.json();
|
|
1344
|
+
return {
|
|
1345
|
+
valid: !!data.valid,
|
|
1346
|
+
tier: data.tier,
|
|
1347
|
+
email: data.email,
|
|
1348
|
+
status: data.status,
|
|
1349
|
+
expires_at: data.expires_at,
|
|
1350
|
+
error: data.error,
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
catch {
|
|
1354
|
+
return null;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Activate a license key — validates via Supabase, stores in ~/.halo/config.json.
|
|
1359
|
+
*/
|
|
1360
|
+
async function activateLicense(licenseKey) {
|
|
1361
|
+
console.log(`\n ${c(colors.dim, 'Validating license key...')}`);
|
|
1362
|
+
const result = await validateLicenseKey(licenseKey);
|
|
1363
|
+
if (!result || !result.valid) {
|
|
1364
|
+
console.error(`\n ${c(colors.red + colors.bold, '✗ Invalid license key')}`);
|
|
1365
|
+
if (result?.error) {
|
|
1366
|
+
console.error(` ${c(colors.dim, result.error)}`);
|
|
1367
|
+
}
|
|
1368
|
+
console.error(`\n ${c(colors.dim, 'Get a license at')} ${c(colors.cyan, 'https://runhalo.dev/#pricing')}\n`);
|
|
1369
|
+
return 1;
|
|
1370
|
+
}
|
|
1371
|
+
// Save to config
|
|
1372
|
+
const existing = loadConfig() || {
|
|
1373
|
+
prompted: true,
|
|
1374
|
+
promptedAt: new Date().toISOString(),
|
|
1375
|
+
consent: false,
|
|
1376
|
+
};
|
|
1377
|
+
saveConfig({
|
|
1378
|
+
...existing,
|
|
1379
|
+
license_key: licenseKey,
|
|
1380
|
+
tier: result.tier,
|
|
1381
|
+
email: result.email || existing.email,
|
|
1382
|
+
});
|
|
1383
|
+
const tierLabel = result.tier === 'enterprise' ? 'Enterprise' : 'Pro';
|
|
1384
|
+
console.log(`\n ${c('\x1b[32m' + colors.bold, `✓ Halo ${tierLabel} activated!`)}`);
|
|
1385
|
+
console.log(` ${c(colors.dim, 'Email:')} ${result.email}`);
|
|
1386
|
+
console.log(` ${c(colors.dim, 'Tier:')} ${c(colors.cyan, tierLabel)}`);
|
|
1387
|
+
if (result.expires_at) {
|
|
1388
|
+
const expiryDate = new Date(result.expires_at).toLocaleDateString('en-US', {
|
|
1389
|
+
year: 'numeric', month: 'long', day: 'numeric'
|
|
1390
|
+
});
|
|
1391
|
+
console.log(` ${c(colors.dim, 'Valid until:')} ${expiryDate}`);
|
|
1392
|
+
}
|
|
1393
|
+
console.log(`\n ${c(colors.dim, 'Unlimited scans. All Pro features unlocked.')}`);
|
|
1394
|
+
console.log(` ${c(colors.dim, 'Run')} ${c(colors.cyan, 'halo scan . --report --ethical')} ${c(colors.dim, 'to get started.')}\n`);
|
|
1395
|
+
return 0;
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Check scan limit for free-tier users.
|
|
1399
|
+
* Returns true if scan is allowed, false if blocked.
|
|
1400
|
+
* CI environments always bypass limits.
|
|
1401
|
+
*/
|
|
1402
|
+
function checkScanLimit() {
|
|
1403
|
+
// CI always unlimited — never break builds
|
|
1404
|
+
if (process.env.CI || process.stdout.isTTY === false) {
|
|
1405
|
+
return true;
|
|
1406
|
+
}
|
|
1407
|
+
const config = loadConfig();
|
|
1408
|
+
// Pro/Enterprise: unlimited
|
|
1409
|
+
if (config?.tier === 'pro' || config?.tier === 'enterprise') {
|
|
1410
|
+
return true;
|
|
1411
|
+
}
|
|
1412
|
+
// Free tier: check daily limit
|
|
1413
|
+
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
|
1414
|
+
if (config?.scan_date !== today) {
|
|
1415
|
+
// New day — reset counter
|
|
1416
|
+
saveConfig({
|
|
1417
|
+
...config,
|
|
1418
|
+
prompted: config?.prompted ?? true,
|
|
1419
|
+
promptedAt: config?.promptedAt ?? new Date().toISOString(),
|
|
1420
|
+
consent: config?.consent ?? false,
|
|
1421
|
+
scans_today: 1,
|
|
1422
|
+
scan_date: today,
|
|
1423
|
+
});
|
|
1424
|
+
return true;
|
|
1425
|
+
}
|
|
1426
|
+
const scansToday = config?.scans_today ?? 0;
|
|
1427
|
+
if (scansToday >= FREE_SCAN_LIMIT) {
|
|
1428
|
+
// Limit reached — show upgrade message
|
|
1429
|
+
console.error('');
|
|
1430
|
+
console.error(` ${c(colors.yellow + colors.bold, `⚡ Daily scan limit reached (${FREE_SCAN_LIMIT}/${FREE_SCAN_LIMIT})`)}`);
|
|
1431
|
+
console.error(` ${c(colors.dim, 'Free tier allows 5 scans per day. Your scans reset at midnight.')}`);
|
|
1432
|
+
console.error('');
|
|
1433
|
+
console.error(` ${c(colors.cyan, 'Upgrade to Halo Pro ($29/mo)')}`);
|
|
1434
|
+
console.error(` ${c(colors.dim, '→ Unlimited scans, ethical design rules, HTML reports, guided fixes')}`);
|
|
1435
|
+
console.error(` ${c(colors.dim, '→')} ${c(colors.cyan, 'https://runhalo.dev/#pricing')}`);
|
|
1436
|
+
console.error('');
|
|
1437
|
+
return false;
|
|
1438
|
+
}
|
|
1439
|
+
// Increment counter
|
|
1440
|
+
saveConfig({
|
|
1441
|
+
...config,
|
|
1442
|
+
prompted: config?.prompted ?? true,
|
|
1443
|
+
promptedAt: config?.promptedAt ?? new Date().toISOString(),
|
|
1444
|
+
consent: config?.consent ?? false,
|
|
1445
|
+
scans_today: scansToday + 1,
|
|
1446
|
+
scan_date: today,
|
|
1447
|
+
});
|
|
1448
|
+
return true;
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* Check if a Pro feature is available for the current user.
|
|
1452
|
+
* Returns true if allowed, false with upsell message if blocked.
|
|
1453
|
+
*/
|
|
1454
|
+
function checkProFeature(featureName, flagName) {
|
|
1455
|
+
// CI always has access — don't break pipelines
|
|
1456
|
+
if (process.env.CI || process.stdout.isTTY === false) {
|
|
1457
|
+
return true;
|
|
1458
|
+
}
|
|
1459
|
+
const config = loadConfig();
|
|
1460
|
+
if (config?.tier === 'pro' || config?.tier === 'enterprise') {
|
|
1461
|
+
return true;
|
|
1462
|
+
}
|
|
1463
|
+
console.error('');
|
|
1464
|
+
console.error(` ${c(colors.yellow + colors.bold, `⚡ ${featureName} requires Halo Pro`)}`);
|
|
1465
|
+
console.error(` ${c(colors.dim, `The ${flagName} flag is a Pro feature.`)}`);
|
|
1466
|
+
console.error('');
|
|
1467
|
+
console.error(` ${c(colors.cyan, 'Upgrade to Halo Pro ($29/mo)')}`);
|
|
1468
|
+
console.error(` ${c(colors.dim, '→ Unlimited scans, ethical design rules, HTML reports, guided fixes')}`);
|
|
1469
|
+
console.error(` ${c(colors.dim, '→')} ${c(colors.cyan, 'https://runhalo.dev/#pricing')}`);
|
|
1470
|
+
console.error('');
|
|
1471
|
+
return false;
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* First-run email prompt — one-time, optional, non-blocking.
|
|
1475
|
+
* Auto-skips when: config exists, --no-prompt, !isTTY, CI env.
|
|
1476
|
+
*/
|
|
1477
|
+
async function firstRunPrompt(noPrompt) {
|
|
1478
|
+
// Skip conditions
|
|
1479
|
+
if (noPrompt)
|
|
1480
|
+
return;
|
|
1481
|
+
if (!process.stdin.isTTY)
|
|
1482
|
+
return;
|
|
1483
|
+
if (process.env.CI)
|
|
1484
|
+
return;
|
|
1485
|
+
const existing = loadConfig();
|
|
1486
|
+
if (existing?.prompted)
|
|
1487
|
+
return;
|
|
1488
|
+
// Show prompt on stderr (never pollute stdout JSON/SARIF)
|
|
1489
|
+
process.stderr.write('\n');
|
|
1490
|
+
process.stderr.write(' Welcome to Halo! 👋\n');
|
|
1491
|
+
process.stderr.write(' Stay updated on COPPA scanning, new rules, and Pro features.\n');
|
|
1492
|
+
process.stderr.write(' We\'ll send occasional product updates. No spam. Unsubscribe anytime.\n');
|
|
1493
|
+
process.stderr.write('\n');
|
|
1494
|
+
const rl = readline.createInterface({
|
|
1495
|
+
input: process.stdin,
|
|
1496
|
+
output: process.stderr,
|
|
1497
|
+
});
|
|
1498
|
+
return new Promise((resolve) => {
|
|
1499
|
+
rl.question(' Email (Enter to skip): ', (answer) => {
|
|
1500
|
+
rl.close();
|
|
1501
|
+
const email = answer.trim();
|
|
1502
|
+
if (email && email.includes('@')) {
|
|
1503
|
+
// Save config with email
|
|
1504
|
+
saveConfig({
|
|
1505
|
+
email,
|
|
1506
|
+
prompted: true,
|
|
1507
|
+
promptedAt: new Date().toISOString(),
|
|
1508
|
+
consent: true,
|
|
1509
|
+
});
|
|
1510
|
+
// Submit to Supabase (fire-and-forget)
|
|
1511
|
+
submitCliLead(email).catch(() => { });
|
|
1512
|
+
process.stderr.write(` ${c('\x1b[32m', '✓')} Thanks! We'll keep you posted.\n\n`);
|
|
1513
|
+
}
|
|
1514
|
+
else {
|
|
1515
|
+
// Skipped — save that we prompted (never ask again)
|
|
1516
|
+
saveConfig({
|
|
1517
|
+
prompted: true,
|
|
1518
|
+
promptedAt: new Date().toISOString(),
|
|
1519
|
+
consent: false,
|
|
1520
|
+
});
|
|
1521
|
+
process.stderr.write('\n');
|
|
1522
|
+
}
|
|
1523
|
+
resolve();
|
|
1524
|
+
});
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
380
1527
|
/**
|
|
381
1528
|
* Main scan function
|
|
382
1529
|
*/
|
|
@@ -451,15 +1598,27 @@ async function scan(paths, options) {
|
|
|
451
1598
|
if (options.verbose && projectDomains.length > 0) {
|
|
452
1599
|
console.error(`🏠 Detected project domains: ${[...new Set(projectDomains)].join(', ')}`);
|
|
453
1600
|
}
|
|
454
|
-
|
|
1601
|
+
// Resolve rules via API/cache/baseline fallback chain
|
|
1602
|
+
const packs = resolvePacks(options);
|
|
1603
|
+
const resolvedRawRules = await resolveRules(packs, options.offline, options.verbose);
|
|
1604
|
+
const resolvedRules = resolvedRawRules ? (0, engine_1.compileRawRules)(resolvedRawRules) : undefined;
|
|
1605
|
+
const engineConfig = {
|
|
455
1606
|
includePatterns: options.include,
|
|
456
1607
|
excludePatterns: options.exclude,
|
|
457
1608
|
rules: options.rules.length > 0 ? options.rules : undefined,
|
|
458
1609
|
severityFilter: options.severity.length > 0 ? options.severity : undefined,
|
|
459
1610
|
ignoreConfig,
|
|
460
1611
|
projectDomains: projectDomains.length > 0 ? [...new Set(projectDomains)] : undefined,
|
|
461
|
-
|
|
462
|
-
|
|
1612
|
+
// If we got rules from API/cache, use loadedRules. Otherwise fall through to legacy flags.
|
|
1613
|
+
...(resolvedRules
|
|
1614
|
+
? { loadedRules: resolvedRules }
|
|
1615
|
+
: {
|
|
1616
|
+
ethical: options.ethicalPreview,
|
|
1617
|
+
aiAudit: options.aiAudit,
|
|
1618
|
+
sectorAuSbd: options.sectorAuSbd,
|
|
1619
|
+
}),
|
|
1620
|
+
};
|
|
1621
|
+
const engine = new engine_1.HaloEngine(engineConfig);
|
|
463
1622
|
const results = [];
|
|
464
1623
|
let fileCount = 0;
|
|
465
1624
|
// Collect all files to scan
|
|
@@ -523,6 +1682,17 @@ async function scan(paths, options) {
|
|
|
523
1682
|
}
|
|
524
1683
|
console.error(`🔍 Scanning ${uniqueFiles.length} files...`);
|
|
525
1684
|
}
|
|
1685
|
+
// Scan start banner (text format only, stderr so it doesn't pollute JSON/SARIF)
|
|
1686
|
+
if (options.format === 'text') {
|
|
1687
|
+
const packNameMap = {
|
|
1688
|
+
'coppa': 'COPPA',
|
|
1689
|
+
'ethical': 'Ethical Design',
|
|
1690
|
+
'ai-audit': 'AI Audit',
|
|
1691
|
+
'au-sbd': 'AU Safety by Design',
|
|
1692
|
+
};
|
|
1693
|
+
const packLabel = packs.map(p => packNameMap[p] || p).join(' + ');
|
|
1694
|
+
console.error(c(colors.dim, `🔍 Scanning ${uniqueFiles.length} files (${packLabel})...`));
|
|
1695
|
+
}
|
|
526
1696
|
// Max file size: 1MB (skip large/binary files)
|
|
527
1697
|
const MAX_FILE_SIZE = 1024 * 1024;
|
|
528
1698
|
// Scan each file
|
|
@@ -559,6 +1729,24 @@ async function scan(paths, options) {
|
|
|
559
1729
|
}
|
|
560
1730
|
}
|
|
561
1731
|
}
|
|
1732
|
+
// Calculate compliance score
|
|
1733
|
+
const allViolations = results.flatMap(r => r.violations);
|
|
1734
|
+
const scorer = new engine_1.ComplianceScoreEngine();
|
|
1735
|
+
const scoreResult = scorer.calculate(allViolations, fileCount);
|
|
1736
|
+
// Scan history: compute trend BEFORE saving (so we compare to previous, not current)
|
|
1737
|
+
const projectPath = path.resolve(paths[0] || '.');
|
|
1738
|
+
const trendLine = formatTrend(scoreResult.score, projectPath);
|
|
1739
|
+
// Save to history (silent, never blocks)
|
|
1740
|
+
saveHistory({
|
|
1741
|
+
scannedAt: new Date().toISOString(),
|
|
1742
|
+
score: scoreResult.score,
|
|
1743
|
+
grade: scoreResult.grade,
|
|
1744
|
+
totalViolations: scoreResult.totalViolations,
|
|
1745
|
+
bySeverity: scoreResult.bySeverity,
|
|
1746
|
+
filesScanned: fileCount,
|
|
1747
|
+
projectPath,
|
|
1748
|
+
rulesTriggered: scoreResult.rulesTriggered,
|
|
1749
|
+
});
|
|
562
1750
|
// Format output
|
|
563
1751
|
let output;
|
|
564
1752
|
switch (options.format) {
|
|
@@ -566,10 +1754,35 @@ async function scan(paths, options) {
|
|
|
566
1754
|
output = formatSARIF(results, engine.getRules());
|
|
567
1755
|
break;
|
|
568
1756
|
case 'json':
|
|
569
|
-
output = formatJSON(results);
|
|
1757
|
+
output = formatJSON(results, scoreResult);
|
|
570
1758
|
break;
|
|
571
1759
|
default:
|
|
572
|
-
output = formatText(results, options.verbose, fileCount);
|
|
1760
|
+
output = formatText(results, options.verbose, fileCount, scoreResult);
|
|
1761
|
+
// Append trend line for text output
|
|
1762
|
+
if (trendLine) {
|
|
1763
|
+
output += trendLine + '\n';
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
// Generate report if requested (HTML or PDF based on filename extension)
|
|
1767
|
+
if (options.report) {
|
|
1768
|
+
const reportFilename = typeof options.report === 'string'
|
|
1769
|
+
? options.report
|
|
1770
|
+
: 'halo-report.html';
|
|
1771
|
+
const projectHistory = loadHistory().filter(h => h.projectPath === projectPath);
|
|
1772
|
+
// Exclude the entry we just saved (last one) so trend is accurate
|
|
1773
|
+
const historyForReport = projectHistory.slice(0, -1);
|
|
1774
|
+
if (reportFilename.endsWith('.pdf')) {
|
|
1775
|
+
// PDF report (government-procurement grade)
|
|
1776
|
+
const pdfBuffer = await generatePdfReport(results, scoreResult, fileCount, projectPath, historyForReport);
|
|
1777
|
+
fs.writeFileSync(reportFilename, pdfBuffer);
|
|
1778
|
+
console.error(`📄 PDF report written to ${reportFilename}`);
|
|
1779
|
+
}
|
|
1780
|
+
else {
|
|
1781
|
+
// HTML report (default)
|
|
1782
|
+
const html = generateHtmlReport(results, scoreResult, fileCount, projectPath, historyForReport);
|
|
1783
|
+
fs.writeFileSync(reportFilename, html, 'utf-8');
|
|
1784
|
+
console.error(`📄 HTML report written to ${reportFilename}`);
|
|
1785
|
+
}
|
|
573
1786
|
}
|
|
574
1787
|
// Write output (only one path — no duplication)
|
|
575
1788
|
if (options.output) {
|
|
@@ -579,6 +1792,15 @@ async function scan(paths, options) {
|
|
|
579
1792
|
else {
|
|
580
1793
|
process.stdout.write(output);
|
|
581
1794
|
}
|
|
1795
|
+
// Post-scan CTA (text format only — goes to stderr so it won't pollute piped output)
|
|
1796
|
+
if (options.format === 'text') {
|
|
1797
|
+
console.error('');
|
|
1798
|
+
console.error('─────────────────────────────────────────');
|
|
1799
|
+
console.error('📊 Track results over time: runhalo.dev/app/dashboard');
|
|
1800
|
+
console.error('🔗 Share your score: runhalo.dev/app/score');
|
|
1801
|
+
console.error('⬆️ Upload (Pro): runhalo scan . --upload');
|
|
1802
|
+
console.error('─────────────────────────────────────────');
|
|
1803
|
+
}
|
|
582
1804
|
// Return exit code based on violations
|
|
583
1805
|
const hasCriticalOrHigh = results.some(r => r.violations.some(v => v.severity === 'critical' || v.severity === 'high'));
|
|
584
1806
|
if (hasCriticalOrHigh) {
|
|
@@ -589,14 +1811,231 @@ async function scan(paths, options) {
|
|
|
589
1811
|
}
|
|
590
1812
|
return 0; // No violations
|
|
591
1813
|
}
|
|
1814
|
+
/**
|
|
1815
|
+
* Main fix function
|
|
1816
|
+
* Flow: discover files → scan → filter auto-fixable → apply fixes → re-scan → write (or dry-run)
|
|
1817
|
+
*/
|
|
1818
|
+
async function fix(paths, options) {
|
|
1819
|
+
const scanRoot = paths[0] || '.';
|
|
1820
|
+
if (!fs.existsSync(scanRoot)) {
|
|
1821
|
+
console.error(`❌ Path not found: ${scanRoot}`);
|
|
1822
|
+
return 3;
|
|
1823
|
+
}
|
|
1824
|
+
const engine = new engine_1.HaloEngine({});
|
|
1825
|
+
const fixer = new engine_1.FixEngine();
|
|
1826
|
+
// Collect files (reuse scan discovery logic)
|
|
1827
|
+
const allFiles = [];
|
|
1828
|
+
for (const scanPath of paths) {
|
|
1829
|
+
let stats;
|
|
1830
|
+
try {
|
|
1831
|
+
stats = fs.statSync(scanPath);
|
|
1832
|
+
}
|
|
1833
|
+
catch {
|
|
1834
|
+
console.error(`❌ Path not found: ${scanPath}`);
|
|
1835
|
+
return 3;
|
|
1836
|
+
}
|
|
1837
|
+
if (stats.isDirectory()) {
|
|
1838
|
+
const patterns = options.include.length > 0
|
|
1839
|
+
? options.include
|
|
1840
|
+
: getDefaultPatterns();
|
|
1841
|
+
const excludes = options.exclude.length > 0
|
|
1842
|
+
? options.exclude
|
|
1843
|
+
: getDefaultExcludePatterns();
|
|
1844
|
+
for (const pattern of patterns) {
|
|
1845
|
+
const fullPattern = path.join(scanPath, pattern);
|
|
1846
|
+
const files = await (0, glob_1.glob)(fullPattern, {
|
|
1847
|
+
ignore: excludes,
|
|
1848
|
+
absolute: true
|
|
1849
|
+
});
|
|
1850
|
+
allFiles.push(...files);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
else if (stats.isFile()) {
|
|
1854
|
+
allFiles.push(path.resolve(scanPath));
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
const uniqueFiles = [...new Set(allFiles)];
|
|
1858
|
+
if (options.verbose) {
|
|
1859
|
+
console.error(`🔍 Scanning ${uniqueFiles.length} files for auto-fixable violations...`);
|
|
1860
|
+
}
|
|
1861
|
+
const MAX_FILE_SIZE = 1024 * 1024;
|
|
1862
|
+
let totalApplied = 0;
|
|
1863
|
+
let totalSkipped = 0;
|
|
1864
|
+
let totalFiles = 0;
|
|
1865
|
+
let filesFixed = 0;
|
|
1866
|
+
const fixedRuleIds = new Set();
|
|
1867
|
+
// Track all violations for Pro tease summary
|
|
1868
|
+
let totalAutoViolations = 0;
|
|
1869
|
+
let totalGuidedViolations = 0;
|
|
1870
|
+
let totalFlagOnlyViolations = 0;
|
|
1871
|
+
for (const filePath of uniqueFiles) {
|
|
1872
|
+
try {
|
|
1873
|
+
const stat = fs.statSync(filePath);
|
|
1874
|
+
if (stat.size > MAX_FILE_SIZE)
|
|
1875
|
+
continue;
|
|
1876
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1877
|
+
if (content.substring(0, 512).includes('\0'))
|
|
1878
|
+
continue;
|
|
1879
|
+
const violations = engine.scanFile(filePath, content);
|
|
1880
|
+
if (violations.length === 0) {
|
|
1881
|
+
totalFiles++;
|
|
1882
|
+
continue;
|
|
1883
|
+
}
|
|
1884
|
+
// Count violations by tier for Pro tease
|
|
1885
|
+
for (const v of violations) {
|
|
1886
|
+
const spec = engine_1.REMEDIATION_MAP[v.ruleId];
|
|
1887
|
+
if (!spec)
|
|
1888
|
+
continue;
|
|
1889
|
+
switch (spec.fixability) {
|
|
1890
|
+
case 'auto':
|
|
1891
|
+
totalAutoViolations++;
|
|
1892
|
+
break;
|
|
1893
|
+
case 'guided':
|
|
1894
|
+
totalGuidedViolations++;
|
|
1895
|
+
break;
|
|
1896
|
+
case 'flag-only':
|
|
1897
|
+
totalFlagOnlyViolations++;
|
|
1898
|
+
break;
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
// Apply auto-fixes
|
|
1902
|
+
const result = fixer.applyFixes(content, violations, {
|
|
1903
|
+
rules: options.rules.length > 0 ? options.rules : undefined,
|
|
1904
|
+
});
|
|
1905
|
+
const applied = result.fixes.filter(f => f.status === 'applied');
|
|
1906
|
+
const skipped = result.fixes.filter(f => f.status === 'skipped');
|
|
1907
|
+
if (applied.length > 0) {
|
|
1908
|
+
filesFixed++;
|
|
1909
|
+
totalApplied += applied.length;
|
|
1910
|
+
applied.forEach(f => fixedRuleIds.add(f.ruleId));
|
|
1911
|
+
if (options.dryRun) {
|
|
1912
|
+
// Show diff
|
|
1913
|
+
const diff = fixer.generateDiff(filePath, content, result.fixedContent);
|
|
1914
|
+
console.log(diff);
|
|
1915
|
+
console.log('');
|
|
1916
|
+
}
|
|
1917
|
+
else {
|
|
1918
|
+
// Write fixed content
|
|
1919
|
+
fs.writeFileSync(filePath, result.fixedContent, 'utf-8');
|
|
1920
|
+
}
|
|
1921
|
+
// Show warnings for behavior-changing fixes
|
|
1922
|
+
for (const f of applied) {
|
|
1923
|
+
if (f.warning) {
|
|
1924
|
+
const relPath = path.relative(process.cwd(), filePath);
|
|
1925
|
+
console.error(` ${c(colors.yellow, '⚠')} ${c(colors.dim, relPath + ':' + f.line)} ${c(colors.yellow, f.warning)}`);
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
if (options.verbose) {
|
|
1929
|
+
const relPath = path.relative(process.cwd(), filePath);
|
|
1930
|
+
console.error(` ${c(colors.cyan, '✓')} ${relPath}: ${applied.length} fix(es) applied`);
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
totalSkipped += skipped.length;
|
|
1934
|
+
totalFiles++;
|
|
1935
|
+
}
|
|
1936
|
+
catch (err) {
|
|
1937
|
+
if (options.verbose) {
|
|
1938
|
+
console.error(`⚠️ Error processing ${filePath}:`, err);
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
// Summary
|
|
1943
|
+
console.error('');
|
|
1944
|
+
if (totalApplied > 0) {
|
|
1945
|
+
const action = options.dryRun ? 'Would auto-fix' : 'Auto-fixed';
|
|
1946
|
+
console.error(`${c(colors.bold + colors.cyan, `✓ ${action} ${totalApplied} issue(s)`)} across ${filesFixed} file(s) (${fixedRuleIds.size} rule(s))`);
|
|
1947
|
+
}
|
|
1948
|
+
else {
|
|
1949
|
+
console.error(`${c(colors.dim, 'No auto-fixable issues found.')}`);
|
|
1950
|
+
}
|
|
1951
|
+
// Pro tease: show unfixed counts by tier
|
|
1952
|
+
const remainingGuided = totalGuidedViolations;
|
|
1953
|
+
const remainingFlagOnly = totalFlagOnlyViolations;
|
|
1954
|
+
if (remainingGuided > 0 && !options.guided) {
|
|
1955
|
+
console.error(`${c(colors.yellow, `⚠ ${remainingGuided} issue(s) need guided fixes`)} ${c(colors.dim, '(run with --guided to generate scaffolds)')}`);
|
|
1956
|
+
}
|
|
1957
|
+
if (remainingFlagOnly > 0) {
|
|
1958
|
+
console.error(`${c(colors.blue, `ℹ ${remainingFlagOnly} issue(s) flagged for design review`)} ${c(colors.dim, '(requires manual assessment)')}`);
|
|
1959
|
+
}
|
|
1960
|
+
// Guided fixes: generate scaffold files for Tier 2 violations
|
|
1961
|
+
if (options.guided && totalGuidedViolations > 0) {
|
|
1962
|
+
const { ScaffoldEngine } = require('@runhalo/engine');
|
|
1963
|
+
const scaffoldEngine = new ScaffoldEngine();
|
|
1964
|
+
// Collect all violations with guided fixability
|
|
1965
|
+
const guidedViolations = [];
|
|
1966
|
+
for (const filePath of uniqueFiles) {
|
|
1967
|
+
try {
|
|
1968
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1969
|
+
const violations = engine.scanFile(filePath, content);
|
|
1970
|
+
for (const v of violations) {
|
|
1971
|
+
const spec = engine_1.REMEDIATION_MAP[v.ruleId];
|
|
1972
|
+
if (spec?.fixability === 'guided') {
|
|
1973
|
+
guidedViolations.push(v);
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
catch { }
|
|
1978
|
+
}
|
|
1979
|
+
const projectPath = path.resolve(scanRoot);
|
|
1980
|
+
const frameworkOverride = options.framework;
|
|
1981
|
+
const summary = scaffoldEngine.getSummary(guidedViolations, projectPath, frameworkOverride);
|
|
1982
|
+
if (summary.totalScaffolds > 0) {
|
|
1983
|
+
console.error('');
|
|
1984
|
+
console.error(`${c(colors.bold + colors.cyan, '🔧 Guided Fixes')} (${summary.framework}${summary.typescript ? ' + TypeScript' : ''}):`);
|
|
1985
|
+
if (options.dryRun) {
|
|
1986
|
+
// Dry run: just show what would be generated
|
|
1987
|
+
const results = scaffoldEngine.generateScaffolds(guidedViolations, projectPath, frameworkOverride);
|
|
1988
|
+
for (const result of results) {
|
|
1989
|
+
console.error(` ${c(colors.cyan, '●')} ${c(colors.bold, result.scaffoldId)} → ${result.ruleId}`);
|
|
1990
|
+
for (const file of result.files) {
|
|
1991
|
+
console.error(` ${c(colors.dim, '→')} ${file.relativePath} — ${file.description}`);
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
console.error('');
|
|
1995
|
+
console.error(` ${c(colors.dim, 'Run without --dry-run to write scaffold files.')}`);
|
|
1996
|
+
}
|
|
1997
|
+
else {
|
|
1998
|
+
// Write scaffold files
|
|
1999
|
+
const outputDir = options.scaffoldDir || path.join(process.cwd(), 'halo-scaffolds');
|
|
2000
|
+
const results = scaffoldEngine.generateScaffolds(guidedViolations, projectPath, frameworkOverride);
|
|
2001
|
+
let filesWritten = 0;
|
|
2002
|
+
for (const result of results) {
|
|
2003
|
+
for (const file of result.files) {
|
|
2004
|
+
const fullPath = path.join(outputDir, file.relativePath);
|
|
2005
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
2006
|
+
fs.writeFileSync(fullPath, file.content, 'utf-8');
|
|
2007
|
+
filesWritten++;
|
|
2008
|
+
if (options.verbose) {
|
|
2009
|
+
console.error(` ${c(colors.cyan, '✓')} ${path.relative(process.cwd(), fullPath)}`);
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
console.error(`${c(colors.bold + colors.cyan, `✓ Generated ${filesWritten} scaffold file(s)`)} in ${path.relative(process.cwd(), outputDir) || outputDir}`);
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
// Show unavailable scaffolds (link to docs)
|
|
2017
|
+
if (summary.unavailableIds.length > 0) {
|
|
2018
|
+
console.error('');
|
|
2019
|
+
for (const id of summary.unavailableIds) {
|
|
2020
|
+
console.error(` ${c(colors.dim, '📖')} ${id} — docs: ${c(colors.blue, `https://runhalo.dev/rules/${id}`)}`);
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
console.error('');
|
|
2025
|
+
// Exit codes: 0 = all good, 1 = partial (some violations remain), 3 = fatal
|
|
2026
|
+
if (totalApplied > 0 && (remainingGuided > 0 || remainingFlagOnly > 0)) {
|
|
2027
|
+
return 1; // Partial — some issues remain
|
|
2028
|
+
}
|
|
2029
|
+
return 0;
|
|
2030
|
+
}
|
|
592
2031
|
// CLI setup
|
|
593
2032
|
program
|
|
594
2033
|
.name('runhalo')
|
|
595
|
-
.description('Halo
|
|
2034
|
+
.description('Halo \u2014 Ethical code scanner for children\u2019s digital safety')
|
|
596
2035
|
.version('1.0.0');
|
|
597
2036
|
program
|
|
598
2037
|
.command('scan')
|
|
599
|
-
.description('Scan
|
|
2038
|
+
.description('Scan source code for child safety compliance violations')
|
|
600
2039
|
.argument('[paths...]', 'Paths to scan (default: current directory)', ['.'])
|
|
601
2040
|
.option('-f, --format <format>', 'Output format: json, sarif, text', 'text')
|
|
602
2041
|
.option('-i, --include <patterns...>', 'File patterns to include')
|
|
@@ -605,9 +2044,37 @@ program
|
|
|
605
2044
|
.option('-s, --severity <levels...>', 'Filter by severity: critical, high, medium, low')
|
|
606
2045
|
.option('-o, --output <file>', 'Output file path')
|
|
607
2046
|
.option('--ethical-preview', 'Enable experimental ethical design rules (Sprint 5 preview)')
|
|
2047
|
+
.option('--ai-audit', 'Enable AI-generated code audit rules (catch AI coding assistant mistakes)')
|
|
2048
|
+
.option('--sector-au-sbd', 'Enable Australia Safety by Design sector rules (eSafety Commissioner framework)')
|
|
2049
|
+
.option('--pack <packs...>', 'Rule packs to scan against (e.g., coppa ethical ai-audit au-sbd)')
|
|
2050
|
+
.option('--offline', 'Skip API fetch, use cached or bundled rules only')
|
|
2051
|
+
.option('--report [filename]', 'Generate HTML compliance report (default: halo-report.html)')
|
|
2052
|
+
.option('--upload', 'Upload scan results to Halo Dashboard (requires Pro)')
|
|
2053
|
+
.option('--no-prompt', 'Skip first-run email prompt')
|
|
608
2054
|
.option('-v, --verbose', 'Verbose output')
|
|
609
2055
|
.action(async (paths, options) => {
|
|
610
2056
|
try {
|
|
2057
|
+
await firstRunPrompt(options.prompt === false);
|
|
2058
|
+
// Pro feature gating (soft upsell — exit 0, not error)
|
|
2059
|
+
if (options.report && !checkProFeature('HTML Compliance Reports', '--report')) {
|
|
2060
|
+
process.exit(0);
|
|
2061
|
+
}
|
|
2062
|
+
if (options.ethicalPreview && !checkProFeature('Ethical Design Rules', '--ethical-preview')) {
|
|
2063
|
+
process.exit(0);
|
|
2064
|
+
}
|
|
2065
|
+
if (options.aiAudit && !checkProFeature('AI-Generated Code Audit', '--ai-audit')) {
|
|
2066
|
+
process.exit(0);
|
|
2067
|
+
}
|
|
2068
|
+
if (options.sectorAuSbd && !checkProFeature('AU Safety by Design Rules', '--sector-au-sbd')) {
|
|
2069
|
+
process.exit(0);
|
|
2070
|
+
}
|
|
2071
|
+
if (options.upload && !checkProFeature('Dashboard Upload', '--upload')) {
|
|
2072
|
+
process.exit(0);
|
|
2073
|
+
}
|
|
2074
|
+
// Scan limit check (soft — exit 0, not error)
|
|
2075
|
+
if (!checkScanLimit()) {
|
|
2076
|
+
process.exit(0);
|
|
2077
|
+
}
|
|
611
2078
|
const exitCode = await scan(paths, {
|
|
612
2079
|
format: options.format || 'text',
|
|
613
2080
|
include: options.include || [],
|
|
@@ -616,8 +2083,66 @@ program
|
|
|
616
2083
|
severity: options.severity || [],
|
|
617
2084
|
output: options.output || '',
|
|
618
2085
|
verbose: options.verbose || false,
|
|
619
|
-
ethicalPreview: options.ethicalPreview || false
|
|
2086
|
+
ethicalPreview: options.ethicalPreview || false,
|
|
2087
|
+
aiAudit: options.aiAudit || false,
|
|
2088
|
+
sectorAuSbd: options.sectorAuSbd || false,
|
|
2089
|
+
report: options.report || false,
|
|
2090
|
+
pack: options.pack || [],
|
|
2091
|
+
offline: options.offline || false,
|
|
620
2092
|
});
|
|
2093
|
+
// Upload to Halo Dashboard (non-blocking — upload failure doesn't affect exit code)
|
|
2094
|
+
if (options.upload) {
|
|
2095
|
+
try {
|
|
2096
|
+
const config = loadConfig();
|
|
2097
|
+
if (!config.license_key) {
|
|
2098
|
+
console.error('⚠️ No license key found. Run `halo activate <key>` first.');
|
|
2099
|
+
}
|
|
2100
|
+
else {
|
|
2101
|
+
console.error('☁️ Uploading scan results to Halo Dashboard...');
|
|
2102
|
+
// Re-scan in JSON format to get structured data for upload
|
|
2103
|
+
// Use the scan history to get the latest results
|
|
2104
|
+
const history = loadHistory();
|
|
2105
|
+
const lastEntry = history[history.length - 1];
|
|
2106
|
+
if (lastEntry) {
|
|
2107
|
+
const projectPath = path.resolve(paths[0] || '.');
|
|
2108
|
+
// Build minimal scan_json from last scan entry
|
|
2109
|
+
const scanJsonForUpload = {
|
|
2110
|
+
repo: projectPath,
|
|
2111
|
+
scannedAt: lastEntry.scannedAt,
|
|
2112
|
+
filesScanned: lastEntry.filesScanned,
|
|
2113
|
+
totalFiles: lastEntry.filesScanned,
|
|
2114
|
+
violations: [], // Full violations not in history; send metadata only
|
|
2115
|
+
score: lastEntry.score,
|
|
2116
|
+
grade: lastEntry.grade,
|
|
2117
|
+
bySeverity: lastEntry.bySeverity,
|
|
2118
|
+
rulesTriggered: lastEntry.rulesTriggered,
|
|
2119
|
+
};
|
|
2120
|
+
const uploadUrl = 'https://wrfwcmyxxbafcdvxlmug.supabase.co/functions/v1/upload-scan';
|
|
2121
|
+
const res = await fetch(uploadUrl, {
|
|
2122
|
+
method: 'POST',
|
|
2123
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2124
|
+
body: JSON.stringify({
|
|
2125
|
+
license_key: config.license_key,
|
|
2126
|
+
scan_json: scanJsonForUpload,
|
|
2127
|
+
repo_url: projectPath,
|
|
2128
|
+
}),
|
|
2129
|
+
});
|
|
2130
|
+
if (res.ok) {
|
|
2131
|
+
const data = await res.json();
|
|
2132
|
+
console.error(`✅ Uploaded to dashboard: ${data.dashboard_url}`);
|
|
2133
|
+
console.error(`🔗 Share: ${data.share_url}`);
|
|
2134
|
+
}
|
|
2135
|
+
else {
|
|
2136
|
+
const err = await res.json().catch(() => ({}));
|
|
2137
|
+
console.error(`⚠️ Upload failed: ${err.error || res.statusText}`);
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
catch (uploadErr) {
|
|
2143
|
+
console.error(`⚠️ Upload failed: ${uploadErr instanceof Error ? uploadErr.message : uploadErr}`);
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
621
2146
|
process.exit(exitCode);
|
|
622
2147
|
}
|
|
623
2148
|
catch (error) {
|
|
@@ -625,6 +2150,358 @@ program
|
|
|
625
2150
|
process.exit(3); // Fatal error
|
|
626
2151
|
}
|
|
627
2152
|
});
|
|
2153
|
+
program
|
|
2154
|
+
.command('fix')
|
|
2155
|
+
.description('Auto-fix COPPA violations (Tier 1 deterministic transforms + Tier 2 scaffolds)')
|
|
2156
|
+
.argument('[paths...]', 'Paths to fix (default: current directory)', ['.'])
|
|
2157
|
+
.option('--dry-run', 'Show diffs without writing changes', false)
|
|
2158
|
+
.option('-r, --rules <ruleIds...>', 'Fix specific rules only')
|
|
2159
|
+
.option('-i, --include <patterns...>', 'File patterns to include')
|
|
2160
|
+
.option('-e, --exclude <patterns...>', 'File patterns to exclude')
|
|
2161
|
+
.option('--guided', 'Generate scaffold files for Tier 2 guided fixes', false)
|
|
2162
|
+
.option('--framework <framework>', 'Override framework detection: react, nextjs, vue, svelte, plain-js')
|
|
2163
|
+
.option('--scaffold-dir <dir>', 'Output directory for scaffold files (default: ./halo-scaffolds)')
|
|
2164
|
+
.option('--no-prompt', 'Skip first-run email prompt')
|
|
2165
|
+
.option('-v, --verbose', 'Detailed output', false)
|
|
2166
|
+
.action(async (paths, options) => {
|
|
2167
|
+
try {
|
|
2168
|
+
await firstRunPrompt(options.prompt === false);
|
|
2169
|
+
// Pro feature gating for guided fixes
|
|
2170
|
+
if (options.guided && !checkProFeature('Guided Scaffold Generation', '--guided')) {
|
|
2171
|
+
process.exit(0);
|
|
2172
|
+
}
|
|
2173
|
+
const exitCode = await fix(paths, {
|
|
2174
|
+
dryRun: options.dryRun || false,
|
|
2175
|
+
rules: options.rules || [],
|
|
2176
|
+
include: options.include || [],
|
|
2177
|
+
exclude: options.exclude || [],
|
|
2178
|
+
verbose: options.verbose || false,
|
|
2179
|
+
guided: options.guided || false,
|
|
2180
|
+
framework: options.framework,
|
|
2181
|
+
scaffoldDir: options.scaffoldDir,
|
|
2182
|
+
});
|
|
2183
|
+
process.exit(exitCode);
|
|
2184
|
+
}
|
|
2185
|
+
catch (error) {
|
|
2186
|
+
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
|
2187
|
+
process.exit(3);
|
|
2188
|
+
}
|
|
2189
|
+
});
|
|
2190
|
+
// ==================== Activate Command (License Key) ====================
|
|
2191
|
+
program
|
|
2192
|
+
.command('activate')
|
|
2193
|
+
.description('Activate a Halo Pro or Enterprise license key')
|
|
2194
|
+
.argument('<key>', 'License key (UUID from your purchase confirmation)')
|
|
2195
|
+
.action(async (key) => {
|
|
2196
|
+
try {
|
|
2197
|
+
const exitCode = await activateLicense(key);
|
|
2198
|
+
process.exit(exitCode);
|
|
2199
|
+
}
|
|
2200
|
+
catch (error) {
|
|
2201
|
+
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
|
2202
|
+
process.exit(3);
|
|
2203
|
+
}
|
|
2204
|
+
});
|
|
2205
|
+
/**
|
|
2206
|
+
* COPPA compliance rules content for AI coding assistants.
|
|
2207
|
+
* Teaches the assistant to write COPPA-compliant code proactively.
|
|
2208
|
+
*/
|
|
2209
|
+
function getIDERulesContent() {
|
|
2210
|
+
return `# Halo — COPPA 2.0 Compliance Rules for AI Coding Assistants
|
|
2211
|
+
# Generated by: npx runhalo init --ide
|
|
2212
|
+
# Docs: https://runhalo.dev/rules
|
|
2213
|
+
# Scanner: npx runhalo scan .
|
|
2214
|
+
|
|
2215
|
+
You are working on a child-directed application subject to COPPA 2.0 (effective April 22, 2026).
|
|
2216
|
+
All code you generate MUST comply with these rules. Violations carry penalties up to $53,088/day.
|
|
2217
|
+
|
|
2218
|
+
## CRITICAL Rules (auto-block in CI)
|
|
2219
|
+
|
|
2220
|
+
1. **No Social Login Without Age Gate** (coppa-auth-001)
|
|
2221
|
+
Never use signInWithPopup(), passport.authenticate('google'), or OAuth flows
|
|
2222
|
+
without first verifying the user is 13+ via date-of-birth check.
|
|
2223
|
+
|
|
2224
|
+
2. **No Ad Trackers** (coppa-tracking-003)
|
|
2225
|
+
Never add Google Analytics (gtag), Facebook Pixel (fbq), or AdSense (adsbygoogle)
|
|
2226
|
+
without setting child_directed_treatment: true.
|
|
2227
|
+
|
|
2228
|
+
3. **No Biometric Data Collection** (coppa-bio-012)
|
|
2229
|
+
Never use FaceID, TouchID, face-api.js, or voice print APIs without
|
|
2230
|
+
explicit verifiable parental consent. Voice prints are biometric data under COPPA 2.0.
|
|
2231
|
+
|
|
2232
|
+
4. **No Unencrypted PII** (coppa-sec-006)
|
|
2233
|
+
Never use http:// for API endpoints that handle personal information.
|
|
2234
|
+
All PII transmission must use https://.
|
|
2235
|
+
|
|
2236
|
+
5. **Default Privacy = Private** (coppa-default-020)
|
|
2237
|
+
Never set isProfileVisible: true, visibility: "public", or defaultPrivacy: "public".
|
|
2238
|
+
All profiles default to private. Privacy by design is required.
|
|
2239
|
+
|
|
2240
|
+
## HIGH Rules (flag in PR review)
|
|
2241
|
+
|
|
2242
|
+
6. **No PII in URL Parameters** (coppa-data-002)
|
|
2243
|
+
Never pass email, name, DOB, or phone as GET query parameters.
|
|
2244
|
+
Use POST with request body instead.
|
|
2245
|
+
|
|
2246
|
+
7. **No Precise Geolocation** (coppa-geo-004)
|
|
2247
|
+
Never use navigator.geolocation.getCurrentPosition() without parental consent.
|
|
2248
|
+
Downgrade accuracy to city-level if needed.
|
|
2249
|
+
|
|
2250
|
+
8. **No Passive Audio Recording** (coppa-audio-007)
|
|
2251
|
+
Never call getUserMedia({audio: true}) or MediaRecorder without click handler
|
|
2252
|
+
and parental consent check.
|
|
2253
|
+
|
|
2254
|
+
9. **No Unmoderated Chat Widgets** (coppa-ext-011)
|
|
2255
|
+
Never add Intercom, Zendesk, Drift, or Freshdesk without age-gating.
|
|
2256
|
+
Chat widgets allow children to disclose PII freely.
|
|
2257
|
+
|
|
2258
|
+
10. **No PII in Analytics** (coppa-analytics-018)
|
|
2259
|
+
Never pass email, name, or phone to analytics.identify(), mixpanel.identify(),
|
|
2260
|
+
or segment.identify(). Hash user IDs instead.
|
|
2261
|
+
|
|
2262
|
+
11. **No UGC Without PII Filter** (coppa-ugc-014)
|
|
2263
|
+
Text areas for bio, about-me, or comments must pass through PII scrubbing
|
|
2264
|
+
before database storage.
|
|
2265
|
+
|
|
2266
|
+
12. **Parent Email Required for Child Contact** (coppa-flow-009)
|
|
2267
|
+
Forms collecting child_email or student_email must also require parent_email.
|
|
2268
|
+
|
|
2269
|
+
## MEDIUM Rules (compliance warnings)
|
|
2270
|
+
|
|
2271
|
+
13. **Data Retention Required** (coppa-retention-005)
|
|
2272
|
+
Database schemas must include deleted_at, expiration_date, or TTL index.
|
|
2273
|
+
COPPA requires data retention policies.
|
|
2274
|
+
|
|
2275
|
+
14. **Privacy Policy on Registration** (coppa-ui-008)
|
|
2276
|
+
Registration forms must include a visible link to the privacy policy.
|
|
2277
|
+
|
|
2278
|
+
15. **Secure Default Passwords** (coppa-sec-010)
|
|
2279
|
+
Never use "password", "123456", or "changeme" as default/initial passwords.
|
|
2280
|
+
|
|
2281
|
+
16. **External Link Warnings** (coppa-ext-017)
|
|
2282
|
+
External links opening in _blank should trigger a "You are leaving..." modal.
|
|
2283
|
+
|
|
2284
|
+
17. **Push Notification Consent** (coppa-notif-013)
|
|
2285
|
+
Push notifications are "Online Contact Info" under COPPA 2.0.
|
|
2286
|
+
Gate subscriptions behind parental dashboard settings.
|
|
2287
|
+
|
|
2288
|
+
18. **Teacher Account Verification** (coppa-edu-019)
|
|
2289
|
+
Teacher sign-ups using @gmail.com bypass the School Official consent exception.
|
|
2290
|
+
Restrict to .edu domains or require manual approval.
|
|
2291
|
+
|
|
2292
|
+
19. **XSS Prevention** (coppa-sec-015)
|
|
2293
|
+
Never use dangerouslySetInnerHTML or .innerHTML with user-controlled content.
|
|
2294
|
+
Use DOMPurify or standard JSX rendering.
|
|
2295
|
+
|
|
2296
|
+
20. **Cookie Consent** (coppa-cookies-016)
|
|
2297
|
+
Cookies or localStorage storing tracking/PII data require a consent banner.
|
|
2298
|
+
|
|
2299
|
+
## AI Code Generation Guidelines
|
|
2300
|
+
|
|
2301
|
+
When generating code for this project:
|
|
2302
|
+
- Always add age verification before authentication flows
|
|
2303
|
+
- Always use HTTPS for API endpoints
|
|
2304
|
+
- Always default user profiles to private
|
|
2305
|
+
- Always add cookie consent before setting tracking cookies
|
|
2306
|
+
- Always use POST (not GET) for forms collecting personal information
|
|
2307
|
+
- Always add PII scrubbing middleware before storing user-generated content
|
|
2308
|
+
- Always include data retention/deletion utilities in database schemas
|
|
2309
|
+
- Never add analytics, tracking, or ad scripts without child_directed_treatment flags
|
|
2310
|
+
- Never embed third-party chat/support widgets without age-gating logic
|
|
2311
|
+
- Prefer privacy-preserving alternatives (hashed IDs, aggregated analytics, on-device processing)
|
|
2312
|
+
|
|
2313
|
+
## Scan Your Code
|
|
2314
|
+
|
|
2315
|
+
Run \`npx runhalo scan .\` to check compliance.
|
|
2316
|
+
Run \`npx runhalo fix . --guided\` for guided remediation scaffolds.
|
|
2317
|
+
Run \`npx runhalo scan . --report\` for an HTML compliance report.
|
|
2318
|
+
`;
|
|
2319
|
+
}
|
|
2320
|
+
/**
|
|
2321
|
+
* Generate .cursor/rules content (Cursor-specific format)
|
|
2322
|
+
*/
|
|
2323
|
+
function getCursorRulesContent() {
|
|
2324
|
+
return getIDERulesContent();
|
|
2325
|
+
}
|
|
2326
|
+
/**
|
|
2327
|
+
* Generate .windsurfrules content (Windsurf-specific format)
|
|
2328
|
+
*/
|
|
2329
|
+
function getWindsurfRulesContent() {
|
|
2330
|
+
return getIDERulesContent();
|
|
2331
|
+
}
|
|
2332
|
+
/**
|
|
2333
|
+
* Generate .github/copilot-instructions.md (GitHub Copilot)
|
|
2334
|
+
*/
|
|
2335
|
+
function getCopilotInstructionsContent() {
|
|
2336
|
+
return getIDERulesContent();
|
|
2337
|
+
}
|
|
2338
|
+
/**
|
|
2339
|
+
* Init command — generate IDE rules files and project configuration.
|
|
2340
|
+
*/
|
|
2341
|
+
async function init(projectPath, options) {
|
|
2342
|
+
const resolvedPath = path.resolve(projectPath);
|
|
2343
|
+
if (!options.ide) {
|
|
2344
|
+
console.log('🔮 Halo init — project setup');
|
|
2345
|
+
console.log('');
|
|
2346
|
+
console.log('Usage:');
|
|
2347
|
+
console.log(' runhalo init --ide Generate AI coding assistant rules files');
|
|
2348
|
+
console.log('');
|
|
2349
|
+
console.log('Options:');
|
|
2350
|
+
console.log(' --ide Generate .cursor/rules, .windsurfrules, .github/copilot-instructions.md');
|
|
2351
|
+
console.log(' --force Overwrite existing rules files');
|
|
2352
|
+
return 0;
|
|
2353
|
+
}
|
|
2354
|
+
console.log('🔮 Halo init — Generating AI coding assistant rules...\n');
|
|
2355
|
+
const files = [
|
|
2356
|
+
{
|
|
2357
|
+
path: path.join(resolvedPath, '.cursor', 'rules'),
|
|
2358
|
+
content: getCursorRulesContent(),
|
|
2359
|
+
label: 'Cursor'
|
|
2360
|
+
},
|
|
2361
|
+
{
|
|
2362
|
+
path: path.join(resolvedPath, '.windsurfrules'),
|
|
2363
|
+
content: getWindsurfRulesContent(),
|
|
2364
|
+
label: 'Windsurf'
|
|
2365
|
+
},
|
|
2366
|
+
{
|
|
2367
|
+
path: path.join(resolvedPath, '.github', 'copilot-instructions.md'),
|
|
2368
|
+
content: getCopilotInstructionsContent(),
|
|
2369
|
+
label: 'GitHub Copilot'
|
|
2370
|
+
}
|
|
2371
|
+
];
|
|
2372
|
+
let created = 0;
|
|
2373
|
+
let skipped = 0;
|
|
2374
|
+
for (const file of files) {
|
|
2375
|
+
const dir = path.dirname(file.path);
|
|
2376
|
+
const relativePath = path.relative(resolvedPath, file.path);
|
|
2377
|
+
if (fs.existsSync(file.path) && !options.force) {
|
|
2378
|
+
console.log(` ⏭ ${relativePath} (exists — use --force to overwrite)`);
|
|
2379
|
+
skipped++;
|
|
2380
|
+
continue;
|
|
2381
|
+
}
|
|
2382
|
+
try {
|
|
2383
|
+
if (!fs.existsSync(dir)) {
|
|
2384
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2385
|
+
}
|
|
2386
|
+
fs.writeFileSync(file.path, file.content, 'utf-8');
|
|
2387
|
+
console.log(` ✅ ${relativePath} — ${file.label} rules`);
|
|
2388
|
+
created++;
|
|
2389
|
+
}
|
|
2390
|
+
catch (err) {
|
|
2391
|
+
console.error(` ❌ ${relativePath} — ${err instanceof Error ? err.message : err}`);
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
console.log('');
|
|
2395
|
+
if (created > 0) {
|
|
2396
|
+
console.log(`Created ${created} rules file${created > 1 ? 's' : ''}. Your AI assistant now knows COPPA 2.0.`);
|
|
2397
|
+
}
|
|
2398
|
+
if (skipped > 0) {
|
|
2399
|
+
console.log(`Skipped ${skipped} existing file${skipped > 1 ? 's' : ''}.`);
|
|
2400
|
+
}
|
|
2401
|
+
console.log('');
|
|
2402
|
+
console.log('What happens next:');
|
|
2403
|
+
console.log(' • Cursor, Windsurf, and Copilot will read these rules automatically');
|
|
2404
|
+
console.log(' • AI-generated code will follow COPPA compliance patterns');
|
|
2405
|
+
console.log(' • Run "npx runhalo scan ." to verify compliance');
|
|
2406
|
+
console.log('');
|
|
2407
|
+
console.log('Full-stack compliance for the AI coding era:');
|
|
2408
|
+
console.log(' CI: uses: runhalo/action@v1 catches violations in PRs');
|
|
2409
|
+
console.log(' Local: npx runhalo scan . catches violations on your machine');
|
|
2410
|
+
console.log(' Proactive: AI rules files prevent violations before they\'re written');
|
|
2411
|
+
return 0;
|
|
2412
|
+
}
|
|
2413
|
+
program
|
|
2414
|
+
.command('packs')
|
|
2415
|
+
.description('List available rule packs')
|
|
2416
|
+
.option('-v, --verbose', 'Show detailed pack information')
|
|
2417
|
+
.action(async (options) => {
|
|
2418
|
+
try {
|
|
2419
|
+
const verbose = options.verbose || false;
|
|
2420
|
+
// Try API first, then cache, then bundled
|
|
2421
|
+
let packData = null;
|
|
2422
|
+
// Try API
|
|
2423
|
+
try {
|
|
2424
|
+
const controller = new AbortController();
|
|
2425
|
+
const timeout = setTimeout(() => controller.abort(), RULES_FETCH_TIMEOUT_MS);
|
|
2426
|
+
const res = await fetch(`${RULES_API_BASE}/rules-catalog`, { signal: controller.signal });
|
|
2427
|
+
clearTimeout(timeout);
|
|
2428
|
+
if (res.ok) {
|
|
2429
|
+
const data = await res.json();
|
|
2430
|
+
packData = data.packs || [];
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
catch {
|
|
2434
|
+
// API failed — try bundled
|
|
2435
|
+
}
|
|
2436
|
+
// Fallback: load from bundled rules.json
|
|
2437
|
+
if (!packData) {
|
|
2438
|
+
try {
|
|
2439
|
+
const rulesJsonPath = require.resolve('@runhalo/engine/rules/rules.json');
|
|
2440
|
+
const data = JSON.parse(fs.readFileSync(rulesJsonPath, 'utf-8'));
|
|
2441
|
+
packData = Object.values(data.packs).map((pack) => {
|
|
2442
|
+
const ruleCount = (data.rules || []).filter((r) => r.packs.includes(pack.id)).length;
|
|
2443
|
+
return {
|
|
2444
|
+
pack_id: pack.id,
|
|
2445
|
+
name: pack.name,
|
|
2446
|
+
description: pack.description,
|
|
2447
|
+
jurisdiction: pack.jurisdiction,
|
|
2448
|
+
is_free: pack.is_free,
|
|
2449
|
+
rule_count: ruleCount,
|
|
2450
|
+
};
|
|
2451
|
+
});
|
|
2452
|
+
}
|
|
2453
|
+
catch {
|
|
2454
|
+
console.error('❌ Could not load pack information');
|
|
2455
|
+
process.exit(1);
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
console.log('');
|
|
2459
|
+
console.log('Available Rule Packs:');
|
|
2460
|
+
console.log('');
|
|
2461
|
+
for (const pack of packData) {
|
|
2462
|
+
const tier = pack.is_free ? c(colors.green, 'free') : c(colors.yellow, 'pro');
|
|
2463
|
+
const id = c(colors.bold, pack.pack_id);
|
|
2464
|
+
console.log(` ${id} (${tier}) — ${pack.name} — ${pack.rule_count} rules`);
|
|
2465
|
+
if (verbose && pack.description) {
|
|
2466
|
+
console.log(` ${c(colors.dim, pack.description)}`);
|
|
2467
|
+
if (pack.jurisdiction) {
|
|
2468
|
+
console.log(` ${c(colors.dim, `Jurisdiction: ${pack.jurisdiction}`)}`);
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
const totalRules = packData.reduce((sum, p) => sum + p.rule_count, 0);
|
|
2473
|
+
console.log('');
|
|
2474
|
+
console.log(` ${c(colors.dim, `${packData.length} packs, ${totalRules} total rules`)}`);
|
|
2475
|
+
console.log('');
|
|
2476
|
+
console.log('Usage:');
|
|
2477
|
+
console.log(' npx runhalo scan . --pack coppa ethical');
|
|
2478
|
+
console.log(' npx runhalo scan . --pack coppa ai-audit au-sbd');
|
|
2479
|
+
console.log('');
|
|
2480
|
+
}
|
|
2481
|
+
catch (error) {
|
|
2482
|
+
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
|
2483
|
+
process.exit(1);
|
|
2484
|
+
}
|
|
2485
|
+
});
|
|
2486
|
+
program
|
|
2487
|
+
.command('init')
|
|
2488
|
+
.description('Initialize Halo in your project (generate IDE rules files)')
|
|
2489
|
+
.argument('[path]', 'Project root path (default: current directory)', '.')
|
|
2490
|
+
.option('--ide', 'Generate AI coding assistant rules files', false)
|
|
2491
|
+
.option('--force', 'Overwrite existing rules files', false)
|
|
2492
|
+
.action(async (projectPath, options) => {
|
|
2493
|
+
try {
|
|
2494
|
+
const exitCode = await init(projectPath, {
|
|
2495
|
+
ide: options.ide || false,
|
|
2496
|
+
force: options.force || false,
|
|
2497
|
+
});
|
|
2498
|
+
process.exit(exitCode);
|
|
2499
|
+
}
|
|
2500
|
+
catch (error) {
|
|
2501
|
+
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
|
2502
|
+
process.exit(3);
|
|
2503
|
+
}
|
|
2504
|
+
});
|
|
628
2505
|
// Run CLI
|
|
629
2506
|
if (require.main === module) {
|
|
630
2507
|
program.parse();
|