@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.d.ts +154 -0
- package/dist/index.js +956 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -0
- package/rules/coppa-tier-1.yaml +203 -0
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
|