@runhalo/engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,956 @@
1
+ "use strict";
2
+ /**
3
+ * Halo COPPA Rule Engine
4
+ * Core scanning logic for child safety compliance detection
5
+ *
6
+ * Sprint 2: Added rules 6-20 (coppa-sec-006 through coppa-default-020)
7
+ * Sprint 2: Added suppression system for // halo-ignore comments
8
+ * Sprint 1 Fixes: Added tree-sitter for AST analysis, YAML rule loading
9
+ */
10
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ var desc = Object.getOwnPropertyDescriptor(m, k);
13
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
14
+ desc = { enumerable: true, get: function() { return m[k]; } };
15
+ }
16
+ Object.defineProperty(o, k2, desc);
17
+ }) : (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ o[k2] = m[k];
20
+ }));
21
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
22
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
23
+ }) : function(o, v) {
24
+ o["default"] = v;
25
+ });
26
+ var __importStar = (this && this.__importStar) || (function () {
27
+ var ownKeys = function(o) {
28
+ ownKeys = Object.getOwnPropertyNames || function (o) {
29
+ var ar = [];
30
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
31
+ return ar;
32
+ };
33
+ return ownKeys(o);
34
+ };
35
+ return function (mod) {
36
+ if (mod && mod.__esModule) return mod;
37
+ var result = {};
38
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
39
+ __setModuleDefault(result, mod);
40
+ return result;
41
+ };
42
+ })();
43
+ var __importDefault = (this && this.__importDefault) || function (mod) {
44
+ return (mod && mod.__esModule) ? mod : { "default": mod };
45
+ };
46
+ Object.defineProperty(exports, "__esModule", { value: true });
47
+ exports.HaloEngine = exports.ETHICAL_RULES = exports.COPPA_RULES = exports.treeSitterParser = exports.TreeSitterParser = void 0;
48
+ exports.loadRulesFromYAML = loadRulesFromYAML;
49
+ exports.parseHaloignore = parseHaloignore;
50
+ exports.shouldIgnoreFile = shouldIgnoreFile;
51
+ exports.shouldIgnoreViolation = shouldIgnoreViolation;
52
+ const fs = __importStar(require("fs"));
53
+ const tree_sitter_1 = __importDefault(require("tree-sitter"));
54
+ const tree_sitter_typescript_1 = __importDefault(require("tree-sitter-typescript"));
55
+ const tree_sitter_javascript_1 = __importDefault(require("tree-sitter-javascript"));
56
+ const yaml = __importStar(require("js-yaml"));
57
+ // YAML Rule Loader - Load rules from coppa-tier-1.yaml
58
+ function loadRulesFromYAML(yamlPath) {
59
+ try {
60
+ const yamlContent = fs.readFileSync(yamlPath, 'utf-8');
61
+ const config = yaml.load(yamlContent);
62
+ if (!config?.rules) {
63
+ console.warn(`No rules found in YAML file: ${yamlPath}`);
64
+ return [];
65
+ }
66
+ return config.rules.map((ruleConfig) => {
67
+ const metadata = ruleConfig.metadata || {};
68
+ const patterns = (ruleConfig.patterns || []).map((p) => {
69
+ return new RegExp(p.regex.pattern, p.regex.flags || 'g');
70
+ });
71
+ return {
72
+ id: metadata.id || '',
73
+ name: metadata.name || '',
74
+ severity: metadata.severity || 'medium',
75
+ description: metadata.coppaSection || '',
76
+ patterns,
77
+ fixSuggestion: ruleConfig.autoFix?.description || '',
78
+ penalty: metadata.penaltyRange || '',
79
+ languages: metadata.languages || []
80
+ };
81
+ });
82
+ }
83
+ catch (error) {
84
+ console.error(`Error loading YAML rules from ${yamlPath}:`, error);
85
+ return [];
86
+ }
87
+ }
88
+ // Tree-sitter Parser for AST analysis
89
+ class TreeSitterParser {
90
+ constructor() {
91
+ this.parser = new tree_sitter_1.default();
92
+ }
93
+ /**
94
+ * Initialize parser with language
95
+ */
96
+ initialize(language) {
97
+ if (language === 'typescript') {
98
+ this.parser.setLanguage(tree_sitter_typescript_1.default.typescript);
99
+ }
100
+ else {
101
+ this.parser.setLanguage(tree_sitter_javascript_1.default);
102
+ }
103
+ }
104
+ /**
105
+ * Parse code and return AST
106
+ */
107
+ parse(code, language = 'typescript') {
108
+ this.initialize(language);
109
+ return this.parser.parse(code);
110
+ }
111
+ /**
112
+ * Find all function calls matching a name pattern
113
+ */
114
+ findFunctionCalls(code, functionName) {
115
+ const results = [];
116
+ const tree = this.parse(code);
117
+ const walk = (node) => {
118
+ // Check for call_expression nodes
119
+ if (node.type === 'call_expression') {
120
+ const funcNode = node.child(0);
121
+ if (funcNode && funcNode.text === functionName) {
122
+ const startPosition = node.startPosition;
123
+ results.push({
124
+ line: startPosition.row + 1,
125
+ column: startPosition.column + 1
126
+ });
127
+ }
128
+ }
129
+ if (node.children) {
130
+ node.children.forEach(walk);
131
+ }
132
+ };
133
+ walk(tree.rootNode);
134
+ return results;
135
+ }
136
+ /**
137
+ * Extract all identifiers from code (for pattern matching)
138
+ */
139
+ extractIdentifiers(code) {
140
+ const identifiers = [];
141
+ const tree = this.parse(code);
142
+ const walk = (node) => {
143
+ if (node.type === 'identifier') {
144
+ identifiers.push(node.text);
145
+ }
146
+ if (node.children) {
147
+ node.children.forEach(walk);
148
+ }
149
+ };
150
+ walk(tree.rootNode);
151
+ return identifiers;
152
+ }
153
+ }
154
+ exports.TreeSitterParser = TreeSitterParser;
155
+ // Default tree-sitter parser instance
156
+ exports.treeSitterParser = new TreeSitterParser();
157
+ // Parse suppression comments from content
158
+ function parseSuppressions(content) {
159
+ const suppressions = new Map();
160
+ const lines = content.split('\n');
161
+ const pattern = /\/\/\s*halo-ignore(?::\s*([\w-]+(?:\s*,\s*[\w-]+)*))?/gi;
162
+ lines.forEach((line, index) => {
163
+ let match;
164
+ while ((match = pattern.exec(line)) !== null) {
165
+ const ruleIds = match[1] ? match[1].split(',').map((id) => id.trim()) : null;
166
+ suppressions.set(index + 1, ruleIds ? ruleIds.join(',') : 'all');
167
+ }
168
+ });
169
+ return suppressions;
170
+ }
171
+ // Check if a violation is suppressed
172
+ function isViolationSuppressed(violation, suppressions, globalSuppressions) {
173
+ // Check same-line suppression (comment on same line as violation)
174
+ const sameLineSuppression = suppressions.get(violation.line);
175
+ if (sameLineSuppression) {
176
+ if (sameLineSuppression === 'all' || sameLineSuppression.includes(violation.ruleId)) {
177
+ return true;
178
+ }
179
+ }
180
+ // Check next-line suppression (comment on line above violation)
181
+ const prevLineSuppression = suppressions.get(violation.line - 1);
182
+ if (prevLineSuppression) {
183
+ if (prevLineSuppression === 'all' || prevLineSuppression.includes(violation.ruleId)) {
184
+ return true;
185
+ }
186
+ }
187
+ // Check global suppression (suppression before any code)
188
+ if (globalSuppressions.has(violation.line)) {
189
+ return true;
190
+ }
191
+ return false;
192
+ }
193
+ // COPPA Rule Definitions (Tier 1 - All 20 rules)
194
+ // Rules 1-5 from Sprint 1, Rules 6-20 added in Sprint 2
195
+ exports.COPPA_RULES = [
196
+ // ========== Rules 1-5 (Sprint 1) ==========
197
+ {
198
+ id: 'coppa-auth-001',
199
+ name: 'Unverified Social Login Providers',
200
+ severity: 'critical',
201
+ description: 'Social login (Google, Facebook, Twitter) without age gating is prohibited for child-directed apps',
202
+ patterns: [
203
+ /signInWithPopup\s*\(\s*\w+\s*,\s*['"](google|facebook|twitter|github)['"]/gi,
204
+ /signInWithPopup\s*\(\s*['"](google|facebook|twitter|github)['"]/gi,
205
+ /signInWithPopup\s*\(\s*\w+\s*,\s*\w+\s*\)/gi,
206
+ /firebase\.auth\(\)\s*\.\s*signInWithPopup/gi,
207
+ /passport\.authenticate\s*\(\s*['"](google|facebook|twitter)['"]/gi
208
+ ],
209
+ fixSuggestion: 'Wrap the auth call in a conditional check for user.age >= 13 or use signInWithParentEmail() for children',
210
+ penalty: '$51,744 per violation',
211
+ languages: ['typescript', 'javascript', 'python', 'swift']
212
+ },
213
+ {
214
+ id: 'coppa-data-002',
215
+ name: 'PII Collection in URL Parameters',
216
+ severity: 'high',
217
+ description: 'Email, name, DOB, or phone in GET request URLs exposes PII in logs',
218
+ patterns: [
219
+ /(\?|&)(email|first_?name|last_?name|dob|phone|birthdate)=/gi,
220
+ /axios\.get\s*\(\s*[`'"]https?:\/\/[^\s]*\?[^`'"]*\$\{/gi,
221
+ /fetch\s*\(\s*[`'"]https?:\/\/[^\s]*\?[^`'"]*\$\{/gi,
222
+ /\?[^'"`\s]*\$\{[^}]*(?:\.email|\.firstName|\.lastName|\.dob|\.phone)[^}]*\}/gi
223
+ ],
224
+ fixSuggestion: 'Switch to POST method and move PII to request body',
225
+ penalty: '$51,744 per violation',
226
+ languages: ['typescript', 'javascript', 'python', 'java', 'swift']
227
+ },
228
+ {
229
+ id: 'coppa-tracking-003',
230
+ name: 'Third-Party Ad Trackers',
231
+ severity: 'critical',
232
+ description: 'Facebook Pixel, Google Analytics, or other ad trackers without child_directed_treatment flag',
233
+ patterns: [
234
+ /fbq\s*\(\s*['"]init['"]/gi,
235
+ /ga\s*\(\s*['"]create['"]/gi,
236
+ /adsbygoogle/gi,
237
+ /gtag\s*\(\s*['"]config['"]/gi,
238
+ /google-analytics\.com\/analytics\.js/gi
239
+ ],
240
+ fixSuggestion: 'Add "child_directed_treatment": true or "restrictDataProcessing": true to SDK initialization',
241
+ penalty: '$51,744 per violation',
242
+ languages: ['typescript', 'javascript', 'html']
243
+ },
244
+ {
245
+ id: 'coppa-geo-004',
246
+ name: 'Precise Geolocation Collection',
247
+ severity: 'high',
248
+ description: 'High-accuracy geolocation without parental consent is prohibited',
249
+ patterns: [
250
+ /navigator\.geolocation\.getCurrentPosition/gi,
251
+ /navigator\.geolocation\.watchPosition/gi,
252
+ /CLLocationManager\.startUpdatingLocation\(\)/gi,
253
+ /locationServices\.requestLocation/gi
254
+ ],
255
+ fixSuggestion: 'Downgrade accuracy to kCLLocationAccuracyThreeKilometers or require parental consent',
256
+ penalty: '$51,744 per violation',
257
+ languages: ['typescript', 'javascript', 'swift', 'kotlin']
258
+ },
259
+ {
260
+ id: 'coppa-retention-005',
261
+ name: 'Missing Data Retention Policy',
262
+ severity: 'medium',
263
+ description: 'User schemas must have deleted_at, expiration_date, or TTL index for data retention',
264
+ patterns: [
265
+ /new\s+Schema\s*\(\s*\{[^{}]*\}/gi
266
+ ],
267
+ fixSuggestion: 'Add deleted_at column, expiration_date field, or TTL index to database schema',
268
+ penalty: 'Regulatory audit failure',
269
+ languages: ['typescript', 'javascript', 'python', 'sql', 'swift']
270
+ },
271
+ // ========== Rules 6-20 (Sprint 2) ==========
272
+ // Rule 6: Unencrypted PII Transmission
273
+ {
274
+ id: 'coppa-sec-006',
275
+ name: 'Unencrypted PII Transmission',
276
+ severity: 'critical',
277
+ description: 'HTTP transmission of PII exposes data in transit. All API endpoints handling personal information must use HTTPS.',
278
+ patterns: [
279
+ /http:\/\/[^\s]*(\/api\/|\/login|\/user|\/register|\/profile)/gi,
280
+ /http:\/\/localhost:[^\s]*(\/api\/)/gi,
281
+ /axios\.get\s*\(\s*['"]http:\/\//gi,
282
+ /fetch\s*\(\s*['"]http:\/\//gi,
283
+ /http:\/\/[^\s]*email[^\s]*/gi
284
+ ],
285
+ fixSuggestion: 'Replace http:// with https:// for all API endpoints and resources',
286
+ penalty: 'Security breach liability + COPPA penalties',
287
+ languages: ['typescript', 'javascript', 'python', 'java', 'swift']
288
+ },
289
+ // Rule 7: Passive Audio Recording
290
+ // Fixed Sprint 4: Skip audio:false, skip AudioContext (playback only), skip import-only
291
+ {
292
+ id: 'coppa-audio-007',
293
+ name: 'Unauthorized Audio Recording',
294
+ severity: 'high',
295
+ description: 'Audio recording without explicit user consent is prohibited. COPPA 2.0 clarifies voice prints as biometric data.',
296
+ patterns: [
297
+ /getUserMedia\s*\(\s*\{[^}]*audio\s*:\s*true[^}]*\}/gi,
298
+ /getUserMedia\s*\(\s*\{\s*audio\s*\}/gi,
299
+ /getUserMedia\s*\(\s*\{\s*audio\s*,/gi,
300
+ /AVAudioSession\s*\.\s*sharedInstance/gi,
301
+ /AVAudioRecorder\s*\(/gi,
302
+ /new\s+AudioRecord\s*\(/gi,
303
+ /new\s+MediaRecorder\s*\(/gi
304
+ ],
305
+ fixSuggestion: 'Wrap audio recording in click handler and add parental consent check',
306
+ penalty: '$51,744 per violation',
307
+ languages: ['typescript', 'javascript', 'swift', 'kotlin']
308
+ },
309
+ // Rule 8: Missing Privacy Policy Link
310
+ // Fixed Sprint 4: Only flag forms with registration-related fields (email, password, name, DOB)
311
+ // Fixed Phase B: Tightened regex — word boundary after "Form" prevents registerFormat/registerOption FPs
312
+ {
313
+ id: 'coppa-ui-008',
314
+ name: 'Missing Privacy Policy on Registration',
315
+ severity: 'medium',
316
+ description: 'Registration forms collecting PII must include a clear link to the privacy policy',
317
+ patterns: [
318
+ // PascalCase component names: SignUpForm, RegisterForm, RegistrationForm, CreateAccountForm
319
+ // \b after Form prevents matching registerFormat, registerFormats, etc.
320
+ /\b(?:SignUp|Register|Registration|CreateAccount)Form\b/gi,
321
+ // kebab-case / snake_case: sign-up-form, register_form, create-account-form
322
+ /\b(?:sign[-_]?up|register|registration|create[-_]?account)[-_]form\b/gi,
323
+ // HTML form elements with registration-related ids/classes
324
+ /<form[^>]*(?:id|class|name)\s*=\s*["'][^"']*(?:register|signup|sign[-_]up|create[-_]account)[^"']*["']/gi
325
+ ],
326
+ fixSuggestion: 'Add <a href="/privacy">Privacy Policy</a> link to registration form footer',
327
+ penalty: 'Compliance failure',
328
+ languages: ['typescript', 'javascript', 'html', 'tsx', 'jsx', 'php']
329
+ },
330
+ // Rule 9: Contact Info Collection Without Parent Email
331
+ {
332
+ id: 'coppa-flow-009',
333
+ name: 'Direct Contact Collection Without Parent Context',
334
+ severity: 'high',
335
+ description: 'Forms collecting child email/phone must also require parent email for consent verification',
336
+ patterns: [
337
+ /(child_email|student_email)\s*:\s*String/gi,
338
+ /(child_email|student_email|kid_email)\s*=/gi
339
+ ],
340
+ fixSuggestion: 'Make parent_email required when collecting child contact information',
341
+ penalty: '$51,744 per violation',
342
+ languages: ['typescript', 'javascript', 'python']
343
+ },
344
+ // Rule 10: Insecure Default Passwords
345
+ {
346
+ id: 'coppa-sec-010',
347
+ name: 'Weak Default Student Passwords',
348
+ severity: 'medium',
349
+ description: 'Default passwords like "password", "123456", or "changeme" create security vulnerabilities',
350
+ patterns: [
351
+ /(password|default_pass|temp_password)\s*=\s*['"](123456|password|changeme|student|welcome)['"]/gi,
352
+ /defaultPassword:\s*['"](123456|password|changeme)['"]/gi,
353
+ /initialPassword:\s*['"](123456|password)['"]/gi,
354
+ /pass\s*=\s*['"](student123|child123|default)['"]/gi
355
+ ],
356
+ fixSuggestion: 'Use a secure random string generator for temporary credentials',
357
+ penalty: 'Security audit failure',
358
+ languages: ['typescript', 'javascript', 'python', 'java', 'swift']
359
+ },
360
+ // Rule 11: Third-Party Chat Widgets
361
+ {
362
+ id: 'coppa-ext-011',
363
+ name: 'Unmoderated Third-Party Chat',
364
+ severity: 'high',
365
+ description: 'Third-party chat widgets (Intercom, Zendesk, Drift) allow children to disclose PII freely',
366
+ patterns: [
367
+ /intercom\.init/gi,
368
+ /zendesk\.init/gi,
369
+ /drift\.init/gi,
370
+ /<script[^>]+src=['"][^'"]*intercom/gi,
371
+ /<script[^>]+src=['"][^'"]*(zendesk|zdassets)/gi,
372
+ /Freshdesk|FreshChat/gi
373
+ ],
374
+ fixSuggestion: 'Disable chat widget for unauthenticated or under-13 users via conditional rendering',
375
+ penalty: '$51,744 per violation',
376
+ languages: ['typescript', 'javascript', 'html']
377
+ },
378
+ // Rule 12: Biometric Data Collection
379
+ {
380
+ id: 'coppa-bio-012',
381
+ name: 'Biometric Data Collection',
382
+ severity: 'critical',
383
+ description: 'Face recognition, voice prints, or gait analysis requires explicit parental consent. COPPA 2.0 explicitly classifies biometrics as PI.',
384
+ patterns: [
385
+ /(?:import\s+.*from\s+['"]face-api\.js['"]|require\s*\(\s*['"]face-api\.js['"]\s*\))/gi,
386
+ /LocalAuthentication.*evaluatePolicy/gi,
387
+ /FaceID|TouchID/gi,
388
+ /biometricAuth|BiometricAuth/g,
389
+ /voicePrint|VoicePrint/g,
390
+ /livenessCheck|LivenessCheck/g,
391
+ /FaceMatcher|FaceDetector|FaceRecognizer/g
392
+ ],
393
+ fixSuggestion: 'Ensure biometric data remains local-only (on-device) or obtain verifiable parental consent',
394
+ penalty: '$51,744 per violation',
395
+ languages: ['typescript', 'javascript', 'swift', 'kotlin']
396
+ },
397
+ // Rule 13: Push Notifications to Children
398
+ {
399
+ id: 'coppa-notif-013',
400
+ name: 'Direct Push Notifications Without Consent',
401
+ severity: 'medium',
402
+ description: 'Push notifications are "Online Contact Info" under COPPA 2.0. Direct notifications to children require parental consent.',
403
+ patterns: [
404
+ /FirebaseMessaging\.subscribeToTopic/gi,
405
+ /OneSignal\.promptForPushNotifications/gi,
406
+ /sendPushNotification\s*\(/gi,
407
+ /fcm\.send\s*\(/gi,
408
+ /PushManager\.subscribe\s*\(/gi,
409
+ /Notification\.requestPermission/gi,
410
+ /new\s+Notification\s*\(/gi
411
+ ],
412
+ fixSuggestion: 'Gate push notification subscription behind parental dashboard setting',
413
+ penalty: '$51,744 per violation',
414
+ languages: ['typescript', 'javascript', 'swift', 'kotlin']
415
+ },
416
+ // Rule 14: Unfiltered User Generated Content
417
+ {
418
+ id: 'coppa-ugc-014',
419
+ name: 'UGC Upload Without PII Filter',
420
+ severity: 'high',
421
+ description: 'Text areas for "bio", "about me", or comments must pass through PII scrubbing before database storage',
422
+ patterns: [
423
+ /<textarea[^>]*placeholder=["'](?:bio|about me|describe yourself)[^"']*["']/gi,
424
+ /user\.bio\s*=/gi,
425
+ /aboutMe\s*=/gi,
426
+ /(?:submit|save|post)Comment\s*\(/gi,
427
+ /saveBio\s*\(|updateBio\s*\(/gi,
428
+ /commentForm.*submit|handleCommentSubmit/gi
429
+ ],
430
+ fixSuggestion: 'Add middleware hook for PII scrubbing (regex or AWS Comprehend) before database storage',
431
+ penalty: '$51,744 per violation',
432
+ languages: ['typescript', 'javascript', 'python']
433
+ },
434
+ // Rule 15: XSS Vulnerabilities
435
+ // Fixed Sprint 4: Skip innerHTML='', localization, CSS injection, and self-clearing patterns
436
+ {
437
+ id: 'coppa-sec-015',
438
+ name: 'Reflected XSS Risk',
439
+ severity: 'medium',
440
+ description: 'DangerouslySetInnerHTML or innerHTML with user-controlled content creates XSS vulnerabilities',
441
+ patterns: [
442
+ /dangerouslySetInnerHTML\s*=\s*\{\s*\{\s*__html\s*:\s*(?!['"]<)[^}]*\}\s*\}/gi,
443
+ /\.innerHTML\s*=\s*\$\{/gi,
444
+ /\.innerHTML\s*=\s*(?!['"]?\s*['"]?\s*;)(?!.*[Ll]ocal(?:ize|ization))(?!.*styleContent)[^;]*\b(?:user|input|query|param|req\.|request\.|body\.|data\.)\w*/gi,
445
+ /\.html\s*\(\s*(?:user|req\.|request\.|params?\.)/gi,
446
+ /v-html\s*=\s*["']?(?!.*sanitize)/gi
447
+ ],
448
+ fixSuggestion: 'Use standard JSX rendering or DOMPurify before setting HTML content',
449
+ penalty: 'Security failure',
450
+ languages: ['typescript', 'javascript', 'tsx', 'jsx', 'vue']
451
+ },
452
+ // Rule 16: Missing Cookie Consent
453
+ // Fixed Sprint 4: Only flag tracking/PII cookies, not functional preferences (theme, view mode)
454
+ {
455
+ id: 'coppa-cookies-016',
456
+ name: 'Missing Cookie Notice',
457
+ severity: 'low',
458
+ description: 'Cookies or localStorage storing tracking data or PII requires a consent banner',
459
+ patterns: [
460
+ /document\.cookie\s*=\s*[^;]*(?:user|email|name|token|session|track|id|uid|analytics)/gi,
461
+ /localStorage\.setItem\s*\(\s*['"][^'"]*(?:user|email|token|session|track|auth|login|id|uid|analytics)[^'"]*['"]/gi,
462
+ /sessionStorage\.setItem\s*\(\s*['"][^'"]*(?:user|email|token|session|track|auth|login|id|uid|analytics)[^'"]*['"]/gi
463
+ ],
464
+ fixSuggestion: 'Add a cookie consent banner component before setting tracking or PII cookies',
465
+ penalty: 'Compliance warning',
466
+ languages: ['typescript', 'javascript']
467
+ },
468
+ // Rule 17: External Links to Non-Child-Safe Sites
469
+ // Fixed Sprint 4: Exclude privacy/TOS links, mailto, and common safe targets
470
+ {
471
+ id: 'coppa-ext-017',
472
+ name: 'Unwarned External Links',
473
+ severity: 'medium',
474
+ description: 'External links in child-facing views should trigger a "You are leaving..." modal',
475
+ patterns: [
476
+ /<a[^>]+href=["']https?:\/\/(?!.*(?:privacy|terms|legal|tos|policy|consent|support|help|docs|documentation))[^"']+["'][^>]*target=["']_blank["'][^>]*>/gi,
477
+ /window\.open\s*\(\s*['"]https?:\/\/(?!.*(?:privacy|terms|legal|tos|policy))/gi
478
+ ],
479
+ fixSuggestion: 'Wrap external links in SafeLink component with warning modal',
480
+ penalty: 'Warning',
481
+ languages: ['typescript', 'javascript', 'html', 'tsx', 'jsx']
482
+ },
483
+ // Rule 18: Analytics User ID Mapping
484
+ {
485
+ id: 'coppa-analytics-018',
486
+ name: 'Mapping PII to Analytics User IDs',
487
+ severity: 'high',
488
+ description: 'Passing email, name, or phone to analytics.identify() exposes PII to third parties',
489
+ patterns: [
490
+ /analytics\.identify\s*\([^)]*email/gi,
491
+ /mixpanel\.identify.*email/gi,
492
+ /segment\.identify.*email/gi,
493
+ /amplitude\.identify.*email/gi,
494
+ /identify\s*\(\s*\{[^}]*(?:email|name|phone)[^}]*\}/gi
495
+ ],
496
+ fixSuggestion: 'Hash user ID and omit email/name from analytics payload',
497
+ penalty: '$51,744 per violation',
498
+ languages: ['typescript', 'javascript']
499
+ },
500
+ // Rule 19: School Official Consent Bypass
501
+ // Fixed Sprint 4: Tightened patterns to match actual auth/registration flows only
502
+ {
503
+ id: 'coppa-edu-019',
504
+ name: 'Missing Teacher/School Verification',
505
+ severity: 'medium',
506
+ description: 'Teacher accounts using generic email (@gmail.com) bypass "School Official" consent exception',
507
+ patterns: [
508
+ /(?:teacher|educator)(?:Sign[Uu]p|[Rr]egist(?:er|ration))\s*(?:\(|=|:)/gi,
509
+ /createTeacherAccount|registerTeacher|teacherAuth/gi,
510
+ /role\s*(?:=|:)\s*['"]teacher['"].*(?:@gmail|@yahoo|@hotmail)/gi,
511
+ /isTeacher\s*&&\s*!.*\.edu/gi
512
+ ],
513
+ fixSuggestion: 'Restrict teacher sign-ups to verified EDU domains or require manual approval',
514
+ penalty: 'Loss of School Official consent status',
515
+ languages: ['typescript', 'javascript', 'python']
516
+ },
517
+ // Rule 20: Default Privacy Settings Public
518
+ {
519
+ id: 'coppa-default-020',
520
+ name: 'Default Public Profile Visibility',
521
+ severity: 'critical',
522
+ description: 'Default profile visibility must be private. COPPA 2.0 requires privacy by design.',
523
+ patterns: [
524
+ /isProfileVisible:\s*true/gi,
525
+ /visibility:\s*['"]public['"]/gi,
526
+ /defaultPrivacy:\s*['"]public['"]/gi,
527
+ /isPublic:\s*true[^}]*(profile|User)/gi,
528
+ /profileVisibility\s*=\s*['"]?(?:public|Public)['"]?/gi
529
+ ],
530
+ fixSuggestion: 'Change default visibility to "private" or false',
531
+ penalty: '$51,744 per violation',
532
+ languages: ['typescript', 'javascript', 'python', 'swift']
533
+ }
534
+ ];
535
+ // Ethical Design Rules (Sprint 5 Preview)
536
+ exports.ETHICAL_RULES = [
537
+ // ETHICAL-001: Infinite Scroll
538
+ {
539
+ id: 'ETHICAL-001',
540
+ name: 'Infinite Scroll / Endless Feed',
541
+ severity: 'high',
542
+ description: 'Infinite scroll exploits lack of impulse control. Children spend 2-3x longer on infinite feeds.',
543
+ patterns: [
544
+ /IntersectionObserver.*isIntersecting.*loadMore/gi,
545
+ /window\.addEventListener.*['"]scroll['"].*fetchNext/gi,
546
+ /import.*InfiniteScroll/gi,
547
+ /<InfiniteScroll/gi,
548
+ /ngx-infinite-scroll/gi,
549
+ /vue-infinite-loading/gi
550
+ ],
551
+ fixSuggestion: 'Replace infinite scroll with pagination or "Load More" buttons to create natural stopping points',
552
+ penalty: 'Ethical Design Violation',
553
+ languages: ['typescript', 'javascript', 'tsx', 'jsx', 'vue']
554
+ },
555
+ // ETHICAL-002: Streak Pressure
556
+ {
557
+ id: 'ETHICAL-002',
558
+ name: 'Streak Pressure Mechanics',
559
+ severity: 'high',
560
+ description: 'Streak mechanics use loss aversion to manufacture daily compulsive usage',
561
+ patterns: [
562
+ /streak.*>.*0.*loss/gi,
563
+ /lose.*your.*streak/gi,
564
+ /streak.*ends.*in/gi,
565
+ /don'?t.*break.*streak/gi,
566
+ /dailyStreak/gi,
567
+ /consecutiveDays/gi
568
+ ],
569
+ fixSuggestion: 'Remove streak loss penalties. Frame progress as cumulative (e.g., "15 days practiced") rather than consecutive.',
570
+ penalty: 'Ethical Design Violation',
571
+ languages: ['typescript', 'javascript', 'swift', 'kotlin']
572
+ },
573
+ // ETHICAL-003: Variable Rewards (Loot Boxes)
574
+ {
575
+ id: 'ETHICAL-003',
576
+ name: 'Variable Ratio Rewards (Loot Boxes)',
577
+ severity: 'critical',
578
+ description: 'Randomized rewards (loot boxes, gacha) exploit gambling psychology in developing brains',
579
+ patterns: [
580
+ /Math\.random\(\).*reward/gi,
581
+ /loot_?box/gi,
582
+ /gacha/gi,
583
+ /mystery_?crate/gi,
584
+ /openCrate/gi,
585
+ /rarityTable/gi,
586
+ /dropRates/gi
587
+ ],
588
+ fixSuggestion: 'Replace random rewards with transparent, effort-based rewards. Disclosure of odds is a minimum requirement.',
589
+ penalty: 'Ethical Design Violation',
590
+ languages: ['typescript', 'javascript', 'python', 'csharp']
591
+ },
592
+ // ETHICAL-004: Manipulative Notifications
593
+ {
594
+ id: 'ETHICAL-004',
595
+ name: 'Manipulative Notification Language',
596
+ severity: 'medium',
597
+ description: 'Notifications using urgency ("Hurry!", "Missing out") manipulate children\'s fear of social exclusion',
598
+ patterns: [
599
+ /hurry|limited\s*time|missing\s*out|don'?t\s*miss|left\s*behind/gi,
600
+ /everyone\s*else\s*is/gi,
601
+ /last\s*chance/gi,
602
+ /running\s*out/gi
603
+ ],
604
+ fixSuggestion: 'Use neutral, informational language. Avoid FOMO (Fear Of Missing Out) triggers.',
605
+ penalty: 'Ethical Design Violation',
606
+ languages: ['typescript', 'javascript', 'json', 'xml']
607
+ },
608
+ // ETHICAL-005: Artificial Scarcity
609
+ {
610
+ id: 'ETHICAL-005',
611
+ name: 'Artificial Scarcity / Countdowns',
612
+ severity: 'medium',
613
+ description: 'Fake scarcity ("Only 2 left!") and countdown timers pressure children into impulsive decisions',
614
+ patterns: [
615
+ /CountdownTimer/gi,
616
+ /only\s*\d+\s*left/gi,
617
+ /offer\s*ends\s*in/gi,
618
+ /selling\s*fast/gi,
619
+ /almost\s*sold\s*out/gi,
620
+ /limited\s*availability/gi
621
+ ],
622
+ fixSuggestion: 'Remove artificial urgency. If an offer expires, state the date calmly without countdown pressure.',
623
+ penalty: 'Ethical Design Violation',
624
+ languages: ['typescript', 'javascript', 'tsx', 'jsx', 'html']
625
+ }
626
+ ];
627
+ /**
628
+ * Parse a .haloignore file content
629
+ *
630
+ * Format:
631
+ * # comment
632
+ * path/to/file.ts — ignore entire file
633
+ * **\/*.test.ts — glob pattern to ignore files
634
+ * rule:coppa-auth-001 — globally suppress a rule
635
+ * src/auth.ts:coppa-auth-001 — suppress rule in specific file
636
+ */
637
+ function parseHaloignore(content) {
638
+ const config = {
639
+ ignoredFiles: [],
640
+ globalRuleSuppressions: new Set(),
641
+ fileRuleSuppressions: new Map()
642
+ };
643
+ const lines = content.split('\n');
644
+ for (const raw of lines) {
645
+ const line = raw.trim();
646
+ if (!line || line.startsWith('#'))
647
+ continue;
648
+ if (line.startsWith('rule:')) {
649
+ // Global rule suppression: rule:coppa-auth-001
650
+ const ruleId = line.slice(5).trim();
651
+ if (ruleId)
652
+ config.globalRuleSuppressions.add(ruleId);
653
+ }
654
+ else if (line.includes(':coppa-')) {
655
+ // Per-file rule suppression: src/auth.ts:coppa-auth-001
656
+ const colonIdx = line.indexOf(':coppa-');
657
+ const filePath = line.slice(0, colonIdx).trim();
658
+ const ruleId = line.slice(colonIdx + 1).trim();
659
+ if (filePath && ruleId) {
660
+ if (!config.fileRuleSuppressions.has(filePath)) {
661
+ config.fileRuleSuppressions.set(filePath, new Set());
662
+ }
663
+ config.fileRuleSuppressions.get(filePath).add(ruleId);
664
+ }
665
+ }
666
+ else {
667
+ // File glob pattern
668
+ config.ignoredFiles.push(line);
669
+ }
670
+ }
671
+ return config;
672
+ }
673
+ /**
674
+ * Check if a file should be ignored based on .haloignore config
675
+ */
676
+ function shouldIgnoreFile(filePath, config) {
677
+ const normalized = filePath.replace(/\\/g, '/');
678
+ for (const pattern of config.ignoredFiles) {
679
+ if (minimatch(normalized, pattern))
680
+ return true;
681
+ }
682
+ return false;
683
+ }
684
+ /**
685
+ * Check if a violation should be ignored based on .haloignore config
686
+ */
687
+ function shouldIgnoreViolation(violation, config) {
688
+ // Global rule suppression
689
+ if (config.globalRuleSuppressions.has(violation.ruleId))
690
+ return true;
691
+ // Per-file rule suppression
692
+ const normalized = violation.filePath.replace(/\\/g, '/');
693
+ const fileSuppressions = config.fileRuleSuppressions.get(normalized);
694
+ if (fileSuppressions?.has(violation.ruleId))
695
+ return true;
696
+ return false;
697
+ }
698
+ /**
699
+ * Simple glob matching (supports * and **)
700
+ */
701
+ function minimatch(filePath, pattern) {
702
+ // Convert glob to regex
703
+ const regexStr = pattern
704
+ .replace(/\./g, '\\.')
705
+ .replace(/\*\*/g, '{{GLOBSTAR}}')
706
+ .replace(/\*/g, '[^/]*')
707
+ .replace(/\{\{GLOBSTAR\}\}/g, '.*');
708
+ const regex = new RegExp(`^${regexStr}$`);
709
+ return regex.test(filePath);
710
+ }
711
+ // Engine class
712
+ class HaloEngine {
713
+ constructor(config = {}) {
714
+ this.config = config;
715
+ this.treeSitter = new TreeSitterParser();
716
+ // Load rules - first try YAML if path provided, otherwise use hardcoded rules
717
+ let loadedRules = [];
718
+ if (config.rulesPath) {
719
+ // Load from YAML file
720
+ loadedRules = loadRulesFromYAML(config.rulesPath);
721
+ }
722
+ // If no YAML rules loaded, fall back to hardcoded COPPA_RULES
723
+ this.rules = loadedRules.length > 0
724
+ ? loadedRules
725
+ : (config.rules
726
+ ? exports.COPPA_RULES.filter(r => config.rules.includes(r.id))
727
+ : exports.COPPA_RULES);
728
+ // Add Ethical Design Rules if enabled (Sprint 5 Preview)
729
+ if (config.ethical) {
730
+ this.rules = [...this.rules, ...exports.ETHICAL_RULES];
731
+ }
732
+ if (config.severityFilter) {
733
+ this.rules = this.rules.filter(r => config.severityFilter.includes(r.severity));
734
+ }
735
+ }
736
+ /**
737
+ * Get the tree-sitter parser for advanced AST analysis
738
+ */
739
+ getParser() {
740
+ return this.treeSitter;
741
+ }
742
+ /**
743
+ * Scan using tree-sitter AST analysis (advanced mode)
744
+ */
745
+ scanFileWithAST(filePath, content, language = 'typescript') {
746
+ // First get regex-based violations
747
+ const violations = this.scanFile(filePath, content);
748
+ // Then enhance with AST analysis
749
+ try {
750
+ const identifiers = this.treeSitter.extractIdentifiers(content);
751
+ const functionCalls = this.treeSitter.findFunctionCalls(content, 'signInWithPopup');
752
+ // Add AST-based detection for social login
753
+ for (const call of functionCalls) {
754
+ // Check if already detected by regex
755
+ const exists = violations.some(v => v.ruleId === 'coppa-auth-001' &&
756
+ v.line === call.line);
757
+ if (!exists) {
758
+ const authRule = exports.COPPA_RULES.find(r => r.id === 'coppa-auth-001');
759
+ if (authRule) {
760
+ violations.push({
761
+ ruleId: 'coppa-auth-001',
762
+ ruleName: authRule.name,
763
+ severity: authRule.severity,
764
+ filePath,
765
+ line: call.line,
766
+ column: call.column,
767
+ message: `${authRule.name}: Detected via AST analysis`,
768
+ codeSnippet: content.split('\n')[call.line - 1]?.trim() || '',
769
+ fixSuggestion: authRule.fixSuggestion,
770
+ penalty: authRule.penalty
771
+ });
772
+ }
773
+ }
774
+ }
775
+ }
776
+ catch (error) {
777
+ // If AST parsing fails, fall back to regex-only
778
+ console.warn('AST parsing failed, using regex-only mode:', error);
779
+ }
780
+ return violations;
781
+ }
782
+ /**
783
+ * Get the ignore config (if any)
784
+ */
785
+ getIgnoreConfig() {
786
+ return this.config.ignoreConfig;
787
+ }
788
+ /**
789
+ * Scan a single file for violations
790
+ */
791
+ scanFile(filePath, content) {
792
+ // Check .haloignore — skip entire file if matched
793
+ const ignoreConfig = this.config.ignoreConfig;
794
+ if (ignoreConfig && shouldIgnoreFile(filePath, ignoreConfig)) {
795
+ return [];
796
+ }
797
+ const violations = [];
798
+ const lines = content.split('\n');
799
+ // Parse suppression comments
800
+ const suppressions = parseSuppressions(content);
801
+ // Track lines with global suppressions (at top of file)
802
+ const globalSuppressionLines = new Set();
803
+ for (const [line, rules] of suppressions.entries()) {
804
+ if (rules === 'all') {
805
+ globalSuppressionLines.add(line);
806
+ }
807
+ }
808
+ for (const rule of this.rules) {
809
+ // Special handling for coppa-retention-005: skip if schema has retention fields
810
+ if (rule.id === 'coppa-retention-005') {
811
+ // Check if the content has retention-related fields
812
+ const hasRetention = /deletedAt|deleted_at|expires|TTL|retention|paranoid|expiration/i.test(content);
813
+ if (!hasRetention) {
814
+ // Continue with normal pattern matching
815
+ }
816
+ else {
817
+ // Has retention fields - still scan but be more careful
818
+ // Only flag schemas that clearly lack retention
819
+ const schemaMatches = content.match(/new\s+Schema\s*\(\s*\{[^{}]*\}/gi);
820
+ if (schemaMatches) {
821
+ for (const schemaMatch of schemaMatches) {
822
+ // Check if this specific schema has retention
823
+ const surroundingContext = content.substring(Math.max(0, content.indexOf(schemaMatch) - 50), Math.min(content.length, content.indexOf(schemaMatch) + schemaMatch.length + 200));
824
+ if (/deletedAt|deleted_at|expires|TTL|paranoid/i.test(surroundingContext)) {
825
+ continue; // Skip this match - has retention
826
+ }
827
+ // This schema lacks retention - fall through to normal pattern matching
828
+ }
829
+ }
830
+ }
831
+ }
832
+ for (const pattern of rule.patterns) {
833
+ // Reset regex state
834
+ pattern.lastIndex = 0;
835
+ // Find all matches in content
836
+ let match;
837
+ while ((match = pattern.exec(content)) !== null) {
838
+ // Calculate line and column from match position
839
+ const beforeMatch = content.substring(0, match.index);
840
+ const lineNumber = (beforeMatch.match(/\n/g) || []).length + 1;
841
+ // Get the line content for snippet
842
+ const lineIndex = lineNumber - 1;
843
+ const lineContent = lines[lineIndex] || '';
844
+ // Calculate column
845
+ const lastNewline = beforeMatch.lastIndexOf('\n');
846
+ const column = match.index - lastNewline;
847
+ // Skip if the line is a comment (// or /* or * or <!-- or #)
848
+ const trimmedLine = lineContent.trim();
849
+ if (trimmedLine.startsWith('//') || trimmedLine.startsWith('/*') || trimmedLine.startsWith('*') || trimmedLine.startsWith('<!--') || trimmedLine.startsWith('#')) {
850
+ // Exception: don't skip halo-ignore comments (those are suppression directives)
851
+ if (!trimmedLine.includes('halo-ignore')) {
852
+ continue;
853
+ }
854
+ }
855
+ // For coppa-ext-017: skip matches that are own-domain links
856
+ // Check both match[0] (captures multi-line <a> tags) and lineContent (captures window.open)
857
+ if (rule.id === 'coppa-ext-017' && this.config.projectDomains?.length) {
858
+ const checkText = (match[0] + ' ' + lineContent).toLowerCase();
859
+ const isOwnDomain = this.config.projectDomains.some(domain => checkText.includes(domain.toLowerCase()));
860
+ if (isOwnDomain)
861
+ continue;
862
+ }
863
+ // Check if this violation already exists (avoid duplicates)
864
+ const exists = violations.some(v => v.ruleId === rule.id &&
865
+ v.line === lineNumber &&
866
+ v.filePath === filePath);
867
+ if (!exists) {
868
+ // Check suppression
869
+ const suppressed = this.config.suppressions?.enabled !== false &&
870
+ isViolationSuppressed({ ruleId: rule.id, line: lineNumber, ruleName: '', severity: 'low', filePath: '', column: 0, message: '', codeSnippet: '', fixSuggestion: '' }, suppressions, globalSuppressionLines);
871
+ // Get suppression comment if suppressed
872
+ let suppressionComment;
873
+ if (suppressed) {
874
+ suppressionComment = suppressions.get(lineNumber);
875
+ }
876
+ violations.push({
877
+ ruleId: rule.id,
878
+ ruleName: rule.name,
879
+ severity: rule.severity,
880
+ filePath,
881
+ line: lineNumber,
882
+ column: column + 1,
883
+ message: `${rule.name}: ${rule.description}`,
884
+ codeSnippet: lineContent.trim().substring(0, 100).replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''),
885
+ fixSuggestion: rule.fixSuggestion,
886
+ penalty: rule.penalty,
887
+ suppressed: suppressed || false,
888
+ suppressionComment
889
+ });
890
+ }
891
+ }
892
+ }
893
+ }
894
+ // Filter suppressed if configured
895
+ if (this.config.suppressions?.enabled !== false && !this.config.includeSuppressed) {
896
+ const unsuppressed = violations.filter(v => !v.suppressed);
897
+ // Apply .haloignore per-violation filtering
898
+ if (ignoreConfig) {
899
+ return unsuppressed.filter(v => !shouldIgnoreViolation(v, ignoreConfig));
900
+ }
901
+ return unsuppressed;
902
+ }
903
+ // Even when showing suppressed, still apply .haloignore
904
+ if (ignoreConfig) {
905
+ return violations.filter(v => !shouldIgnoreViolation(v, ignoreConfig));
906
+ }
907
+ return violations;
908
+ }
909
+ /**
910
+ * Get all rules
911
+ */
912
+ getRules() {
913
+ return this.rules;
914
+ }
915
+ /**
916
+ * Get rule by ID
917
+ */
918
+ getRule(ruleId) {
919
+ return exports.COPPA_RULES.find(r => r.id === ruleId);
920
+ }
921
+ /**
922
+ * Explain a rule (for MCP)
923
+ */
924
+ explainRule(ruleId) {
925
+ const rule = this.getRule(ruleId);
926
+ if (!rule) {
927
+ return `Rule ${ruleId} not found.`;
928
+ }
929
+ return `
930
+ Rule: ${rule.id}
931
+ Name: ${rule.name}
932
+ Severity: ${rule.severity.toUpperCase()}
933
+
934
+ Description: ${rule.description}
935
+
936
+ COPPA Reference: ${rule.penalty}
937
+
938
+ Fix Suggestion: ${rule.fixSuggestion}
939
+
940
+ Supported Languages: ${rule.languages.join(', ')}
941
+ `.trim();
942
+ }
943
+ /**
944
+ * Get fix suggestion for a rule (for MCP)
945
+ */
946
+ getFixSuggestion(ruleId) {
947
+ const rule = this.getRule(ruleId);
948
+ if (!rule) {
949
+ return `Rule ${ruleId} not found.`;
950
+ }
951
+ return rule.fixSuggestion;
952
+ }
953
+ }
954
+ exports.HaloEngine = HaloEngine;
955
+ exports.default = HaloEngine;
956
+ //# sourceMappingURL=index.js.map