@runhalo/engine 0.4.0 → 0.5.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/ast-engine.d.ts +60 -0
- package/dist/ast-engine.js +653 -0
- package/dist/ast-engine.js.map +1 -0
- package/dist/context-analyzer.d.ts +209 -0
- package/dist/context-analyzer.js +401 -0
- package/dist/context-analyzer.js.map +1 -0
- package/dist/data-flow-tracer.d.ts +106 -0
- package/dist/data-flow-tracer.js +506 -0
- package/dist/data-flow-tracer.js.map +1 -0
- package/dist/frameworks/django.d.ts +11 -0
- package/dist/frameworks/django.js +57 -0
- package/dist/frameworks/django.js.map +1 -0
- package/dist/frameworks/index.d.ts +59 -0
- package/dist/frameworks/index.js +93 -0
- package/dist/frameworks/index.js.map +1 -0
- package/dist/frameworks/nextjs.d.ts +11 -0
- package/dist/frameworks/nextjs.js +59 -0
- package/dist/frameworks/nextjs.js.map +1 -0
- package/dist/frameworks/rails.d.ts +11 -0
- package/dist/frameworks/rails.js +58 -0
- package/dist/frameworks/rails.js.map +1 -0
- package/dist/frameworks/types.d.ts +29 -0
- package/dist/frameworks/types.js +11 -0
- package/dist/frameworks/types.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +105 -7
- package/dist/index.js.map +1 -1
- package/dist/scope-analyzer.d.ts +91 -0
- package/dist/scope-analyzer.js +300 -0
- package/dist/scope-analyzer.js.map +1 -0
- package/package.json +6 -2
- package/rules/rules.json +1699 -72
- package/rules/validation-report.json +58 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Halo AST Rule Engine
|
|
4
|
+
*
|
|
5
|
+
* Takes a parsed tree-sitter AST + rule ID and returns an AST-based verdict
|
|
6
|
+
* that supplements the regex scanner. AST analysis can suppress false positives
|
|
7
|
+
* (e.g., a Schema that already has TTL) or confirm true positives with higher
|
|
8
|
+
* confidence.
|
|
9
|
+
*
|
|
10
|
+
* Sprint 8: 10 rule analyzers for JS/TS.
|
|
11
|
+
* HARD SCOPE: Single-file only (via DataFlowTracer).
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.ASTRuleEngine = void 0;
|
|
15
|
+
const scope_analyzer_1 = require("./scope-analyzer");
|
|
16
|
+
const data_flow_tracer_1 = require("./data-flow-tracer");
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// AST walker helper
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
function walk(node, visitor) {
|
|
21
|
+
if (!node)
|
|
22
|
+
return;
|
|
23
|
+
visitor(node);
|
|
24
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
25
|
+
walk(node.child(i), visitor);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// ASTRuleEngine
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
class ASTRuleEngine {
|
|
32
|
+
constructor() {
|
|
33
|
+
this.scopeAnalyzer = new scope_analyzer_1.ScopeAnalyzer();
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Analyze a regex-detected violation using AST context.
|
|
37
|
+
*
|
|
38
|
+
* @param ruleId - The COPPA/ethical rule ID
|
|
39
|
+
* @param content - Full file content
|
|
40
|
+
* @param violation - The violation from the regex scanner
|
|
41
|
+
* @param tree - Parsed tree-sitter AST
|
|
42
|
+
* @returns ASTResult with verdict, confidence, and reason
|
|
43
|
+
*/
|
|
44
|
+
analyzeViolation(ruleId, content, violation, tree) {
|
|
45
|
+
// First check scope-level suppressions that apply to all rules
|
|
46
|
+
const scopeContext = this.scopeAnalyzer.analyzeFile(violation.codeSnippet, // We don't have filePath here, but snippet gives some hint
|
|
47
|
+
content, tree);
|
|
48
|
+
// Type definition files should suppress most violations
|
|
49
|
+
if (scopeContext.isTypeDefinition) {
|
|
50
|
+
return {
|
|
51
|
+
verdict: 'suppressed',
|
|
52
|
+
confidence: 0.95,
|
|
53
|
+
reason: 'File is primarily type definitions — no runtime behavior',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
// Test files get suppressed for most rules (test code isn't deployed)
|
|
57
|
+
if (scopeContext.isTestFile) {
|
|
58
|
+
return {
|
|
59
|
+
verdict: 'suppressed',
|
|
60
|
+
confidence: 0.90,
|
|
61
|
+
reason: 'Violation is in a test file — not deployed to production',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// Route to rule-specific analyzer
|
|
65
|
+
const analyzer = this.getAnalyzer(ruleId);
|
|
66
|
+
if (!analyzer) {
|
|
67
|
+
return { verdict: 'regex_only', confidence: 0, reason: 'No AST analyzer for this rule' };
|
|
68
|
+
}
|
|
69
|
+
return analyzer(tree, content, violation, scopeContext);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Analyze a violation with a known file path (used from scanFileWithAST integration).
|
|
73
|
+
* This version passes the real file path for scope analysis.
|
|
74
|
+
*/
|
|
75
|
+
analyzeViolationWithPath(ruleId, filePath, content, violation, tree) {
|
|
76
|
+
const scopeContext = this.scopeAnalyzer.analyzeFile(filePath, content, tree);
|
|
77
|
+
if (scopeContext.isTypeDefinition) {
|
|
78
|
+
return {
|
|
79
|
+
verdict: 'suppressed',
|
|
80
|
+
confidence: 0.95,
|
|
81
|
+
reason: 'File is primarily type definitions — no runtime behavior',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (scopeContext.isTestFile) {
|
|
85
|
+
return {
|
|
86
|
+
verdict: 'suppressed',
|
|
87
|
+
confidence: 0.90,
|
|
88
|
+
reason: 'Violation is in a test file — not deployed to production',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const analyzer = this.getAnalyzer(ruleId);
|
|
92
|
+
if (!analyzer) {
|
|
93
|
+
return { verdict: 'regex_only', confidence: 0, reason: 'No AST analyzer for this rule' };
|
|
94
|
+
}
|
|
95
|
+
return analyzer(tree, content, violation, scopeContext);
|
|
96
|
+
}
|
|
97
|
+
// -------------------------------------------------------------------------
|
|
98
|
+
// Analyzer dispatch
|
|
99
|
+
// -------------------------------------------------------------------------
|
|
100
|
+
getAnalyzer(ruleId) {
|
|
101
|
+
const analyzers = {
|
|
102
|
+
'coppa-tracking-003': this.analyzeTracking003.bind(this),
|
|
103
|
+
'coppa-retention-005': this.analyzeRetention005.bind(this),
|
|
104
|
+
'coppa-ext-017': this.analyzeExtLinks017.bind(this),
|
|
105
|
+
'coppa-sec-015': this.analyzeXSS015.bind(this),
|
|
106
|
+
'coppa-auth-001': this.analyzeAuth001.bind(this),
|
|
107
|
+
'coppa-ui-008': this.analyzeUI008.bind(this),
|
|
108
|
+
'coppa-ugc-014': this.analyzeUGC014.bind(this),
|
|
109
|
+
'coppa-flow-009': this.analyzeFlow009.bind(this),
|
|
110
|
+
'coppa-cookies-016': this.analyzeCookies016.bind(this),
|
|
111
|
+
'ETHICAL-001': this.analyzeInfiniteScroll001.bind(this),
|
|
112
|
+
};
|
|
113
|
+
return analyzers[ruleId] ?? null;
|
|
114
|
+
}
|
|
115
|
+
// -------------------------------------------------------------------------
|
|
116
|
+
// Rule: coppa-tracking-003 — Ad Trackers
|
|
117
|
+
// Check CallExpression args for child_directed_treatment
|
|
118
|
+
// -------------------------------------------------------------------------
|
|
119
|
+
analyzeTracking003(tree, content, violation, _scope) {
|
|
120
|
+
const tracer = new data_flow_tracer_1.DataFlowTracer(tree);
|
|
121
|
+
// Check if the call at the violation line includes child_directed_treatment
|
|
122
|
+
if (tracer.hasArgument(violation.line, 'child_directed_treatment')) {
|
|
123
|
+
return {
|
|
124
|
+
verdict: 'suppressed',
|
|
125
|
+
confidence: 0.92,
|
|
126
|
+
reason: 'child_directed_treatment flag detected in tracker initialization arguments',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
// Also check for restrictDataProcessing
|
|
130
|
+
if (tracer.hasArgument(violation.line, 'restrictDataProcessing')) {
|
|
131
|
+
return {
|
|
132
|
+
verdict: 'suppressed',
|
|
133
|
+
confidence: 0.88,
|
|
134
|
+
reason: 'restrictDataProcessing flag detected in tracker initialization',
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
// Check nearby context (within 5 lines) for the flag being set separately
|
|
138
|
+
const nearbyCalls = tracer.findNearbyCallExpressions(violation.line, 5);
|
|
139
|
+
for (const call of nearbyCalls) {
|
|
140
|
+
if (call.name.includes('set') || call.name.includes('config')) {
|
|
141
|
+
if (tracer.hasArgument(call.line, 'child_directed_treatment') ||
|
|
142
|
+
tracer.hasArgument(call.line, 'restrictDataProcessing')) {
|
|
143
|
+
return {
|
|
144
|
+
verdict: 'suppressed',
|
|
145
|
+
confidence: 0.85,
|
|
146
|
+
reason: 'Child-directed flag configured near tracker initialization',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Check if this is in a config file (likely setting it up correctly)
|
|
152
|
+
if (_scope.isConfigFile) {
|
|
153
|
+
return {
|
|
154
|
+
verdict: 'confirmed',
|
|
155
|
+
confidence: 0.70,
|
|
156
|
+
reason: 'Tracker in config file without child_directed_treatment — may need flag',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
verdict: 'confirmed',
|
|
161
|
+
confidence: 0.85,
|
|
162
|
+
reason: 'No child_directed_treatment or restrictDataProcessing flag found in tracker setup',
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
// -------------------------------------------------------------------------
|
|
166
|
+
// Rule: coppa-retention-005 — Missing Data Retention
|
|
167
|
+
// Find Schema constructors, trace for TTL/expires/deletedAt in scope
|
|
168
|
+
// -------------------------------------------------------------------------
|
|
169
|
+
analyzeRetention005(tree, content, violation, _scope) {
|
|
170
|
+
const tracer = new data_flow_tracer_1.DataFlowTracer(tree);
|
|
171
|
+
const retentionFields = [
|
|
172
|
+
'TTL', 'ttl', 'expires', 'expireAt', 'expireAfterSeconds',
|
|
173
|
+
'deletedAt', 'deleted_at', 'expiresAt', 'expires_at',
|
|
174
|
+
'retention', 'paranoid', 'expiration', 'retentionPolicy',
|
|
175
|
+
];
|
|
176
|
+
// Check if there's a retention-related property in the same scope
|
|
177
|
+
if (tracer.hasPropertyInScope(violation.line, retentionFields)) {
|
|
178
|
+
return {
|
|
179
|
+
verdict: 'suppressed',
|
|
180
|
+
confidence: 0.90,
|
|
181
|
+
reason: 'Data retention field (TTL/expires/deletedAt) found in same scope as schema',
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
// Also check if there's a Mongoose index with expires
|
|
185
|
+
const scope = tracer.getEnclosingScope(violation.line);
|
|
186
|
+
if (scope) {
|
|
187
|
+
const scopeContent = content.split('\n').slice(scope.startLine - 1, scope.endLine).join('\n');
|
|
188
|
+
if (/\.index\s*\([^)]*expires/i.test(scopeContent) ||
|
|
189
|
+
/expireAfterSeconds/i.test(scopeContent)) {
|
|
190
|
+
return {
|
|
191
|
+
verdict: 'suppressed',
|
|
192
|
+
confidence: 0.90,
|
|
193
|
+
reason: 'Mongoose TTL index (expireAfterSeconds) found in schema scope',
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Check config files — schema definitions in config might be handled elsewhere
|
|
198
|
+
if (_scope.isConfigFile) {
|
|
199
|
+
return {
|
|
200
|
+
verdict: 'confirmed',
|
|
201
|
+
confidence: 0.50,
|
|
202
|
+
reason: 'Schema in config file — retention may be handled at application level',
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
verdict: 'confirmed',
|
|
207
|
+
confidence: 0.80,
|
|
208
|
+
reason: 'No TTL, expires, or deletedAt field found in schema scope',
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
// -------------------------------------------------------------------------
|
|
212
|
+
// Rule: coppa-ext-017 — External Links
|
|
213
|
+
// Parse JSX <a> elements, check for rel="noopener" and interstitial warning
|
|
214
|
+
// -------------------------------------------------------------------------
|
|
215
|
+
analyzeExtLinks017(tree, content, violation, _scope) {
|
|
216
|
+
// Admin routes don't need external link warnings
|
|
217
|
+
if (_scope.isAdminRoute) {
|
|
218
|
+
return {
|
|
219
|
+
verdict: 'suppressed',
|
|
220
|
+
confidence: 0.88,
|
|
221
|
+
reason: 'External link in admin route — admin users don\'t need child-safety warnings',
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const lines = content.split('\n');
|
|
225
|
+
const violationLine = lines[violation.line - 1] || '';
|
|
226
|
+
// Check if the link is wrapped in a SafeLink or ExternalLink component
|
|
227
|
+
// Look at surrounding lines for component wrappers
|
|
228
|
+
const contextStart = Math.max(0, violation.line - 4);
|
|
229
|
+
const contextEnd = Math.min(lines.length, violation.line + 3);
|
|
230
|
+
const context = lines.slice(contextStart, contextEnd).join('\n');
|
|
231
|
+
if (/SafeLink|ExternalLink|InterstitialLink|WarningLink/i.test(context)) {
|
|
232
|
+
return {
|
|
233
|
+
verdict: 'suppressed',
|
|
234
|
+
confidence: 0.90,
|
|
235
|
+
reason: 'External link wrapped in safety/warning component',
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
// Check if the link has rel="noopener noreferrer" (partial mitigation)
|
|
239
|
+
if (/rel\s*=\s*["'][^"']*noopener[^"']*["']/i.test(violationLine)) {
|
|
240
|
+
return {
|
|
241
|
+
verdict: 'confirmed',
|
|
242
|
+
confidence: 0.60,
|
|
243
|
+
reason: 'External link has noopener but no interstitial warning for child users',
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
verdict: 'confirmed',
|
|
248
|
+
confidence: 0.80,
|
|
249
|
+
reason: 'External link with target="_blank" lacks child-facing exit warning',
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
// -------------------------------------------------------------------------
|
|
253
|
+
// Rule: coppa-sec-015 — XSS Risk (dangerouslySetInnerHTML / innerHTML)
|
|
254
|
+
// Trace data flow for sanitization (DOMPurify, sanitize-html, xss, etc.)
|
|
255
|
+
// -------------------------------------------------------------------------
|
|
256
|
+
analyzeXSS015(tree, content, violation, _scope) {
|
|
257
|
+
const tracer = new data_flow_tracer_1.DataFlowTracer(tree);
|
|
258
|
+
// Check if the value passes through a sanitization function
|
|
259
|
+
const sanitizers = [
|
|
260
|
+
'DOMPurify.sanitize', 'sanitize', 'xss', 'sanitizeHtml',
|
|
261
|
+
'purify', 'clean', 'bleach', 'escape', 'escapeHtml',
|
|
262
|
+
'he.encode', 'encode', 'striptags', 'htmlEncode',
|
|
263
|
+
];
|
|
264
|
+
if (tracer.passesThrough(violation.line, sanitizers)) {
|
|
265
|
+
return {
|
|
266
|
+
verdict: 'suppressed',
|
|
267
|
+
confidence: 0.92,
|
|
268
|
+
reason: 'Value passes through sanitization function before HTML insertion',
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
// Check if the file imports a sanitizer
|
|
272
|
+
const importLines = content.split('\n').slice(0, 20).join('\n');
|
|
273
|
+
const hasSanitizerImport = /import.*(?:DOMPurify|sanitize-html|xss|isomorphic-dompurify|dompurify)/i.test(importLines);
|
|
274
|
+
if (hasSanitizerImport) {
|
|
275
|
+
// File imports a sanitizer — check if it's used in the same function scope
|
|
276
|
+
const scope = tracer.getEnclosingScope(violation.line);
|
|
277
|
+
if (scope) {
|
|
278
|
+
const scopeContent = content.split('\n').slice(scope.startLine - 1, scope.endLine).join('\n');
|
|
279
|
+
if (/sanitize|purify|clean|escape/i.test(scopeContent)) {
|
|
280
|
+
return {
|
|
281
|
+
verdict: 'suppressed',
|
|
282
|
+
confidence: 0.85,
|
|
283
|
+
reason: 'Sanitizer imported and used in same function scope',
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// Sanitizer imported but might not be used at this violation point
|
|
288
|
+
return {
|
|
289
|
+
verdict: 'confirmed',
|
|
290
|
+
confidence: 0.55,
|
|
291
|
+
reason: 'Sanitizer library imported but not clearly applied to this HTML insertion',
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
// Check for static content (string literal)
|
|
295
|
+
const violationLine = content.split('\n')[violation.line - 1] || '';
|
|
296
|
+
if (/dangerouslySetInnerHTML\s*=\s*\{\s*\{\s*__html\s*:\s*['"`]/.test(violationLine)) {
|
|
297
|
+
return {
|
|
298
|
+
verdict: 'suppressed',
|
|
299
|
+
confidence: 0.95,
|
|
300
|
+
reason: 'innerHTML set with static string literal — no XSS vector',
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
verdict: 'confirmed',
|
|
305
|
+
confidence: 0.85,
|
|
306
|
+
reason: 'No sanitization detected before HTML content insertion',
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
// -------------------------------------------------------------------------
|
|
310
|
+
// Rule: coppa-auth-001 — Social Login Without Age Gate
|
|
311
|
+
// Check for age verification wrapper around signInWithPopup
|
|
312
|
+
// -------------------------------------------------------------------------
|
|
313
|
+
analyzeAuth001(tree, content, violation, _scope) {
|
|
314
|
+
const tracer = new data_flow_tracer_1.DataFlowTracer(tree);
|
|
315
|
+
// Check the enclosing function scope for age-gating patterns
|
|
316
|
+
const scope = tracer.getEnclosingScope(violation.line);
|
|
317
|
+
if (scope) {
|
|
318
|
+
const scopeContent = content.split('\n').slice(scope.startLine - 1, scope.endLine).join('\n');
|
|
319
|
+
// Look for age check patterns
|
|
320
|
+
const agePatterns = [
|
|
321
|
+
/age\s*>=?\s*13/,
|
|
322
|
+
/age\s*>\s*12/,
|
|
323
|
+
/isMinor/i,
|
|
324
|
+
/isChild/i,
|
|
325
|
+
/ageGate/i,
|
|
326
|
+
/age_gate/i,
|
|
327
|
+
/verifyAge/i,
|
|
328
|
+
/checkAge/i,
|
|
329
|
+
/parentalConsent/i,
|
|
330
|
+
/parental_consent/i,
|
|
331
|
+
/isOver13/i,
|
|
332
|
+
/isAdult/i,
|
|
333
|
+
];
|
|
334
|
+
if (agePatterns.some(p => p.test(scopeContent))) {
|
|
335
|
+
return {
|
|
336
|
+
verdict: 'suppressed',
|
|
337
|
+
confidence: 0.88,
|
|
338
|
+
reason: 'Age verification check found in same function scope as social login',
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// Check if there's a parent context with age gate (look 10 lines up)
|
|
343
|
+
const contextStart = Math.max(0, violation.line - 11);
|
|
344
|
+
const contextContent = content.split('\n').slice(contextStart, violation.line).join('\n');
|
|
345
|
+
if (/age\s*>=?\s*13|isMinor|ageGate|verifyAge|parentalConsent/i.test(contextContent)) {
|
|
346
|
+
return {
|
|
347
|
+
verdict: 'suppressed',
|
|
348
|
+
confidence: 0.80,
|
|
349
|
+
reason: 'Age verification found in preceding context',
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
verdict: 'confirmed',
|
|
354
|
+
confidence: 0.85,
|
|
355
|
+
reason: 'Social login without age gate verification in scope',
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
// -------------------------------------------------------------------------
|
|
359
|
+
// Rule: coppa-ui-008 — Missing Privacy Policy on Registration
|
|
360
|
+
// Walk JSX tree for privacy/terms child components
|
|
361
|
+
// -------------------------------------------------------------------------
|
|
362
|
+
analyzeUI008(tree, content, violation, _scope) {
|
|
363
|
+
const tracer = new data_flow_tracer_1.DataFlowTracer(tree);
|
|
364
|
+
// Check the enclosing scope (likely a component) for privacy-related elements
|
|
365
|
+
const scope = tracer.getEnclosingScope(violation.line);
|
|
366
|
+
if (!scope) {
|
|
367
|
+
// If no enclosing scope, check the whole file
|
|
368
|
+
return this.checkPrivacyInContent(content, violation);
|
|
369
|
+
}
|
|
370
|
+
const scopeContent = content.split('\n').slice(scope.startLine - 1, scope.endLine).join('\n');
|
|
371
|
+
// Look for privacy policy references in the component
|
|
372
|
+
const privacyPatterns = [
|
|
373
|
+
/privacy/i,
|
|
374
|
+
/PrivacyPolicy/,
|
|
375
|
+
/privacy-policy/i,
|
|
376
|
+
/terms.*service/i,
|
|
377
|
+
/TermsOfService/,
|
|
378
|
+
/terms-of-service/i,
|
|
379
|
+
/href\s*=\s*["'][^"']*privacy/i,
|
|
380
|
+
/href\s*=\s*["'][^"']*terms/i,
|
|
381
|
+
/<PrivacyLink/,
|
|
382
|
+
/<TermsLink/,
|
|
383
|
+
/PrivacyCheckbox/,
|
|
384
|
+
/AcceptTerms/,
|
|
385
|
+
];
|
|
386
|
+
if (privacyPatterns.some(p => p.test(scopeContent))) {
|
|
387
|
+
return {
|
|
388
|
+
verdict: 'suppressed',
|
|
389
|
+
confidence: 0.88,
|
|
390
|
+
reason: 'Privacy policy / terms of service link found in registration component',
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
verdict: 'confirmed',
|
|
395
|
+
confidence: 0.75,
|
|
396
|
+
reason: 'Registration form lacks visible privacy policy link in component scope',
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
checkPrivacyInContent(content, violation) {
|
|
400
|
+
// Check a wider window (50 lines around violation)
|
|
401
|
+
const lines = content.split('\n');
|
|
402
|
+
const start = Math.max(0, violation.line - 25);
|
|
403
|
+
const end = Math.min(lines.length, violation.line + 25);
|
|
404
|
+
const window = lines.slice(start, end).join('\n');
|
|
405
|
+
if (/privacy|PrivacyPolicy|terms.*service|TermsOfService/i.test(window)) {
|
|
406
|
+
return {
|
|
407
|
+
verdict: 'suppressed',
|
|
408
|
+
confidence: 0.75,
|
|
409
|
+
reason: 'Privacy/terms reference found near registration form',
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
verdict: 'confirmed',
|
|
414
|
+
confidence: 0.70,
|
|
415
|
+
reason: 'No privacy policy reference found near registration form',
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
// -------------------------------------------------------------------------
|
|
419
|
+
// Rule: coppa-ugc-014 — UGC Upload Without PII Filter
|
|
420
|
+
// Find submit functions, check for moderation/scrubbing calls in body
|
|
421
|
+
// -------------------------------------------------------------------------
|
|
422
|
+
analyzeUGC014(tree, content, violation, _scope) {
|
|
423
|
+
const tracer = new data_flow_tracer_1.DataFlowTracer(tree);
|
|
424
|
+
// Check the enclosing function for moderation/filter calls
|
|
425
|
+
const scope = tracer.getEnclosingScope(violation.line);
|
|
426
|
+
if (scope) {
|
|
427
|
+
const scopeLines = content.split('\n').slice(scope.startLine - 1, scope.endLine);
|
|
428
|
+
// Strip single-line comments to avoid false matches (e.g., "// Todo: add filter")
|
|
429
|
+
const scopeContent = scopeLines
|
|
430
|
+
.map(line => line.replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, ''))
|
|
431
|
+
.join('\n');
|
|
432
|
+
const moderationPatterns = [
|
|
433
|
+
/scrub|filter|sanitize|moderate|moderation/i,
|
|
434
|
+
/piiFilter|PIIFilter|pii_filter/,
|
|
435
|
+
/contentFilter|ContentFilter/,
|
|
436
|
+
/comprehend|rekognition|detect_pii/i,
|
|
437
|
+
/removePII|stripPII|redact/i,
|
|
438
|
+
/textModeration|contentModeration/i,
|
|
439
|
+
/profanityFilter|wordFilter/i,
|
|
440
|
+
];
|
|
441
|
+
if (moderationPatterns.some(p => p.test(scopeContent))) {
|
|
442
|
+
return {
|
|
443
|
+
verdict: 'suppressed',
|
|
444
|
+
confidence: 0.88,
|
|
445
|
+
reason: 'PII scrubbing / content moderation found in submit handler',
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// Check if the data passes through a filter before the submission point
|
|
450
|
+
const filterFunctions = [
|
|
451
|
+
'scrubPII', 'filterPII', 'sanitize', 'moderate', 'contentFilter',
|
|
452
|
+
'textFilter', 'piiFilter', 'redact', 'filterContent',
|
|
453
|
+
];
|
|
454
|
+
if (tracer.passesThrough(violation.line, filterFunctions)) {
|
|
455
|
+
return {
|
|
456
|
+
verdict: 'suppressed',
|
|
457
|
+
confidence: 0.85,
|
|
458
|
+
reason: 'UGC content passes through PII filter before storage',
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
verdict: 'confirmed',
|
|
463
|
+
confidence: 0.80,
|
|
464
|
+
reason: 'No PII scrubbing or content moderation detected in submit handler',
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
// -------------------------------------------------------------------------
|
|
468
|
+
// Rule: coppa-flow-009 — Child Contact Collection
|
|
469
|
+
// Distinguish InterfaceDeclaration vs VariableDeclaration
|
|
470
|
+
// -------------------------------------------------------------------------
|
|
471
|
+
analyzeFlow009(tree, content, violation, _scope) {
|
|
472
|
+
// Check line context — if we're in an interface declaration, it's a type
|
|
473
|
+
// definition, not actual data collection
|
|
474
|
+
const lineContext = this.scopeAnalyzer.analyzeLineContext(violation.line, tree);
|
|
475
|
+
if (lineContext.inInterfaceDecl) {
|
|
476
|
+
return {
|
|
477
|
+
verdict: 'suppressed',
|
|
478
|
+
confidence: 0.92,
|
|
479
|
+
reason: 'child_email is in a TypeScript interface declaration — defines shape, not collection',
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
// Check if the line is a type alias or similar type-level construct
|
|
483
|
+
const violationLine = content.split('\n')[violation.line - 1] || '';
|
|
484
|
+
if (/^\s*(export\s+)?(type|interface)\s/.test(violationLine)) {
|
|
485
|
+
return {
|
|
486
|
+
verdict: 'suppressed',
|
|
487
|
+
confidence: 0.90,
|
|
488
|
+
reason: 'child_email is in a type/interface definition — not runtime collection',
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
// Check if there's a parent_email variable in the same scope (any context)
|
|
492
|
+
const tracer = new data_flow_tracer_1.DataFlowTracer(tree);
|
|
493
|
+
const parentEmailVars = [
|
|
494
|
+
'parent_email', 'parentEmail', 'guardian_email', 'guardianEmail',
|
|
495
|
+
'parent_contact', 'parentContact', 'guardian_contact', 'guardianContact',
|
|
496
|
+
];
|
|
497
|
+
// Use AST-aware variable detection (Sprint 9 DataFlowTracer improvement)
|
|
498
|
+
if (tracer.hasVariableInScope(violation.line, parentEmailVars)) {
|
|
499
|
+
return {
|
|
500
|
+
verdict: 'suppressed',
|
|
501
|
+
confidence: 0.85,
|
|
502
|
+
reason: 'Parent/guardian email also collected in same scope — consent flow likely in place',
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
// Fallback: also check via regex on enclosing scope content
|
|
506
|
+
const scope = tracer.getEnclosingScope(violation.line);
|
|
507
|
+
if (scope) {
|
|
508
|
+
const scopeContent = content.split('\n').slice(scope.startLine - 1, scope.endLine).join('\n');
|
|
509
|
+
if (/parent_email|parentEmail|guardian_email|guardianEmail/i.test(scopeContent)) {
|
|
510
|
+
return {
|
|
511
|
+
verdict: 'suppressed',
|
|
512
|
+
confidence: 0.85,
|
|
513
|
+
reason: 'Parent/guardian email also collected in same scope — consent flow likely in place',
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return {
|
|
518
|
+
verdict: 'confirmed',
|
|
519
|
+
confidence: 0.80,
|
|
520
|
+
reason: 'Child contact information collected without visible parent consent flow',
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
// -------------------------------------------------------------------------
|
|
524
|
+
// Rule: coppa-cookies-016 — Missing Cookie Notice
|
|
525
|
+
// Check setItem() key for PII vs preference strings
|
|
526
|
+
// -------------------------------------------------------------------------
|
|
527
|
+
analyzeCookies016(tree, content, violation, _scope) {
|
|
528
|
+
const violationLine = content.split('\n')[violation.line - 1] || '';
|
|
529
|
+
// Check if this is setting a preference cookie (not PII/tracking)
|
|
530
|
+
const preferencePatterns = [
|
|
531
|
+
/theme|darkMode|dark_mode|colorScheme|color_scheme/i,
|
|
532
|
+
/locale|language|lang|i18n/i,
|
|
533
|
+
/viewMode|view_mode|layout|sidebar/i,
|
|
534
|
+
/consent|cookieConsent|cookie_consent/i,
|
|
535
|
+
/preference|pref|setting/i,
|
|
536
|
+
];
|
|
537
|
+
if (preferencePatterns.some(p => p.test(violationLine))) {
|
|
538
|
+
return {
|
|
539
|
+
verdict: 'suppressed',
|
|
540
|
+
confidence: 0.90,
|
|
541
|
+
reason: 'Cookie stores user preference (theme/locale/layout) — not PII or tracking',
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
// Check if there's a cookie consent check before this setItem
|
|
545
|
+
const tracer = new data_flow_tracer_1.DataFlowTracer(tree);
|
|
546
|
+
const scope = tracer.getEnclosingScope(violation.line);
|
|
547
|
+
if (scope) {
|
|
548
|
+
const scopeContent = content.split('\n').slice(scope.startLine - 1, scope.endLine).join('\n');
|
|
549
|
+
if (/consent|hasConsent|cookieConsent|getCookieConsent/i.test(scopeContent)) {
|
|
550
|
+
return {
|
|
551
|
+
verdict: 'suppressed',
|
|
552
|
+
confidence: 0.85,
|
|
553
|
+
reason: 'Cookie consent check found in same scope as storage operation',
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
// If it's clearly tracking/PII storage, confirm
|
|
558
|
+
const trackingPatterns = [
|
|
559
|
+
/analytics|track|pixel|fbq|ga\(|gtag/i,
|
|
560
|
+
/userId|user_id|sessionId|session_id/i,
|
|
561
|
+
/email|phone|name|dob|birthdate/i,
|
|
562
|
+
];
|
|
563
|
+
if (trackingPatterns.some(p => p.test(violationLine))) {
|
|
564
|
+
return {
|
|
565
|
+
verdict: 'confirmed',
|
|
566
|
+
confidence: 0.90,
|
|
567
|
+
reason: 'Storage operation involves tracking or PII data without consent check',
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
return {
|
|
571
|
+
verdict: 'confirmed',
|
|
572
|
+
confidence: 0.65,
|
|
573
|
+
reason: 'Cookie/storage operation may involve tracking data — consent check recommended',
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
// -------------------------------------------------------------------------
|
|
577
|
+
// Rule: ETHICAL-001 — Infinite Scroll / Endless Feed
|
|
578
|
+
// Check IntersectionObserver callback: loadMore vs lazyLoad
|
|
579
|
+
// -------------------------------------------------------------------------
|
|
580
|
+
analyzeInfiniteScroll001(tree, content, violation, _scope) {
|
|
581
|
+
const violationLine = content.split('\n')[violation.line - 1] || '';
|
|
582
|
+
// Check if this is a lazy-loading observer (not infinite content scroll)
|
|
583
|
+
const lazyLoadPatterns = [
|
|
584
|
+
/lazyLoad|lazy-load|lazy_load/i,
|
|
585
|
+
/lazyImage|lazy-image/i,
|
|
586
|
+
/ImageObserver/i,
|
|
587
|
+
/loading\s*=\s*['"]lazy['"]/i,
|
|
588
|
+
];
|
|
589
|
+
if (lazyLoadPatterns.some(p => p.test(violationLine))) {
|
|
590
|
+
return {
|
|
591
|
+
verdict: 'suppressed',
|
|
592
|
+
confidence: 0.90,
|
|
593
|
+
reason: 'IntersectionObserver used for lazy loading (images), not infinite content scroll',
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
// Check surrounding context for lazy loading vs content loading
|
|
597
|
+
const lines = content.split('\n');
|
|
598
|
+
const contextStart = Math.max(0, violation.line - 6);
|
|
599
|
+
const contextEnd = Math.min(lines.length, violation.line + 6);
|
|
600
|
+
const context = lines.slice(contextStart, contextEnd).join('\n');
|
|
601
|
+
if (/lazyLoad|lazy|preload|prefetch|image|img|src/i.test(context) &&
|
|
602
|
+
!/loadMore|fetchMore|nextPage|pagination/i.test(context)) {
|
|
603
|
+
return {
|
|
604
|
+
verdict: 'suppressed',
|
|
605
|
+
confidence: 0.80,
|
|
606
|
+
reason: 'Context suggests lazy loading of resources, not infinite content feed',
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
// Check for pagination controls nearby (indicates good pattern)
|
|
610
|
+
if (/pagination|Pagination|page.*button|PageButton|loadMore.*button/i.test(context)) {
|
|
611
|
+
return {
|
|
612
|
+
verdict: 'suppressed',
|
|
613
|
+
confidence: 0.75,
|
|
614
|
+
reason: 'Pagination controls found near scroll handler — natural stopping points exist',
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
// Check if there's a max/limit on loaded content
|
|
618
|
+
const tracer = new data_flow_tracer_1.DataFlowTracer(tree);
|
|
619
|
+
// Sprint 9: Use AST-aware variable detection for limit constants
|
|
620
|
+
const limitVars = [
|
|
621
|
+
'maxItems', 'maxPages', 'MAX_PAGES', 'MAX_ITEMS',
|
|
622
|
+
'pageLimit', 'limit', 'maxResults', 'MAX_RESULTS',
|
|
623
|
+
'itemLimit', 'ITEM_LIMIT', 'PAGE_LIMIT',
|
|
624
|
+
];
|
|
625
|
+
if (tracer.hasVariableInScope(violation.line, limitVars)) {
|
|
626
|
+
return {
|
|
627
|
+
verdict: 'suppressed',
|
|
628
|
+
confidence: 0.78,
|
|
629
|
+
reason: 'Content loading has max items/pages limit — not truly infinite',
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
// Fallback: regex check on enclosing scope content
|
|
633
|
+
const scope = tracer.getEnclosingScope(violation.line);
|
|
634
|
+
if (scope) {
|
|
635
|
+
const scopeContent = content.split('\n').slice(scope.startLine - 1, scope.endLine).join('\n');
|
|
636
|
+
if (/maxItems|maxPages|MAX_PAGES|limit|MAX_ITEMS|pageLimit/i.test(scopeContent)) {
|
|
637
|
+
return {
|
|
638
|
+
verdict: 'suppressed',
|
|
639
|
+
confidence: 0.78,
|
|
640
|
+
reason: 'Content loading has max items/pages limit — not truly infinite',
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
verdict: 'confirmed',
|
|
646
|
+
confidence: 0.80,
|
|
647
|
+
reason: 'Infinite scroll pattern detected without natural stopping points',
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
exports.ASTRuleEngine = ASTRuleEngine;
|
|
652
|
+
exports.default = ASTRuleEngine;
|
|
653
|
+
//# sourceMappingURL=ast-engine.js.map
|