@fanboynz/network-scanner 1.0.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1107 @@
1
+ const { formatLogMessage } = require('./colorize');
2
+
3
+ /**
4
+ * Enhanced domain validation function
5
+ * @param {string} domain - The domain to validate
6
+ * @returns {boolean} True if domain is valid, false otherwise
7
+ */
8
+ function isValidDomain(domain) {
9
+ if (!domain || typeof domain !== 'string') {
10
+ return false;
11
+ }
12
+
13
+ // Trim whitespace
14
+ domain = domain.trim();
15
+
16
+ // Check minimum length (shortest valid domain is something like "a.b" = 3 chars)
17
+ if (domain.length < 3) {
18
+ return false;
19
+ }
20
+
21
+ // Check maximum length (RFC 1035 - 253 characters max)
22
+ if (domain.length > 253) {
23
+ return false;
24
+ }
25
+
26
+ // Check for IP addresses (both IPv4 and IPv6)
27
+ if (isIPAddress(domain)) {
28
+ return true; // IP addresses are valid targets
29
+ }
30
+
31
+ // Must contain at least one dot
32
+ if (!domain.includes('.')) {
33
+ return false;
34
+ }
35
+
36
+ // Cannot start or end with dot
37
+ if (domain.startsWith('.') || domain.endsWith('.')) {
38
+ return false;
39
+ }
40
+
41
+ // Cannot contain consecutive dots
42
+ if (domain.includes('..')) {
43
+ return false;
44
+ }
45
+
46
+ // Split into labels and validate each
47
+ const labels = domain.split('.');
48
+
49
+ // Must have at least 2 labels (domain.tld)
50
+ if (labels.length < 2) {
51
+ return false;
52
+ }
53
+
54
+ // Validate each label
55
+ for (const label of labels) {
56
+ if (!isValidDomainLabel(label)) {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ // TLD (last label) validation
62
+ const tld = labels[labels.length - 1];
63
+ if (!isValidTLD(tld)) {
64
+ return false;
65
+ }
66
+
67
+ return true;
68
+ }
69
+
70
+ /**
71
+ * Validates a single domain label
72
+ * @param {string} label - The label to validate
73
+ * @returns {boolean} True if label is valid
74
+ */
75
+ function isValidDomainLabel(label) {
76
+ if (!label || label.length === 0) {
77
+ return false;
78
+ }
79
+
80
+ // Label cannot be longer than 63 characters (RFC 1035)
81
+ if (label.length > 63) {
82
+ return false;
83
+ }
84
+
85
+ // Label cannot start or end with hyphen
86
+ if (label.startsWith('-') || label.endsWith('-')) {
87
+ return false;
88
+ }
89
+
90
+ // Label can only contain alphanumeric characters and hyphens
91
+ const labelRegex = /^[a-zA-Z0-9-]+$/;
92
+ if (!labelRegex.test(label)) {
93
+ return false;
94
+ }
95
+
96
+ return true;
97
+ }
98
+
99
+ /**
100
+ * Validates TLD (Top Level Domain)
101
+ * @param {string} tld - The TLD to validate
102
+ * @returns {boolean} True if TLD is valid
103
+ */
104
+ function isValidTLD(tld) {
105
+ if (!tld || tld.length === 0) {
106
+ return false;
107
+ }
108
+
109
+ // TLD must be at least 2 characters
110
+ if (tld.length < 2) {
111
+ return false;
112
+ }
113
+
114
+ // Allow numeric TLDs for modern domains like .1password
115
+ // but still validate structure
116
+
117
+ // TLD can contain letters and numbers, but must start with letter
118
+ const tldRegex = /^[a-zA-Z][a-zA-Z0-9]*$/;
119
+ if (!tldRegex.test(tld)) {
120
+ return false;
121
+ }
122
+
123
+ return true;
124
+ }
125
+
126
+ /**
127
+ * Checks if a string is an IP address (IPv4 or IPv6)
128
+ * @param {string} str - String to check
129
+ * @returns {boolean} True if it's an IP address
130
+ */
131
+ function isIPAddress(str) {
132
+ return isIPv4(str) || isIPv6(str);
133
+ }
134
+
135
+ /**
136
+ * Checks if a string is a valid IPv4 address
137
+ * @param {string} str - String to check
138
+ * @returns {boolean} True if valid IPv4
139
+ */
140
+ function isIPv4(str) {
141
+ const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
142
+ return ipv4Regex.test(str);
143
+ }
144
+
145
+ /**
146
+ * Checks if a string is a valid IPv6 address
147
+ * @param {string} str - String to check
148
+ * @returns {boolean} True if valid IPv6
149
+ */
150
+ function isIPv6(str) {
151
+ // Simplified IPv6 regex - covers most common cases
152
+ const ipv6Regex = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::$|^::1$|^(?:[0-9a-fA-F]{1,4}:)*::(?:[0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}$/;
153
+ return ipv6Regex.test(str);
154
+ }
155
+
156
+ /**
157
+ * Validates a regex pattern string
158
+ * @param {string} pattern - The regex pattern to validate
159
+ * @returns {object} Validation result with isValid boolean and error message
160
+ */
161
+ function validateRegexPattern(pattern) {
162
+ if (!pattern || typeof pattern !== 'string') {
163
+ return { isValid: false, error: 'Pattern must be a non-empty string' };
164
+ }
165
+
166
+ try {
167
+ // Remove leading/trailing slashes if present
168
+ const cleanPattern = pattern.replace(/^\/(.*)\/$/, '$1');
169
+ new RegExp(cleanPattern);
170
+ return { isValid: true };
171
+ } catch (err) {
172
+ return { isValid: false, error: `Invalid regex: ${err.message}` };
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Validates adblock filter modifiers
178
+ * @param {string} modifiers - The modifier string (e.g., "script,third-party")
179
+ * @returns {object} Validation result
180
+ */
181
+ function validateAdblockModifiers(modifiers) {
182
+ if (!modifiers) {
183
+ return { isValid: true, modifiers: [] };
184
+ }
185
+
186
+ // Valid adblock filter modifiers
187
+ const validModifiers = new Set([
188
+ // Resource type modifiers
189
+ 'script', 'stylesheet', 'image', 'object', 'xmlhttprequest', 'subdocument',
190
+ 'ping', 'websocket', 'webrtc', 'document', 'elemhide', 'generichide',
191
+ 'genericblock', 'popup', 'font', 'media', 'other',
192
+
193
+ // Party modifiers
194
+ 'third-party', 'first-party', '~third-party', '~first-party',
195
+
196
+ // Domain modifiers (domain= will be validated separately)
197
+ 'domain',
198
+
199
+ // Method modifiers
200
+ 'match-case', '~match-case',
201
+
202
+ // Action modifiers
203
+ 'important', 'badfilter',
204
+
205
+ // CSP and redirect modifiers
206
+ 'csp', 'redirect', 'redirect-rule',
207
+
208
+ // uBlock Origin specific
209
+ 'inline-script', 'inline-font', 'mp4', 'empty', 'xhr'
210
+ ]);
211
+
212
+ const modifierList = modifiers.split(',').map(m => m.trim());
213
+ const invalidModifiers = [];
214
+ const parsedModifiers = [];
215
+
216
+ for (const modifier of modifierList) {
217
+ if (!modifier) continue;
218
+
219
+ // Handle domain= modifier specially
220
+ if (modifier.startsWith('domain=')) {
221
+ const domains = modifier.substring(7);
222
+ if (domains) {
223
+ // Validate domain list format (domains separated by |)
224
+ const domainList = domains.split('|');
225
+ for (const domain of domainList) {
226
+ const cleanDomain = domain.startsWith('~') ? domain.substring(1) : domain;
227
+ if (cleanDomain && !isValidDomain(cleanDomain)) {
228
+ invalidModifiers.push(`Invalid domain in domain= modifier: ${cleanDomain}`);
229
+ }
230
+ }
231
+ parsedModifiers.push({ type: 'domain', value: domains });
232
+ } else {
233
+ invalidModifiers.push('Empty domain= modifier');
234
+ }
235
+ continue;
236
+ }
237
+
238
+ // Handle csp= modifier
239
+ if (modifier.startsWith('csp=')) {
240
+ const cspValue = modifier.substring(4);
241
+ if (!cspValue) {
242
+ invalidModifiers.push('Empty csp= modifier');
243
+ } else {
244
+ parsedModifiers.push({ type: 'csp', value: cspValue });
245
+ }
246
+ continue;
247
+ }
248
+
249
+ // Handle redirect= modifier
250
+ if (modifier.startsWith('redirect=')) {
251
+ const redirectValue = modifier.substring(9);
252
+ if (!redirectValue) {
253
+ invalidModifiers.push('Empty redirect= modifier');
254
+ } else {
255
+ parsedModifiers.push({ type: 'redirect', value: redirectValue });
256
+ }
257
+ continue;
258
+ }
259
+
260
+ // Check for negated modifiers (starting with ~)
261
+ const isNegated = modifier.startsWith('~');
262
+ const baseModifier = isNegated ? modifier.substring(1) : modifier;
263
+
264
+ if (validModifiers.has(modifier) || validModifiers.has(baseModifier)) {
265
+ parsedModifiers.push({
266
+ type: baseModifier,
267
+ negated: isNegated,
268
+ raw: modifier
269
+ });
270
+ } else {
271
+ invalidModifiers.push(modifier);
272
+ }
273
+ }
274
+
275
+ if (invalidModifiers.length > 0) {
276
+ return {
277
+ isValid: false,
278
+ error: `Invalid modifiers: ${invalidModifiers.join(', ')}`,
279
+ validModifiers: parsedModifiers
280
+ };
281
+ }
282
+
283
+ return {
284
+ isValid: true,
285
+ modifiers: parsedModifiers
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Validates adblock rule format with comprehensive modifier support
291
+ * @param {string} rule - The rule to validate
292
+ * @returns {object} Validation result with format type and validity
293
+ */
294
+ function validateAdblockRule(rule) {
295
+ if (!rule || typeof rule !== 'string') {
296
+ return { isValid: false, format: 'unknown', error: 'Rule must be a non-empty string' };
297
+ }
298
+
299
+ const trimmedRule = rule.trim();
300
+
301
+ // Skip comments
302
+ if (trimmedRule.startsWith('!') || trimmedRule.startsWith('#')) {
303
+ return { isValid: true, format: 'comment' };
304
+ }
305
+
306
+ // Adblock format: ||domain.com^ or ||domain.com^$script,third-party
307
+ if (trimmedRule.startsWith('||') && trimmedRule.includes('^')) {
308
+ const parts = trimmedRule.substring(2).split('^');
309
+ const domain = parts[0];
310
+
311
+ if (!isValidDomain(domain)) {
312
+ return { isValid: false, format: 'adblock', error: `Invalid domain in adblock rule: ${domain}` };
313
+ }
314
+
315
+ // Check for modifiers after ^$
316
+ let modifiers = '';
317
+ let modifierValidation = { isValid: true, modifiers: [] };
318
+
319
+ if (parts.length > 1 && parts[1].startsWith('$')) {
320
+ modifiers = parts[1].substring(1);
321
+ modifierValidation = validateAdblockModifiers(modifiers);
322
+
323
+ if (!modifierValidation.isValid) {
324
+ return {
325
+ isValid: false,
326
+ format: 'adblock',
327
+ error: `${modifierValidation.error} in rule: ${trimmedRule}`,
328
+ domain,
329
+ modifiers: modifierValidation.validModifiers || []
330
+ };
331
+ }
332
+ }
333
+
334
+ return {
335
+ isValid: true,
336
+ format: 'adblock',
337
+ domain,
338
+ modifiers: modifierValidation.modifiers,
339
+ hasModifiers: modifiers.length > 0
340
+ };
341
+ }
342
+
343
+ // Basic adblock format without ||: domain.com^$modifier
344
+ if (trimmedRule.includes('^') && trimmedRule.includes('$')) {
345
+ const parts = trimmedRule.split('^$');
346
+ if (parts.length === 2) {
347
+ const domain = parts[0];
348
+ const modifiers = parts[1];
349
+
350
+ if (!isValidDomain(domain)) {
351
+ return { isValid: false, format: 'adblock-basic', error: `Invalid domain in adblock rule: ${domain}` };
352
+ }
353
+
354
+ const modifierValidation = validateAdblockModifiers(modifiers);
355
+ if (!modifierValidation.isValid) {
356
+ return {
357
+ isValid: false,
358
+ format: 'adblock-basic',
359
+ error: modifierValidation.error,
360
+ domain
361
+ };
362
+ }
363
+
364
+ return {
365
+ isValid: true,
366
+ format: 'adblock-basic',
367
+ domain,
368
+ modifiers: modifierValidation.modifiers
369
+ };
370
+ }
371
+ }
372
+
373
+ // Simple adblock format: ||domain.com^ (without modifiers)
374
+ if (trimmedRule.startsWith('||') && trimmedRule.endsWith('^')) {
375
+ const domain = trimmedRule.substring(2, trimmedRule.length - 1);
376
+ if (isValidDomain(domain)) {
377
+ return { isValid: true, format: 'adblock-simple', domain };
378
+ } else {
379
+ return { isValid: false, format: 'adblock-simple', error: `Invalid domain in adblock rule: ${domain}` };
380
+ }
381
+ }
382
+
383
+ // Localhost format: 127.0.0.1 domain.com or 0.0.0.0 domain.com
384
+ if (trimmedRule.match(/^(127\.0\.0\.1|0\.0\.0\.0)\s+/)) {
385
+ const parts = trimmedRule.split(/\s+/);
386
+ if (parts.length >= 2) {
387
+ const domain = parts[1];
388
+ if (isValidDomain(domain)) {
389
+ return { isValid: true, format: 'localhost', domain };
390
+ } else {
391
+ return { isValid: false, format: 'localhost', error: `Invalid domain in localhost rule: ${domain}` };
392
+ }
393
+ }
394
+ return { isValid: false, format: 'localhost', error: 'Malformed localhost rule' };
395
+ }
396
+
397
+ // DNSmasq format: local=/domain.com/
398
+ if (trimmedRule.startsWith('local=/') && trimmedRule.endsWith('/')) {
399
+ const domain = trimmedRule.substring(6, trimmedRule.length - 1);
400
+ if (isValidDomain(domain)) {
401
+ return { isValid: true, format: 'dnsmasq', domain };
402
+ } else {
403
+ return { isValid: false, format: 'dnsmasq', error: `Invalid domain in dnsmasq rule: ${domain}` };
404
+ }
405
+ }
406
+
407
+ // DNSmasq old format: server=/domain.com/
408
+ if (trimmedRule.startsWith('server=/') && trimmedRule.endsWith('/')) {
409
+ const domain = trimmedRule.substring(7, trimmedRule.length - 1);
410
+ if (isValidDomain(domain)) {
411
+ return { isValid: true, format: 'dnsmasq-old', domain };
412
+ } else {
413
+ return { isValid: false, format: 'dnsmasq-old', error: `Invalid domain in dnsmasq-old rule: ${domain}` };
414
+ }
415
+ }
416
+
417
+ // Unbound format: local-zone: "domain.com." always_null
418
+ if (trimmedRule.startsWith('local-zone: "') && trimmedRule.includes('" always_null')) {
419
+ const domain = trimmedRule.substring(13).split('"')[0];
420
+ const cleanDomain = domain.endsWith('.') ? domain.slice(0, -1) : domain;
421
+ if (isValidDomain(cleanDomain)) {
422
+ return { isValid: true, format: 'unbound', domain: cleanDomain };
423
+ } else {
424
+ return { isValid: false, format: 'unbound', error: `Invalid domain in unbound rule: ${cleanDomain}` };
425
+ }
426
+ }
427
+
428
+ // Privoxy format: { +block } .domain.com
429
+ if (trimmedRule.startsWith('{ +block } .')) {
430
+ const domain = trimmedRule.substring(12);
431
+ if (isValidDomain(domain)) {
432
+ return { isValid: true, format: 'privoxy', domain };
433
+ } else {
434
+ return { isValid: false, format: 'privoxy', error: `Invalid domain in privoxy rule: ${domain}` };
435
+ }
436
+ }
437
+
438
+ // Pi-hole regex format: (^|\.)domain\.com$
439
+ if (trimmedRule.match(/^\(\^\|\\?\.\).*\$$/)) {
440
+ const domain = trimmedRule.replace(/^\(\^\|\\?\.\)/, '').replace(/\\\./g, '.').replace(/\$$/, '');
441
+ if (isValidDomain(domain)) {
442
+ return { isValid: true, format: 'pihole', domain };
443
+ } else {
444
+ return { isValid: false, format: 'pihole', error: `Invalid domain in pihole rule: ${domain}` };
445
+ }
446
+ }
447
+
448
+ // Plain domain format
449
+ if (isValidDomain(trimmedRule)) {
450
+ return { isValid: true, format: 'plain', domain: trimmedRule };
451
+ }
452
+
453
+ return { isValid: false, format: 'unknown', error: 'Unrecognized rule format' };
454
+ }
455
+
456
+ /**
457
+ * Validates an entire ruleset file
458
+ * @param {string} filePath - Path to the file to validate
459
+ * @param {object} options - Validation options
460
+ * @returns {object} Validation results with statistics and errors
461
+ */
462
+ function validateRulesetFile(filePath, options = {}) {
463
+ const {
464
+ forceDebug = false,
465
+ silentMode = false,
466
+ maxErrors = 10
467
+ } = options;
468
+
469
+ const fs = require('fs');
470
+
471
+ if (!fs.existsSync(filePath)) {
472
+ return {
473
+ isValid: false,
474
+ error: `File not found: ${filePath}`,
475
+ stats: { total: 0, valid: 0, invalid: 0, comments: 0 }
476
+ };
477
+ }
478
+
479
+ let content;
480
+ try {
481
+ content = fs.readFileSync(filePath, 'utf8');
482
+ } catch (err) {
483
+ return {
484
+ isValid: false,
485
+ error: `Failed to read file: ${err.message}`,
486
+ stats: { total: 0, valid: 0, invalid: 0, comments: 0 }
487
+ };
488
+ }
489
+
490
+ const lines = content.split('\n');
491
+ const stats = {
492
+ total: 0,
493
+ valid: 0,
494
+ invalid: 0,
495
+ comments: 0,
496
+ formats: {}
497
+ };
498
+
499
+ const errors = [];
500
+ const duplicates = new Set();
501
+ const seenRules = new Set();
502
+
503
+ for (let i = 0; i < lines.length; i++) {
504
+ const line = lines[i].trim();
505
+
506
+ // Skip empty lines
507
+ if (!line) continue;
508
+
509
+ stats.total++;
510
+ const lineNumber = i + 1;
511
+
512
+ const validation = validateAdblockRule(line);
513
+
514
+ if (validation.format === 'comment') {
515
+ stats.comments++;
516
+ continue;
517
+ }
518
+
519
+ if (validation.isValid) {
520
+ stats.valid++;
521
+
522
+ // Track format types
523
+ if (!stats.formats[validation.format]) {
524
+ stats.formats[validation.format] = 0;
525
+ }
526
+ stats.formats[validation.format]++;
527
+
528
+ // Check for duplicates
529
+ if (seenRules.has(line)) {
530
+ duplicates.add(line);
531
+ if (forceDebug) {
532
+ errors.push(`Line ${lineNumber}: Duplicate rule - ${line}`);
533
+ }
534
+ } else {
535
+ seenRules.add(line);
536
+ }
537
+ } else {
538
+ stats.invalid++;
539
+ errors.push(`Line ${lineNumber}: ${validation.error} - ${line}`);
540
+
541
+ if (errors.length >= maxErrors) {
542
+ errors.push(`... (stopping after ${maxErrors} errors, ${stats.total - i - 1} lines remaining)`);
543
+ break;
544
+ }
545
+ }
546
+ }
547
+
548
+ // Log validation results
549
+ if (!silentMode) {
550
+ if (forceDebug) {
551
+ console.log(formatLogMessage('debug', `Validated ${filePath}:`));
552
+ console.log(formatLogMessage('debug', ` Total lines: ${stats.total} (${stats.comments} comments)`));
553
+ console.log(formatLogMessage('debug', ` Valid rules: ${stats.valid}`));
554
+ console.log(formatLogMessage('debug', ` Invalid rules: ${stats.invalid}`));
555
+ console.log(formatLogMessage('debug', ` Duplicates found: ${duplicates.size}`));
556
+
557
+ if (Object.keys(stats.formats).length > 0) {
558
+ console.log(formatLogMessage('debug', ` Format breakdown:`));
559
+ Object.entries(stats.formats).forEach(([format, count]) => {
560
+ console.log(formatLogMessage('debug', ` ${format}: ${count}`));
561
+ });
562
+ }
563
+ }
564
+
565
+ if (errors.length > 0) {
566
+ console.log(formatLogMessage('warn', `Validation errors in ${filePath}:`));
567
+ errors.slice(0, 5).forEach(error => {
568
+ console.log(formatLogMessage('warn', ` ${error}`));
569
+ });
570
+ if (errors.length > 5) {
571
+ console.log(formatLogMessage('warn', ` ... and ${errors.length - 5} more errors`));
572
+ }
573
+ }
574
+ }
575
+
576
+ return {
577
+ isValid: stats.invalid === 0,
578
+ stats,
579
+ errors,
580
+ duplicates: Array.from(duplicates),
581
+ filePath
582
+ };
583
+ }
584
+
585
+ /**
586
+ * Validates configuration object for site settings
587
+ * @param {object} siteConfig - Site configuration to validate
588
+ * @param {number} siteIndex - Index of the site for error reporting
589
+ * @returns {object} Validation result with warnings and errors
590
+ */
591
+ function validateSiteConfig(siteConfig, siteIndex = 0) {
592
+ const warnings = [];
593
+ const errors = [];
594
+
595
+ // Check required fields
596
+ if (!siteConfig.url) {
597
+ errors.push(`Site ${siteIndex}: Missing required 'url' field`);
598
+ } else {
599
+ // Validate URLs
600
+ const urls = Array.isArray(siteConfig.url) ? siteConfig.url : [siteConfig.url];
601
+ urls.forEach((url, urlIndex) => {
602
+ try {
603
+ new URL(url);
604
+ } catch (urlErr) {
605
+ errors.push(`Site ${siteIndex}, URL ${urlIndex}: Invalid URL format - ${url}`);
606
+ }
607
+ });
608
+ }
609
+
610
+ // Validate regex patterns
611
+ if (siteConfig.filterRegex) {
612
+ const regexes = Array.isArray(siteConfig.filterRegex) ? siteConfig.filterRegex : [siteConfig.filterRegex];
613
+ regexes.forEach((pattern, patternIndex) => {
614
+ const validation = validateRegexPattern(pattern);
615
+ if (!validation.isValid) {
616
+ errors.push(`Site ${siteIndex}, filterRegex ${patternIndex}: ${validation.error}`);
617
+ }
618
+ });
619
+ }
620
+
621
+ // Validate blocked patterns
622
+ if (siteConfig.blocked) {
623
+ if (!Array.isArray(siteConfig.blocked)) {
624
+ errors.push(`Site ${siteIndex}: 'blocked' must be an array`);
625
+ } else {
626
+ siteConfig.blocked.forEach((pattern, patternIndex) => {
627
+ const validation = validateRegexPattern(pattern);
628
+ if (!validation.isValid) {
629
+ errors.push(`Site ${siteIndex}, blocked ${patternIndex}: ${validation.error}`);
630
+ }
631
+ });
632
+ }
633
+ }
634
+
635
+ // Validate resource types
636
+ if (siteConfig.resourceTypes) {
637
+ if (!Array.isArray(siteConfig.resourceTypes)) {
638
+ errors.push(`Site ${siteIndex}: 'resourceTypes' must be an array`);
639
+ } else {
640
+ const validTypes = ['script', 'stylesheet', 'image', 'font', 'document', 'subdocument', 'xhr', 'fetch', 'websocket', 'media', 'ping', 'other'];
641
+ siteConfig.resourceTypes.forEach(type => {
642
+ if (!validTypes.includes(type)) {
643
+ warnings.push(`Site ${siteIndex}: Unknown resourceType '${type}'. Valid types: ${validTypes.join(', ')}`);
644
+ }
645
+ });
646
+ }
647
+ }
648
+
649
+ // Validate CSS selectors
650
+ if (siteConfig.css_blocked) {
651
+ if (!Array.isArray(siteConfig.css_blocked)) {
652
+ errors.push(`Site ${siteIndex}: 'css_blocked' must be an array`);
653
+ }
654
+ // Note: CSS selector validation would be complex, skipping for now
655
+ }
656
+
657
+ // Validate numeric fields
658
+ const numericFields = ['delay', 'reload', 'timeout'];
659
+ numericFields.forEach(field => {
660
+ if (siteConfig[field] !== undefined) {
661
+ if (typeof siteConfig[field] !== 'number' || siteConfig[field] < 0) {
662
+ errors.push(`Site ${siteIndex}: '${field}' must be a positive number`);
663
+ }
664
+ }
665
+ });
666
+
667
+ // Validate boolean fields
668
+ const booleanFields = ['interact', 'clear_sitedata', 'firstParty', 'thirdParty', 'screenshot', 'headful', 'ignore_similar', 'ignore_similar_ignored_domains'];
669
+ booleanFields.forEach(field => {
670
+ if (siteConfig[field] !== undefined && typeof siteConfig[field] !== 'boolean') {
671
+ warnings.push(`Site ${siteIndex}: '${field}' should be a boolean (true/false)`);
672
+ }
673
+ });
674
+
675
+ // Validate ignore_similar_threshold
676
+ if (siteConfig.ignore_similar_threshold !== undefined) {
677
+ if (typeof siteConfig.ignore_similar_threshold !== 'number' ||
678
+ siteConfig.ignore_similar_threshold < 0 ||
679
+ siteConfig.ignore_similar_threshold > 100) {
680
+ errors.push(`Site ${siteIndex}: 'ignore_similar_threshold' must be a number between 0 and 100`);
681
+ }
682
+ }
683
+
684
+ // Validate user agent
685
+ if (siteConfig.userAgent) {
686
+ const validUserAgents = ['chrome', 'firefox', 'safari'];
687
+ if (!validUserAgents.includes(siteConfig.userAgent.toLowerCase())) {
688
+ warnings.push(`Site ${siteIndex}: Unknown userAgent '${siteConfig.userAgent}'. Valid options: ${validUserAgents.join(', ')}`);
689
+ }
690
+ }
691
+
692
+ // Check for conflicting output format options
693
+ const outputFormats = ['localhost', 'localhost_0_0_0_0', 'plain', 'dnsmasq', 'dnsmasq_old', 'unbound', 'privoxy', 'pihole', 'adblock_rules'];
694
+ const enabledFormats = outputFormats.filter(format => siteConfig[format] === true);
695
+ if (enabledFormats.length > 1) {
696
+ warnings.push(`Site ${siteIndex}: Multiple output formats enabled (${enabledFormats.join(', ')}). Only one should be used.`);
697
+ }
698
+
699
+ return {
700
+ isValid: errors.length === 0,
701
+ warnings,
702
+ errors
703
+ };
704
+ }
705
+
706
+ /**
707
+ * Cleans a ruleset file by removing invalid lines and optionally duplicates
708
+ * @param {string} filePath - Path to the file to clean
709
+ * @param {string} outputPath - Optional output path (defaults to overwriting input file)
710
+ * @param {object} options - Cleaning options
711
+ * @returns {object} Cleaning results with statistics
712
+ */
713
+ function cleanRulesetFile(filePath, outputPath = null, options = {}) {
714
+ const {
715
+ forceDebug = false,
716
+ silentMode = false,
717
+ removeDuplicates = false,
718
+ backupOriginal = true,
719
+ dryRun = false
720
+ } = options;
721
+
722
+ const fs = require('fs');
723
+ const path = require('path');
724
+
725
+ if (!fs.existsSync(filePath)) {
726
+ return {
727
+ success: false,
728
+ error: `File not found: ${filePath}`,
729
+ stats: { total: 0, valid: 0, invalid: 0, removed: 0, duplicates: 0 }
730
+ };
731
+ }
732
+
733
+ let content;
734
+ try {
735
+ content = fs.readFileSync(filePath, 'utf8');
736
+ } catch (err) {
737
+ return {
738
+ success: false,
739
+ error: `Failed to read file: ${err.message}`,
740
+ stats: { total: 0, valid: 0, invalid: 0, removed: 0, duplicates: 0 }
741
+ };
742
+ }
743
+
744
+ const lines = content.split('\n');
745
+ const validLines = [];
746
+ const invalidLines = [];
747
+ const seenRules = new Set();
748
+ const duplicateLines = [];
749
+
750
+ const stats = {
751
+ total: 0,
752
+ valid: 0,
753
+ invalid: 0,
754
+ removed: 0,
755
+ duplicates: 0,
756
+ comments: 0,
757
+ empty: 0
758
+ };
759
+
760
+ for (let i = 0; i < lines.length; i++) {
761
+ const line = lines[i];
762
+ const trimmed = line.trim();
763
+
764
+ // Keep empty lines for formatting
765
+ if (!trimmed) {
766
+ validLines.push(line);
767
+ stats.empty++;
768
+ continue;
769
+ }
770
+
771
+ stats.total++;
772
+ const lineNumber = i + 1;
773
+
774
+ const validation = validateAdblockRule(trimmed);
775
+
776
+ // Comments are always valid
777
+ if (validation.format === 'comment') {
778
+ validLines.push(line);
779
+ stats.valid++;
780
+ stats.comments++;
781
+ continue;
782
+ }
783
+
784
+ if (validation.isValid) {
785
+ // Check for duplicates if requested
786
+ if (removeDuplicates) {
787
+ if (seenRules.has(trimmed)) {
788
+ duplicateLines.push({ line: trimmed, lineNumber });
789
+ stats.duplicates++;
790
+
791
+ if (forceDebug) {
792
+ console.log(formatLogMessage('debug', `[clean] Removing duplicate line ${lineNumber}: ${trimmed}`));
793
+ }
794
+ continue; // Skip duplicate
795
+ } else {
796
+ seenRules.add(trimmed);
797
+ }
798
+ }
799
+
800
+ validLines.push(line);
801
+ stats.valid++;
802
+ } else {
803
+ invalidLines.push({ line: trimmed, lineNumber, error: validation.error });
804
+ stats.invalid++;
805
+
806
+ if (forceDebug) {
807
+ console.log(formatLogMessage('debug', `[clean] Removing invalid line ${lineNumber}: ${trimmed} (${validation.error})`));
808
+ }
809
+ }
810
+ }
811
+
812
+ stats.removed = stats.invalid + stats.duplicates;
813
+
814
+ // Log cleaning results
815
+ if (!silentMode) {
816
+ if (forceDebug) {
817
+ console.log(formatLogMessage('debug', `Cleaning results for ${filePath}:`));
818
+ console.log(formatLogMessage('debug', ` Total lines processed: ${stats.total}`));
819
+ console.log(formatLogMessage('debug', ` Valid rules: ${stats.valid} (${stats.comments} comments)`));
820
+ console.log(formatLogMessage('debug', ` Invalid rules: ${stats.invalid}`));
821
+ console.log(formatLogMessage('debug', ` Duplicates: ${stats.duplicates}`));
822
+ console.log(formatLogMessage('debug', ` Total removed: ${stats.removed}`));
823
+ }
824
+
825
+ if (invalidLines.length > 0 && forceDebug) {
826
+ console.log(formatLogMessage('warn', `Invalid lines found:`));
827
+ invalidLines.slice(0, 5).forEach(item => {
828
+ console.log(formatLogMessage('warn', ` Line ${item.lineNumber}: ${item.error}`));
829
+ });
830
+ if (invalidLines.length > 5) {
831
+ console.log(formatLogMessage('warn', ` ... and ${invalidLines.length - 5} more invalid lines`));
832
+ }
833
+ }
834
+ }
835
+
836
+ // Create cleaned content
837
+ const cleanedContent = validLines.join('\n');
838
+
839
+ // Determine output path
840
+ const finalOutputPath = outputPath || filePath;
841
+
842
+ // Create backup if requested and not in dry run mode
843
+ if (backupOriginal && !dryRun && finalOutputPath === filePath) {
844
+ try {
845
+ const backupPath = `${filePath}.backup`;
846
+ fs.copyFileSync(filePath, backupPath);
847
+ if (forceDebug) {
848
+ console.log(formatLogMessage('debug', `Created backup: ${backupPath}`));
849
+ }
850
+ } catch (backupErr) {
851
+ return {
852
+ success: false,
853
+ error: `Failed to create backup: ${backupErr.message}`,
854
+ stats
855
+ };
856
+ }
857
+ }
858
+
859
+ // Write cleaned file (unless dry run)
860
+ if (!dryRun) {
861
+ try {
862
+ fs.writeFileSync(finalOutputPath, cleanedContent);
863
+ if (forceDebug) {
864
+ console.log(formatLogMessage('debug', `Wrote cleaned file: ${finalOutputPath}`));
865
+ }
866
+ } catch (writeErr) {
867
+ return {
868
+ success: false,
869
+ error: `Failed to write cleaned file: ${writeErr.message}`,
870
+ stats
871
+ };
872
+ }
873
+ }
874
+
875
+ return {
876
+ success: true,
877
+ stats,
878
+ invalidLines,
879
+ duplicateLines,
880
+ modified: stats.removed > 0,
881
+ wouldModify: dryRun && stats.removed > 0,
882
+ backupCreated: backupOriginal && !dryRun && finalOutputPath === filePath
883
+ };
884
+ }
885
+
886
+ /**
887
+ * Validates full configuration object
888
+ * @param {object} config - Complete configuration object
889
+ * @param {object} options - Validation options
890
+ * @returns {object} Comprehensive validation result
891
+ */
892
+ function validateFullConfig(config, options = {}) {
893
+ const { forceDebug = false, silentMode = false } = options;
894
+ const globalErrors = [];
895
+ const siteValidations = [];
896
+
897
+ // Validate global configuration
898
+ if (!config) {
899
+ return {
900
+ isValid: false,
901
+ globalErrors: ['Configuration object is required'],
902
+ siteValidations: [],
903
+ summary: { totalSites: 0, validSites: 0, sitesWithErrors: 0, sitesWithWarnings: 0 }
904
+ };
905
+ }
906
+
907
+ // Validate sites array
908
+ if (!config.sites || !Array.isArray(config.sites)) {
909
+ globalErrors.push('Configuration must contain a "sites" array');
910
+ } else if (config.sites.length === 0) {
911
+ globalErrors.push('Sites array cannot be empty');
912
+ }
913
+
914
+ // Validate global blocked patterns
915
+ if (config.blocked && !Array.isArray(config.blocked)) {
916
+ globalErrors.push('Global "blocked" must be an array');
917
+ } else if (config.blocked) {
918
+ config.blocked.forEach((pattern, index) => {
919
+ const validation = validateRegexPattern(pattern);
920
+ if (!validation.isValid) {
921
+ globalErrors.push(`Global blocked pattern ${index}: ${validation.error}`);
922
+ }
923
+ });
924
+ }
925
+
926
+ // Validate global ignore_similar settings
927
+ if (config.ignore_similar !== undefined && typeof config.ignore_similar !== 'boolean') {
928
+ globalErrors.push('Global "ignore_similar" must be a boolean (true/false)');
929
+ }
930
+
931
+ if (config.ignore_similar_threshold !== undefined) {
932
+ if (typeof config.ignore_similar_threshold !== 'number' ||
933
+ config.ignore_similar_threshold < 0 ||
934
+ config.ignore_similar_threshold > 100) {
935
+ globalErrors.push('Global "ignore_similar_threshold" must be a number between 0 and 100');
936
+ }
937
+ }
938
+
939
+ if (config.ignore_similar_ignored_domains !== undefined && typeof config.ignore_similar_ignored_domains !== 'boolean') {
940
+ globalErrors.push('Global "ignore_similar_ignored_domains" must be a boolean (true/false)');
941
+ }
942
+
943
+ // Validate individual sites
944
+ if (config.sites && Array.isArray(config.sites)) {
945
+ config.sites.forEach((site, index) => {
946
+ const siteValidation = validateSiteConfig(site, index);
947
+ siteValidations.push(siteValidation);
948
+ });
949
+ }
950
+
951
+ // Calculate summary
952
+ const summary = {
953
+ totalSites: siteValidations.length,
954
+ validSites: siteValidations.filter(v => v.isValid).length,
955
+ sitesWithErrors: siteValidations.filter(v => v.errors.length > 0).length,
956
+ sitesWithWarnings: siteValidations.filter(v => v.warnings.length > 0).length
957
+ };
958
+
959
+ const isValid = globalErrors.length === 0 && summary.sitesWithErrors === 0;
960
+
961
+ return {
962
+ isValid,
963
+ globalErrors,
964
+ siteValidations,
965
+ summary
966
+ };
967
+ }
968
+
969
+ /**
970
+ * Test domain validation with known test cases
971
+ * @returns {boolean} True if all tests pass
972
+ */
973
+ function testDomainValidation() {
974
+ const testCases = [
975
+ // Valid domains
976
+ { domain: 'example.com', expected: true },
977
+ { domain: 'sub.example.com', expected: true },
978
+ { domain: 'test-site.co.uk', expected: true },
979
+ { domain: '192.168.1.1', expected: true }, // IPv4
980
+ { domain: '2001:db8::1', expected: true }, // IPv6
981
+
982
+ // Invalid domains
983
+ { domain: '', expected: false },
984
+ { domain: 'example', expected: false },
985
+ { domain: '.example.com', expected: false },
986
+ { domain: 'example.com.', expected: false },
987
+ { domain: 'ex..ample.com', expected: false },
988
+ { domain: '-example.com', expected: false }
989
+ ];
990
+
991
+ let allPassed = true;
992
+
993
+ testCases.forEach(({ domain, expected }) => {
994
+ const result = isValidDomain(domain);
995
+ if (result !== expected) {
996
+ console.error(`Test failed for domain "${domain}": expected ${expected}, got ${result}`);
997
+ allPassed = false;
998
+ }
999
+ });
1000
+
1001
+ return allPassed;
1002
+ }
1003
+
1004
+ /**
1005
+ * Test adblock rule validation with known test cases
1006
+ * @returns {boolean} True if all tests pass
1007
+ */
1008
+ function testAdblockValidation() {
1009
+ const testCases = [
1010
+ // Valid rules
1011
+ { rule: '||example.com^', expected: true },
1012
+ { rule: '||example.com^$script', expected: true },
1013
+ { rule: '127.0.0.1 example.com', expected: true },
1014
+ { rule: 'local=/example.com/', expected: true },
1015
+
1016
+ // Invalid rules
1017
+ { rule: '', expected: false },
1018
+ { rule: '||invalid..domain^', expected: false },
1019
+ { rule: '||.example.com^', expected: false }
1020
+ ];
1021
+
1022
+ let allPassed = true;
1023
+
1024
+ testCases.forEach(({ rule, expected }) => {
1025
+ const result = validateAdblockRule(rule);
1026
+ if (result.isValid !== expected) {
1027
+ console.error(`Test failed for rule "${rule}": expected ${expected}, got ${result.isValid}`);
1028
+ allPassed = false;
1029
+ }
1030
+ });
1031
+
1032
+ return allPassed;
1033
+ }
1034
+
1035
+ /**
1036
+ * Validates a domain and formats it according to specified output options
1037
+ * @param {string} domain - The domain to validate and format
1038
+ * @param {object} options - Formatting options
1039
+ * @returns {string|object} Formatted domain string or error object
1040
+ */
1041
+ function formatDomainWithValidation(domain, options = {}) {
1042
+ const {
1043
+ localhost = false,
1044
+ localhostAlt = false,
1045
+ plain = false,
1046
+ dnsmasq = false,
1047
+ dnsmasqOld = false,
1048
+ unbound = false,
1049
+ privoxy = false,
1050
+ pihole = false,
1051
+ adblockRules = false,
1052
+ resourceType = ''
1053
+ } = options;
1054
+
1055
+ // Validate domain first
1056
+ if (!isValidDomain(domain)) {
1057
+ return {
1058
+ isValid: false,
1059
+ error: `Invalid domain format: ${domain}`,
1060
+ formattedRule: null
1061
+ };
1062
+ }
1063
+
1064
+ // Format according to specified options (priority order)
1065
+ if (pihole) {
1066
+ const escapedDomain = domain.replace(/\./g, '\\.');
1067
+ return `(^|\\.)${escapedDomain}$`;
1068
+ } else if (privoxy) {
1069
+ return `{ +block } .${domain}`;
1070
+ } else if (dnsmasq) {
1071
+ return `local=/${domain}/`;
1072
+ } else if (dnsmasqOld) {
1073
+ return `server=/${domain}/`;
1074
+ } else if (unbound) {
1075
+ return `local-zone: "${domain}." always_null`;
1076
+ } else if (localhost) {
1077
+ return `127.0.0.1 ${domain}`;
1078
+ } else if (localhostAlt) {
1079
+ return `0.0.0.0 ${domain}`;
1080
+ } else if (adblockRules && resourceType) {
1081
+ return `||${domain}^$${resourceType}`;
1082
+ } else if (plain) {
1083
+ return domain;
1084
+ } else {
1085
+ // Default adblock format
1086
+ return `||${domain}^`;
1087
+ }
1088
+ }
1089
+
1090
+ module.exports = {
1091
+ isValidDomain,
1092
+ isValidDomainLabel,
1093
+ isValidTLD,
1094
+ isIPAddress,
1095
+ isIPv4,
1096
+ isIPv6,
1097
+ validateRegexPattern,
1098
+ validateAdblockModifiers,
1099
+ validateAdblockRule,
1100
+ validateRulesetFile,
1101
+ cleanRulesetFile,
1102
+ validateSiteConfig,
1103
+ validateFullConfig,
1104
+ testDomainValidation,
1105
+ testAdblockValidation,
1106
+ formatDomainWithValidation
1107
+ };