safe-link-checker 1.0.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.cjs ADDED
@@ -0,0 +1,1986 @@
1
+ 'use strict';
2
+
3
+ var normalizeUrl = require('normalize-url');
4
+ var isURL = require('validator/lib/isURL.js');
5
+ var ipaddr = require('ipaddr.js');
6
+ var https = require('https');
7
+ var http = require('http');
8
+ var dns = require('dns');
9
+ var punycode = require('punycode');
10
+ var tldts = require('tldts');
11
+
12
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
13
+
14
+ var normalizeUrl__default = /*#__PURE__*/_interopDefault(normalizeUrl);
15
+ var isURL__default = /*#__PURE__*/_interopDefault(isURL);
16
+ var ipaddr__default = /*#__PURE__*/_interopDefault(ipaddr);
17
+ var https__default = /*#__PURE__*/_interopDefault(https);
18
+ var http__default = /*#__PURE__*/_interopDefault(http);
19
+ var dns__default = /*#__PURE__*/_interopDefault(dns);
20
+ var punycode__default = /*#__PURE__*/_interopDefault(punycode);
21
+
22
+ // src/utils/normalize.ts
23
+ var TRACKING_PARAMS = [/^utm_\w+/i, "fbclid", "gclid"];
24
+ function normalizeLink(url, options) {
25
+ const trimmed = url.trim();
26
+ try {
27
+ const u = new URL(trimmed);
28
+ if (!options?.removeTrackingParams || !u.search) {
29
+ if (u.pathname === "/" && !u.search && !u.hash) {
30
+ let simple = trimmed;
31
+ if (simple.endsWith("/")) simple = simple.slice(0, -1);
32
+ if (simple === u.origin) return simple;
33
+ }
34
+ }
35
+ } catch {
36
+ }
37
+ try {
38
+ return normalizeUrl__default.default(trimmed, {
39
+ stripWWW: false,
40
+ removeTrailingSlash: true,
41
+ stripHash: true,
42
+ sortQueryParameters: true,
43
+ removeQueryParameters: options?.removeTrackingParams ? TRACKING_PARAMS : []
44
+ });
45
+ } catch {
46
+ return trimmed;
47
+ }
48
+ }
49
+
50
+ // src/utils/score.ts
51
+ function calculateScore(validators) {
52
+ let totalPenalty = 0;
53
+ let safe = true;
54
+ const reasons = [];
55
+ const recommendations = [];
56
+ for (const v of validators) {
57
+ if (!v.safe) {
58
+ safe = false;
59
+ if (v.message) {
60
+ reasons.push(`[${v.name}] ${v.message}`);
61
+ }
62
+ switch (v.name) {
63
+ case "IP Validator":
64
+ recommendations.push("Avoid interacting with URLs that resolve to private or local network addresses.");
65
+ break;
66
+ case "Punycode Validator":
67
+ recommendations.push("Be cautious of potential homograph attacks; visually inspect the domain name.");
68
+ break;
69
+ case "HTTPS Validator":
70
+ recommendations.push("Prefer links that use secure HTTPS connections to protect your data.");
71
+ break;
72
+ case "Shortener Validator":
73
+ recommendations.push("URL shorteners can mask malicious destinations; verify the expanded URL before trusting it.");
74
+ break;
75
+ case "Heuristics Validator":
76
+ recommendations.push("The URL contains suspicious patterns, excessive subdomains, or lookalike domain traits; verify the source carefully.");
77
+ break;
78
+ case "URL Validator":
79
+ recommendations.push("Ensure the URL is correctly formatted and uses a supported protocol (http/https).");
80
+ break;
81
+ }
82
+ }
83
+ const weight = v.weight ?? 1;
84
+ totalPenalty += v.scoreImpact * weight;
85
+ }
86
+ let score = 100 - totalPenalty;
87
+ if (score < 0) {
88
+ score = 0;
89
+ } else if (score > 100) {
90
+ score = 100;
91
+ }
92
+ let riskLevel = "SAFE";
93
+ if (score <= 49) {
94
+ riskLevel = "DANGEROUS";
95
+ } else if (score <= 89) {
96
+ riskLevel = "SUSPICIOUS";
97
+ }
98
+ return {
99
+ score,
100
+ riskLevel,
101
+ safe,
102
+ reasons,
103
+ recommendations: [...new Set(recommendations)]
104
+ };
105
+ }
106
+ function validateUrl(urlStr) {
107
+ const trimmed = urlStr.trim();
108
+ const checkURL = isURL__default.default;
109
+ if (!checkURL(trimmed, { require_protocol: true })) {
110
+ return {
111
+ name: "URL Validator",
112
+ detector: "url-syntax",
113
+ category: "domain",
114
+ severity: "critical",
115
+ safe: false,
116
+ scoreImpact: 100,
117
+ title: "Malformed URL Syntax",
118
+ message: "Malformed URL: Failed syntax validation.",
119
+ fatal: true
120
+ };
121
+ }
122
+ if (/[\r\n\t]/.test(trimmed)) {
123
+ return {
124
+ name: "URL Validator",
125
+ detector: "url-smuggling",
126
+ category: "network",
127
+ severity: "critical",
128
+ safe: false,
129
+ scoreImpact: 100,
130
+ title: "HTTP Smuggling Attempt",
131
+ message: "Malformed URL: Contains illegal characters (CR, LF, or Tab).",
132
+ fatal: true
133
+ };
134
+ }
135
+ try {
136
+ const parsed = new URL(trimmed);
137
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
138
+ return {
139
+ name: "URL Validator",
140
+ detector: "url-protocol",
141
+ category: "network",
142
+ severity: "high",
143
+ safe: false,
144
+ scoreImpact: 100,
145
+ title: "Unsupported Protocol",
146
+ message: `Unsupported protocol "${parsed.protocol}". Only HTTP and HTTPS are allowed.`,
147
+ fatal: true
148
+ };
149
+ }
150
+ if (!parsed.hostname) {
151
+ return {
152
+ name: "URL Validator",
153
+ detector: "url-hostname",
154
+ category: "domain",
155
+ severity: "critical",
156
+ safe: false,
157
+ scoreImpact: 100,
158
+ title: "Missing Hostname",
159
+ message: "Invalid URL: Hostname is missing.",
160
+ fatal: true
161
+ };
162
+ }
163
+ return {
164
+ name: "URL Validator",
165
+ detector: "url-validator",
166
+ category: "domain",
167
+ severity: "info",
168
+ safe: true,
169
+ scoreImpact: 0,
170
+ title: "Valid URL",
171
+ message: "URL is valid and protocol is supported."
172
+ };
173
+ } catch (err) {
174
+ return {
175
+ name: "URL Validator",
176
+ detector: "url-parser",
177
+ category: "domain",
178
+ severity: "critical",
179
+ safe: false,
180
+ scoreImpact: 100,
181
+ title: "Parser Error",
182
+ message: `Malformed URL: ${err instanceof Error ? err.message : String(err)}`,
183
+ fatal: true
184
+ };
185
+ }
186
+ }
187
+ var LOCAL_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "broadcasthost"]);
188
+ var LOCAL_HOSTNAME_SUFFIX = ".local";
189
+ var BLOCKED_IPV4_RANGES = [
190
+ "loopback",
191
+ // 127.0.0.0/8
192
+ "private",
193
+ // 10/8, 172.16/12, 192.168/16
194
+ "linkLocal",
195
+ // 169.254.0.0/16
196
+ "broadcast",
197
+ // 255.255.255.255/32
198
+ "carrierGradeNat",
199
+ // 100.64.0.0/10
200
+ "unspecified"
201
+ // 0.0.0.0
202
+ ];
203
+ var BLOCKED_IPV6_RANGES = [
204
+ "loopback",
205
+ // ::1
206
+ "linkLocal",
207
+ // fe80::/10
208
+ "uniqueLocal",
209
+ // fc00::/7 (fc/fd)
210
+ "unspecified"
211
+ // ::
212
+ ];
213
+ function describeRange(range) {
214
+ const descriptions = {
215
+ loopback: "loopback address",
216
+ private: "private network address (RFC 1918)",
217
+ linkLocal: "link-local address",
218
+ broadcast: "broadcast address",
219
+ carrierGradeNat: "carrier-grade NAT address",
220
+ uniqueLocal: "unique local IPv6 address",
221
+ unspecified: "unspecified address"
222
+ };
223
+ return descriptions[range] ?? `reserved range "${range}"`;
224
+ }
225
+ function validateIp(urlStr) {
226
+ let hostname;
227
+ try {
228
+ hostname = new URL(urlStr).hostname.toLowerCase();
229
+ } catch {
230
+ return { name: "IP Validator", detector: "ip-parser", category: "network", severity: "info", safe: true, scoreImpact: 0, title: "Unparseable IP", message: "Could not parse hostname." };
231
+ }
232
+ const bracketStripped = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
233
+ const rawHost = bracketStripped.includes("%") ? bracketStripped.slice(0, bracketStripped.indexOf("%")) : bracketStripped;
234
+ if (LOCAL_HOSTNAMES.has(rawHost)) {
235
+ return {
236
+ name: "IP Validator",
237
+ detector: "ip-localhost",
238
+ category: "network",
239
+ severity: "critical",
240
+ safe: false,
241
+ scoreImpact: 100,
242
+ title: "Localhost Address Detected",
243
+ message: `High risk: "${rawHost}" resolves to a local/loopback address.`,
244
+ fatal: true
245
+ };
246
+ }
247
+ if (rawHost.endsWith(LOCAL_HOSTNAME_SUFFIX)) {
248
+ return {
249
+ name: "IP Validator",
250
+ detector: "ip-mdns",
251
+ category: "network",
252
+ severity: "critical",
253
+ safe: false,
254
+ scoreImpact: 100,
255
+ title: "mDNS Address Detected",
256
+ message: `High risk: "${rawHost}" is a link-local mDNS hostname (.local).`,
257
+ fatal: true
258
+ };
259
+ }
260
+ if (!ipaddr__default.default.isValid(rawHost)) {
261
+ return { name: "IP Validator", detector: "ip-domain", category: "network", severity: "info", safe: true, scoreImpact: 0, title: "Standard Domain", message: "Hostname is a domain name, not a raw IP." };
262
+ }
263
+ const addr = ipaddr__default.default.parse(rawHost);
264
+ if (addr.kind() === "ipv4") {
265
+ const range = addr.range();
266
+ if (BLOCKED_IPV4_RANGES.includes(range)) {
267
+ return {
268
+ name: "IP Validator",
269
+ detector: "ip-v4-private",
270
+ category: "network",
271
+ severity: "critical",
272
+ safe: false,
273
+ scoreImpact: 100,
274
+ title: "Private IPv4 Address",
275
+ message: `High risk: IP address is a ${describeRange(range)}.`,
276
+ fatal: true
277
+ };
278
+ }
279
+ } else {
280
+ const v6 = addr;
281
+ const range = v6.range();
282
+ if (BLOCKED_IPV6_RANGES.includes(range)) {
283
+ return {
284
+ name: "IP Validator",
285
+ detector: "ip-v6-private",
286
+ category: "network",
287
+ severity: "critical",
288
+ safe: false,
289
+ scoreImpact: 100,
290
+ title: "Private IPv6 Address",
291
+ message: `High risk: IPv6 address is a ${describeRange(range)}.`,
292
+ fatal: true
293
+ };
294
+ }
295
+ if (v6.isIPv4MappedAddress()) {
296
+ const v4 = v6.toIPv4Address();
297
+ const v4Range = v4.range();
298
+ if (BLOCKED_IPV4_RANGES.includes(v4Range)) {
299
+ return {
300
+ name: "IP Validator",
301
+ detector: "ip-v6-mapped-private",
302
+ category: "network",
303
+ severity: "critical",
304
+ safe: false,
305
+ scoreImpact: 100,
306
+ title: "Private IPv4-Mapped IPv6 Address",
307
+ message: `High risk: IPv4-mapped IPv6 address maps to a ${describeRange(v4Range)}.`,
308
+ fatal: true
309
+ };
310
+ }
311
+ }
312
+ }
313
+ return {
314
+ name: "IP Validator",
315
+ detector: "ip-public",
316
+ category: "network",
317
+ severity: "info",
318
+ safe: true,
319
+ scoreImpact: 0,
320
+ title: "Public IP",
321
+ message: "IP address is not in a private or reserved range."
322
+ };
323
+ }
324
+ var CERT_ERROR_CODES = /* @__PURE__ */ new Set([
325
+ "CERT_HAS_EXPIRED",
326
+ "CERT_NOT_YET_VALID",
327
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
328
+ "SELF_SIGNED_CERT_IN_CHAIN",
329
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
330
+ "CERT_SIGNATURE_FAILURE",
331
+ "UNABLE_TO_GET_ISSUER_CERT",
332
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
333
+ "ERR_TLS_CERT_ALTNAME_INVALID"
334
+ ]);
335
+ var TIMEOUT_ERROR_CODES = /* @__PURE__ */ new Set([
336
+ "ETIMEDOUT",
337
+ "ECONNRESET",
338
+ "SOCKET_TIMEOUT"
339
+ ]);
340
+ function makeResult(status, safe, scoreImpact, title, message, severity) {
341
+ return {
342
+ name: "HTTPS Validator",
343
+ detector: "https-probe",
344
+ category: "certificate",
345
+ severity,
346
+ title,
347
+ safe,
348
+ scoreImpact,
349
+ message,
350
+ metadata: { httpsStatus: status }
351
+ };
352
+ }
353
+ async function validateHttps(urlStr, timeoutMs = 5e3, signal) {
354
+ let parsed;
355
+ try {
356
+ parsed = new URL(urlStr);
357
+ } catch {
358
+ return makeResult("SKIPPED", true, 0, "Unparseable URL", "Could not parse URL for HTTPS check.", "info");
359
+ }
360
+ if (parsed.protocol === "http:") {
361
+ const httpsUrl = urlStr.replace(/^http:/, "https:");
362
+ const httpsResult = await probeHttps(httpsUrl, timeoutMs, signal);
363
+ if (httpsResult.status === "HTTPS") {
364
+ return makeResult(
365
+ "HTTP_ONLY",
366
+ false,
367
+ 20,
368
+ "Unencrypted Connection",
369
+ "URL uses HTTP. An HTTPS version is available but the link uses an unencrypted connection.",
370
+ "medium"
371
+ );
372
+ }
373
+ if (httpsResult.status === "CERT_ERROR") {
374
+ return makeResult(
375
+ "CERT_ERROR",
376
+ false,
377
+ 40,
378
+ "Certificate Error",
379
+ `TLS/SSL certificate error on HTTPS probe: ${httpsResult.detail}`,
380
+ "critical"
381
+ );
382
+ }
383
+ return makeResult(
384
+ "HTTP_ONLY",
385
+ false,
386
+ 20,
387
+ "Unencrypted Connection",
388
+ "URL uses HTTP. Could not confirm whether an HTTPS version is available.",
389
+ "medium"
390
+ );
391
+ }
392
+ const result = await probeHttps(urlStr, timeoutMs, signal);
393
+ switch (result.status) {
394
+ case "HTTPS":
395
+ return makeResult("HTTPS", true, 0, "Secure Connection", "HTTPS is enabled and the certificate is valid.", "info");
396
+ case "CERT_ERROR":
397
+ return makeResult(
398
+ "CERT_ERROR",
399
+ false,
400
+ 40,
401
+ "Certificate Error",
402
+ `TLS/SSL certificate error: ${result.detail}`,
403
+ "critical"
404
+ );
405
+ case "TIMEOUT":
406
+ return makeResult(
407
+ "TIMEOUT",
408
+ true,
409
+ 0,
410
+ "Connection Timeout",
411
+ "HTTPS probe timed out. The server may be slow or rate-limiting. No penalty applied.",
412
+ "info"
413
+ );
414
+ default:
415
+ return makeResult(
416
+ "UNREACHABLE",
417
+ true,
418
+ 0,
419
+ "Host Unreachable",
420
+ `HTTPS probe could not reach the server: ${result.detail}. No penalty applied.`,
421
+ "info"
422
+ );
423
+ }
424
+ }
425
+ function probeHttps(urlStr, timeoutMs, signal) {
426
+ return new Promise((resolve) => {
427
+ let settled = false;
428
+ const settle = (r) => {
429
+ if (!settled) {
430
+ settled = true;
431
+ resolve(r);
432
+ }
433
+ };
434
+ let parsed;
435
+ try {
436
+ parsed = new URL(urlStr);
437
+ } catch {
438
+ return settle({ status: "UNREACHABLE", detail: "Malformed URL" });
439
+ }
440
+ const options = {
441
+ hostname: parsed.hostname,
442
+ port: parsed.port || 443,
443
+ path: parsed.pathname + parsed.search,
444
+ method: "HEAD",
445
+ timeout: timeoutMs,
446
+ signal
447
+ // We intentionally do NOT set rejectUnauthorized:false so cert errors surface
448
+ };
449
+ const req = https__default.default.request(options, (res) => {
450
+ res.resume();
451
+ settle({ status: "HTTPS", detail: `HTTP ${res.statusCode}` });
452
+ });
453
+ req.on("timeout", () => {
454
+ req.destroy();
455
+ settle({ status: "TIMEOUT", detail: "Request timed out" });
456
+ });
457
+ req.on("error", (err) => {
458
+ const code = err.code ?? "";
459
+ if (CERT_ERROR_CODES.has(code)) {
460
+ return settle({ status: "CERT_ERROR", detail: err.message });
461
+ }
462
+ if (TIMEOUT_ERROR_CODES.has(code)) {
463
+ return settle({ status: "TIMEOUT", detail: err.message });
464
+ }
465
+ settle({ status: "UNREACHABLE", detail: err.message });
466
+ });
467
+ req.end();
468
+ });
469
+ }
470
+ var safeLookup = (hostname, options, callback) => {
471
+ dns__default.default.lookup(hostname, options, (err, address, family) => {
472
+ if (err) {
473
+ return callback(err, address, family);
474
+ }
475
+ let ipToCheck = null;
476
+ if (typeof address === "string") {
477
+ ipToCheck = address;
478
+ } else if (Array.isArray(address) && address.length > 0) {
479
+ const first = address[0];
480
+ if (first && typeof first === "object" && "address" in first) {
481
+ ipToCheck = first.address;
482
+ }
483
+ }
484
+ if (ipToCheck) {
485
+ const urlStr = ipToCheck.includes(":") ? `http://[${ipToCheck}]` : `http://${ipToCheck}`;
486
+ const ipResult = validateIp(urlStr);
487
+ if (!ipResult.safe && process.env.NODE_ENV !== "test") {
488
+ return callback(new Error(`Security Exception: DNS Rebinding Blocked. Hostname resolved to forbidden IP: ${ipToCheck}`), address, family);
489
+ }
490
+ }
491
+ callback(null, address, family);
492
+ });
493
+ };
494
+
495
+ // src/validators/redirect.ts
496
+ var DEFAULT_MAX_REDIRECTS = 5;
497
+ var DEFAULT_TIMEOUT_MS = 5e3;
498
+ var REDIRECT_CODES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
499
+ async function traceRedirects(urlStr, options = {}) {
500
+ const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
501
+ const timeoutMs = options.timeout ?? DEFAULT_TIMEOUT_MS;
502
+ const chain = [urlStr];
503
+ const seen = /* @__PURE__ */ new Set([urlStr]);
504
+ const anomalies = [];
505
+ let current = urlStr;
506
+ for (let hop = 0; hop < maxRedirects; hop++) {
507
+ const result = await headRequest(current, timeoutMs, options.signal);
508
+ if (!result) {
509
+ break;
510
+ }
511
+ if (!REDIRECT_CODES.has(result.statusCode)) {
512
+ break;
513
+ }
514
+ const location = result.location;
515
+ if (!location) {
516
+ break;
517
+ }
518
+ let nextUrl;
519
+ try {
520
+ nextUrl = new URL(location, current).toString();
521
+ } catch {
522
+ break;
523
+ }
524
+ if (seen.has(nextUrl)) {
525
+ if (!anomalies.includes("LOOP")) anomalies.push("LOOP");
526
+ break;
527
+ }
528
+ if (current.startsWith("https://") && nextUrl.startsWith("http://")) {
529
+ if (!anomalies.includes("PROTOCOL_DOWNGRADE")) anomalies.push("PROTOCOL_DOWNGRADE");
530
+ }
531
+ seen.add(nextUrl);
532
+ chain.push(nextUrl);
533
+ current = nextUrl;
534
+ }
535
+ if (chain.length - 1 >= maxRedirects) {
536
+ const result = await headRequest(current, timeoutMs, options.signal);
537
+ if (result && REDIRECT_CODES.has(result.statusCode)) {
538
+ if (!anomalies.includes("MAX_REDIRECTS_EXCEEDED")) {
539
+ anomalies.push("MAX_REDIRECTS_EXCEEDED");
540
+ }
541
+ }
542
+ }
543
+ return {
544
+ chain,
545
+ finalUrl: current,
546
+ redirectCount: chain.length - 1,
547
+ anomalies
548
+ };
549
+ }
550
+ function headRequest(urlStr, timeoutMs, signal) {
551
+ return new Promise((resolve) => {
552
+ let settled = false;
553
+ const settle = (v) => {
554
+ if (!settled) {
555
+ settled = true;
556
+ resolve(v);
557
+ }
558
+ };
559
+ let parsed;
560
+ try {
561
+ parsed = new URL(urlStr);
562
+ } catch {
563
+ return settle(null);
564
+ }
565
+ const isHttps = parsed.protocol === "https:";
566
+ const requester = isHttps ? https__default.default : http__default.default;
567
+ const options = {
568
+ hostname: parsed.hostname,
569
+ port: parsed.port || (isHttps ? 443 : 80),
570
+ path: parsed.pathname + parsed.search,
571
+ method: "HEAD",
572
+ timeout: timeoutMs,
573
+ signal,
574
+ lookup: safeLookup,
575
+ // SSRF & DNS Rebinding Protection
576
+ // Skip cert validation so we can follow chains even with bad certs
577
+ // (the https validator is responsible for cert scoring separately)
578
+ rejectUnauthorized: false
579
+ };
580
+ const req = requester.request(options, (res) => {
581
+ res.resume();
582
+ settle({
583
+ statusCode: res.statusCode ?? 0,
584
+ location: Array.isArray(res.headers.location) ? res.headers.location[0] : res.headers.location
585
+ });
586
+ });
587
+ req.setTimeout(timeoutMs, () => {
588
+ req.destroy();
589
+ settle(null);
590
+ });
591
+ req.on("timeout", () => {
592
+ req.destroy();
593
+ settle(null);
594
+ });
595
+ req.on("error", () => settle(null));
596
+ req.end();
597
+ });
598
+ }
599
+ function validatePunycode(urlStr) {
600
+ let hostname;
601
+ try {
602
+ hostname = new URL(urlStr).hostname;
603
+ } catch {
604
+ return { name: "Punycode Validator", detector: "punycode-parser", category: "domain", severity: "info", safe: true, scoreImpact: 0, title: "Unparseable URL", message: "Invalid URL." };
605
+ }
606
+ if (!hostname.includes("xn--")) {
607
+ return {
608
+ name: "Punycode Validator",
609
+ detector: "punycode-clean",
610
+ category: "domain",
611
+ severity: "info",
612
+ safe: true,
613
+ scoreImpact: 0,
614
+ title: "Standard Domain",
615
+ message: "No Punycode detected."
616
+ };
617
+ }
618
+ const labels = hostname.split(".");
619
+ for (const label of labels) {
620
+ if (label.startsWith("xn--")) {
621
+ const decodedLabel = punycode__default.default.toUnicode(label);
622
+ const hasLatin = /[a-zA-Z]/.test(decodedLabel);
623
+ const hasCyrillic = /[\u0400-\u04FF]/.test(decodedLabel);
624
+ const hasGreek = /[\u0370-\u03FF]/.test(decodedLabel);
625
+ const scripts = [hasLatin, hasCyrillic, hasGreek].filter(Boolean);
626
+ if (scripts.length > 1) {
627
+ return {
628
+ name: "Punycode Validator",
629
+ detector: "punycode-mixed-script",
630
+ category: "domain",
631
+ severity: "critical",
632
+ safe: false,
633
+ scoreImpact: 100,
634
+ title: "Mixed-Script Homograph Attack",
635
+ message: `High risk: Mixed-script label "${decodedLabel}" detected (homograph attack indicator).`,
636
+ fatal: true
637
+ };
638
+ }
639
+ if (/[\u0430\u0435\u043E\u0440\u0441\u0443\u0445\u03BF\u03C1\u200B\u200C\u200D\uFEFF\u202A-\u202E]/u.test(decodedLabel)) {
640
+ return {
641
+ name: "Punycode Validator",
642
+ detector: "punycode-confusable",
643
+ category: "domain",
644
+ severity: "critical",
645
+ safe: false,
646
+ scoreImpact: 100,
647
+ // Elevated severity for explicit confusable/invisible characters
648
+ title: "Confusable Characters Detected",
649
+ message: `High risk: Label "${decodedLabel}" contains highly suspicious confusable or invisible Unicode characters.`,
650
+ fatal: true
651
+ };
652
+ }
653
+ }
654
+ }
655
+ const decodedHost = punycode__default.default.toUnicode(hostname);
656
+ return {
657
+ name: "Punycode Validator",
658
+ detector: "punycode-usage",
659
+ category: "domain",
660
+ severity: "medium",
661
+ safe: false,
662
+ scoreImpact: 30,
663
+ // Base impact for xn-- domains
664
+ title: "Internationalized Domain Name",
665
+ message: `Warning: URL uses Punycode (xn--). Decoded: ${decodedHost}`
666
+ };
667
+ }
668
+
669
+ // src/validators/shortener.ts
670
+ var DEFAULT_SHORTENERS = /* @__PURE__ */ new Set([
671
+ "bit.ly",
672
+ "tinyurl.com",
673
+ "t.co",
674
+ "shorturl.at",
675
+ "rb.gy",
676
+ "rebrand.ly",
677
+ "cutt.ly",
678
+ "tiny.cc"
679
+ ]);
680
+ function validateShortener(urlStr, customShorteners = []) {
681
+ let hostname;
682
+ try {
683
+ hostname = new URL(urlStr).hostname.toLowerCase();
684
+ } catch {
685
+ return { name: "Shortener Validator", detector: "shortener-parser", category: "domain", severity: "info", safe: true, scoreImpact: 0, title: "Unparseable URL", message: "Invalid URL." };
686
+ }
687
+ const isShortener = DEFAULT_SHORTENERS.has(hostname) || customShorteners.includes(hostname);
688
+ if (isShortener) {
689
+ return {
690
+ name: "Shortener Validator",
691
+ detector: "shortener-detected",
692
+ category: "domain",
693
+ severity: "medium",
694
+ title: "URL Shortener Detected",
695
+ safe: false,
696
+ // We flag it as unsafe/warning so it impacts score or user is warned
697
+ scoreImpact: 10,
698
+ message: `Warning: URL uses a shortening service (${hostname}). It will be expanded.`,
699
+ metadata: { isShortener: true }
700
+ };
701
+ }
702
+ return {
703
+ name: "Shortener Validator",
704
+ detector: "shortener-clean",
705
+ category: "domain",
706
+ severity: "info",
707
+ title: "No Shortener Detected",
708
+ safe: true,
709
+ scoreImpact: 0,
710
+ message: "URL does not appear to be a known shortening service.",
711
+ metadata: { isShortener: false }
712
+ };
713
+ }
714
+ var SUSPICIOUS_TLDS = /* @__PURE__ */ new Set([
715
+ "tk",
716
+ "ml",
717
+ "ga",
718
+ "cf",
719
+ "gq",
720
+ "zip",
721
+ "mov",
722
+ "cc",
723
+ "ru",
724
+ "cn",
725
+ "pw",
726
+ "top",
727
+ "xyz",
728
+ "icu",
729
+ "wang",
730
+ "space",
731
+ "work"
732
+ ]);
733
+ var SUSPICIOUS_KEYWORDS = [
734
+ "login",
735
+ "admin",
736
+ "secure",
737
+ "update",
738
+ "billing",
739
+ "banking",
740
+ "verify",
741
+ "account",
742
+ "password",
743
+ "credential",
744
+ "auth",
745
+ "support",
746
+ "service"
747
+ ];
748
+ var HIGH_PROFILE_TARGETS = [
749
+ "google",
750
+ "apple",
751
+ "facebook",
752
+ "microsoft",
753
+ "amazon",
754
+ "paypal",
755
+ "netflix",
756
+ "chase",
757
+ "bankofamerica",
758
+ "linkedin",
759
+ "twitter",
760
+ "instagram"
761
+ ];
762
+ function levenshtein(a, b) {
763
+ const matrix = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
764
+ for (let i = 0; i <= a.length; i++) matrix[i][0] = i;
765
+ for (let j = 0; j <= b.length; j++) matrix[0][j] = j;
766
+ for (let i = 1; i <= a.length; i++) {
767
+ for (let j = 1; j <= b.length; j++) {
768
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
769
+ matrix[i][j] = Math.min(
770
+ matrix[i - 1][j] + 1,
771
+ matrix[i][j - 1] + 1,
772
+ matrix[i - 1][j - 1] + cost
773
+ );
774
+ }
775
+ }
776
+ return matrix[a.length][b.length];
777
+ }
778
+ function validateHeuristics(url) {
779
+ const result = {
780
+ name: "Heuristics Validator",
781
+ detector: "heuristics-engine",
782
+ category: "behavior",
783
+ severity: "info",
784
+ title: "Heuristics Check Passed",
785
+ safe: true,
786
+ scoreImpact: 0,
787
+ message: "",
788
+ metadata: {
789
+ flags: []
790
+ }
791
+ };
792
+ const parsed = tldts.parse(url);
793
+ const flags = [];
794
+ let penalty = 0;
795
+ if (parsed.isIp) {
796
+ flags.push("raw_ip");
797
+ }
798
+ if (parsed.publicSuffix && SUSPICIOUS_TLDS.has(parsed.publicSuffix.toLowerCase())) {
799
+ flags.push("suspicious_tld");
800
+ penalty += 20;
801
+ }
802
+ if (parsed.subdomain) {
803
+ const depth = parsed.subdomain.split(".").length;
804
+ if (depth >= 3) {
805
+ flags.push("excessive_subdomains");
806
+ penalty += 15;
807
+ }
808
+ }
809
+ const lowerUrl = url.toLowerCase();
810
+ for (const keyword of SUSPICIOUS_KEYWORDS) {
811
+ if (lowerUrl.includes(keyword)) {
812
+ flags.push(`suspicious_keyword:${keyword}`);
813
+ penalty += 10;
814
+ }
815
+ }
816
+ if (url.includes("@")) {
817
+ flags.push("credential_trick");
818
+ penalty += 30;
819
+ }
820
+ const encodedCount = (url.match(/%[0-9A-Fa-f]{2}/g) || []).length;
821
+ if (encodedCount > 10) {
822
+ flags.push("excessive_encoding");
823
+ penalty += 15;
824
+ }
825
+ if (parsed.domainWithoutSuffix) {
826
+ const domainName = parsed.domainWithoutSuffix.toLowerCase();
827
+ for (const target of HIGH_PROFILE_TARGETS) {
828
+ if (domainName === target) {
829
+ continue;
830
+ }
831
+ const distance = levenshtein(domainName, target);
832
+ if (distance > 0 && distance <= 2) {
833
+ flags.push(`lookalike_domain:${target}`);
834
+ penalty += 40;
835
+ break;
836
+ }
837
+ const deobfuscated = domainName.replace(/0/g, "o").replace(/1/g, "l").replace(/3/g, "e").replace(/5/g, "s");
838
+ if (deobfuscated === target && deobfuscated !== domainName) {
839
+ flags.push(`lookalike_domain_substituted:${target}`);
840
+ penalty += 50;
841
+ break;
842
+ }
843
+ }
844
+ }
845
+ if (penalty > 0) {
846
+ result.safe = false;
847
+ result.scoreImpact = Math.min(100, penalty);
848
+ result.metadata.flags = flags;
849
+ result.title = "Suspicious Heuristics Detected";
850
+ result.message = `Detected suspicious heuristics: ${flags.join(", ")}`;
851
+ if (result.scoreImpact >= 50) {
852
+ result.severity = "critical";
853
+ } else if (result.scoreImpact >= 30) {
854
+ result.severity = "high";
855
+ } else {
856
+ result.severity = "medium";
857
+ }
858
+ }
859
+ return result;
860
+ }
861
+
862
+ // src/verify.ts
863
+ async function verifyLink(url, options = {}) {
864
+ const normalizedUrl = normalizeLink(url, options);
865
+ const urlVal = validateUrl(normalizedUrl);
866
+ if (!urlVal.safe) {
867
+ const scoreInfo2 = calculateScore([urlVal]);
868
+ return {
869
+ url,
870
+ normalizedUrl,
871
+ safe: scoreInfo2.safe,
872
+ score: scoreInfo2.score,
873
+ confidence: 100,
874
+ riskLevel: scoreInfo2.riskLevel,
875
+ reasons: scoreInfo2.reasons,
876
+ recommendations: scoreInfo2.recommendations,
877
+ redirectChain: [],
878
+ redirectTrace: { chain: [], finalUrl: normalizedUrl, redirectCount: 0, anomalies: [] },
879
+ checks: [urlVal],
880
+ fromCache: false
881
+ };
882
+ }
883
+ const shortVal = validateShortener(normalizedUrl, options.customShorteners);
884
+ const isShortener = shortVal.metadata?.isShortener === true;
885
+ const initialIpVal = validateIp(normalizedUrl);
886
+ if (!initialIpVal.safe) {
887
+ const heurVal2 = validateHeuristics(normalizedUrl);
888
+ const checks2 = [urlVal, shortVal, initialIpVal, heurVal2];
889
+ const scoreInfo2 = calculateScore(checks2);
890
+ return {
891
+ url,
892
+ normalizedUrl,
893
+ safe: scoreInfo2.safe,
894
+ score: scoreInfo2.score,
895
+ confidence: 100,
896
+ riskLevel: scoreInfo2.riskLevel,
897
+ reasons: scoreInfo2.reasons,
898
+ recommendations: scoreInfo2.recommendations,
899
+ redirectChain: [],
900
+ redirectTrace: { chain: [], finalUrl: normalizedUrl, redirectCount: 0, anomalies: [] },
901
+ checks: checks2,
902
+ fromCache: false
903
+ };
904
+ }
905
+ const redirectTrace = await traceRedirects(normalizedUrl, options);
906
+ const targetUrl = isShortener ? redirectTrace.finalUrl : normalizedUrl;
907
+ let ipVal = initialIpVal;
908
+ if (isShortener && targetUrl !== normalizedUrl) {
909
+ ipVal = validateIp(targetUrl);
910
+ if (!ipVal.safe) {
911
+ const heurVal2 = validateHeuristics(targetUrl);
912
+ const checks2 = [urlVal, shortVal, ipVal, heurVal2];
913
+ const scoreInfo2 = calculateScore(checks2);
914
+ return {
915
+ url,
916
+ normalizedUrl,
917
+ safe: scoreInfo2.safe,
918
+ score: scoreInfo2.score,
919
+ confidence: 100,
920
+ riskLevel: scoreInfo2.riskLevel,
921
+ reasons: scoreInfo2.reasons,
922
+ recommendations: scoreInfo2.recommendations,
923
+ redirectChain: redirectTrace.chain,
924
+ redirectTrace,
925
+ checks: checks2,
926
+ fromCache: false
927
+ };
928
+ }
929
+ }
930
+ const punyVal = validatePunycode(targetUrl);
931
+ const heurVal = validateHeuristics(targetUrl);
932
+ let checks = [urlVal, shortVal, ipVal, punyVal, heurVal];
933
+ if (options.checkHttps !== false) {
934
+ const timeout = options.timeout ?? 5e3;
935
+ const httpsVal = await validateHttps(targetUrl, timeout, options.signal);
936
+ checks.push(httpsVal);
937
+ }
938
+ const scoreInfo = calculateScore(checks);
939
+ return {
940
+ url,
941
+ normalizedUrl,
942
+ safe: scoreInfo.safe,
943
+ score: scoreInfo.score,
944
+ confidence: 100,
945
+ riskLevel: scoreInfo.riskLevel,
946
+ reasons: scoreInfo.reasons,
947
+ recommendations: scoreInfo.recommendations,
948
+ redirectChain: redirectTrace.chain,
949
+ redirectTrace,
950
+ checks,
951
+ fromCache: false
952
+ };
953
+ }
954
+
955
+ // src/cache/lru.ts
956
+ var LRUCache = class {
957
+ cache;
958
+ maxSize;
959
+ ttlMs;
960
+ constructor(options = {}) {
961
+ this.maxSize = options.maxSize ?? 1e3;
962
+ this.ttlMs = options.ttlMs ?? 1e3 * 60 * 60;
963
+ this.cache = /* @__PURE__ */ new Map();
964
+ }
965
+ get(url) {
966
+ const entry = this.cache.get(url);
967
+ if (!entry) return null;
968
+ if (Date.now() > entry.expiresAt) {
969
+ this.cache.delete(url);
970
+ return null;
971
+ }
972
+ this.cache.delete(url);
973
+ this.cache.set(url, entry);
974
+ return entry.value;
975
+ }
976
+ set(url, result) {
977
+ if (this.cache.size >= this.maxSize && !this.cache.has(url)) {
978
+ const firstKey = this.cache.keys().next().value;
979
+ if (firstKey !== void 0) {
980
+ this.cache.delete(firstKey);
981
+ }
982
+ }
983
+ this.cache.delete(url);
984
+ this.cache.set(url, {
985
+ value: result,
986
+ expiresAt: Date.now() + this.ttlMs
987
+ });
988
+ }
989
+ clear() {
990
+ this.cache.clear();
991
+ }
992
+ get size() {
993
+ return this.cache.size;
994
+ }
995
+ };
996
+
997
+ // src/cache/memory.ts
998
+ var defaultCache = new LRUCache();
999
+ async function extractMetadata(urlStr, timeout = 5e3) {
1000
+ return new Promise((resolve) => {
1001
+ let settled = false;
1002
+ const settle = (v) => {
1003
+ if (!settled) {
1004
+ settled = true;
1005
+ resolve(v);
1006
+ }
1007
+ };
1008
+ let parsed;
1009
+ try {
1010
+ parsed = new URL(urlStr);
1011
+ } catch {
1012
+ return settle(null);
1013
+ }
1014
+ const isHttps = parsed.protocol === "https:";
1015
+ const requester = isHttps ? https__default.default : http__default.default;
1016
+ const options = {
1017
+ hostname: parsed.hostname,
1018
+ port: parsed.port || (isHttps ? 443 : 80),
1019
+ path: parsed.pathname + parsed.search,
1020
+ method: "GET",
1021
+ timeout,
1022
+ lookup: safeLookup,
1023
+ // SSRF & DNS Rebinding Protection
1024
+ headers: {
1025
+ "User-Agent": "safe-link-checker-bot/1.0",
1026
+ "Accept": "text/html",
1027
+ "Accept-Encoding": "identity"
1028
+ // Prevent compression bombs
1029
+ }
1030
+ // Do not follow redirects here (we already follow them in RedirectTrace!)
1031
+ };
1032
+ const req = requester.request(options, (res) => {
1033
+ const contentType = res.headers["content-type"] || "";
1034
+ if (res.statusCode !== 200 || !contentType.toLowerCase().includes("text/html")) {
1035
+ res.resume();
1036
+ return settle(null);
1037
+ }
1038
+ let html = "";
1039
+ let bytesRead = 0;
1040
+ const MAX_SIZE = 1024 * 500;
1041
+ res.on("data", (chunk) => {
1042
+ bytesRead += chunk.length;
1043
+ if (bytesRead > MAX_SIZE) {
1044
+ req.destroy();
1045
+ parseHtml(html, urlStr, settle);
1046
+ return;
1047
+ }
1048
+ html += chunk.toString("utf8");
1049
+ });
1050
+ res.on("end", () => {
1051
+ if (!settled) parseHtml(html, urlStr, settle);
1052
+ });
1053
+ res.on("error", () => settle(null));
1054
+ });
1055
+ req.setTimeout(timeout, () => {
1056
+ req.destroy();
1057
+ settle(null);
1058
+ });
1059
+ req.on("timeout", () => {
1060
+ req.destroy();
1061
+ settle(null);
1062
+ });
1063
+ req.on("error", () => settle(null));
1064
+ req.end();
1065
+ });
1066
+ }
1067
+ function parseHtml(html, url, settle) {
1068
+ try {
1069
+ const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
1070
+ const parseArea = headMatch && headMatch[1] ? headMatch[1] : html;
1071
+ const result = { url };
1072
+ const extractContent = (pattern) => {
1073
+ const match = parseArea.match(pattern);
1074
+ return match ? match[1]?.trim() : void 0;
1075
+ };
1076
+ const ogTitle = extractContent(/<meta[^>]*property=["']og:title["'][^>]*content=["']([^"']+)["'][^>]*>/i);
1077
+ const metaTitle = extractContent(/<title[^>]*>([^<]+)<\/title>/i);
1078
+ const title = ogTitle || metaTitle;
1079
+ if (title !== void 0) result.title = title;
1080
+ const ogDesc = extractContent(/<meta[^>]*property=["']og:description["'][^>]*content=["']([^"']+)["'][^>]*>/i);
1081
+ const metaDesc = extractContent(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["'][^>]*>/i);
1082
+ const description = ogDesc || metaDesc;
1083
+ if (description !== void 0) result.description = description;
1084
+ const ogImage = extractContent(/<meta[^>]*property=["']og:image["'][^>]*content=["']([^"']+)["'][^>]*>/i);
1085
+ if (ogImage !== void 0) result.image = ogImage;
1086
+ const icon1 = extractContent(/<link[^>]*rel=["']icon["'][^>]*href=["']([^"']+)["'][^>]*>/i);
1087
+ const icon2 = extractContent(/<link[^>]*rel=["']shortcut icon["'][^>]*href=["']([^"']+)["'][^>]*>/i);
1088
+ const favicon = icon1 || icon2;
1089
+ if (favicon !== void 0) result.favicon = favicon;
1090
+ if (result.favicon && !result.favicon.startsWith("http")) {
1091
+ try {
1092
+ result.favicon = new URL(result.favicon, url).href;
1093
+ } catch {
1094
+ }
1095
+ }
1096
+ if (result.image && !result.image.startsWith("http")) {
1097
+ try {
1098
+ result.image = new URL(result.image, url).href;
1099
+ } catch {
1100
+ }
1101
+ }
1102
+ settle(result);
1103
+ } catch {
1104
+ settle(null);
1105
+ }
1106
+ }
1107
+
1108
+ // src/providers/urlhaus.ts
1109
+ var URLHausProvider = class {
1110
+ name = "URLHaus Provider";
1111
+ cache = new LRUCache({ maxSize: 1e3, ttlMs: 1e3 * 60 * 60 * 24 });
1112
+ // 24h cache
1113
+ offlineDataset = /* @__PURE__ */ new Set();
1114
+ datasetLoaded = false;
1115
+ updateInterval = null;
1116
+ async init() {
1117
+ await this.updateDataset();
1118
+ this.updateInterval = setInterval(() => {
1119
+ this.updateDataset().catch(() => {
1120
+ });
1121
+ }, 1e3 * 60 * 60 * 24);
1122
+ if (this.updateInterval.unref) {
1123
+ this.updateInterval.unref();
1124
+ }
1125
+ }
1126
+ async updateDataset() {
1127
+ try {
1128
+ const controller = new AbortController();
1129
+ const timeoutId = setTimeout(() => controller.abort(), 1e4);
1130
+ const res = await fetch("https://urlhaus.abuse.ch/downloads/csv_online/", {
1131
+ signal: controller.signal
1132
+ });
1133
+ clearTimeout(timeoutId);
1134
+ if (!res.ok) return;
1135
+ const text = await res.text();
1136
+ const lines = text.split("\n");
1137
+ const newDataset = /* @__PURE__ */ new Set();
1138
+ for (const line of lines) {
1139
+ if (line.startsWith("#") || !line.trim()) continue;
1140
+ const parts = line.split('","');
1141
+ if (parts.length > 2) {
1142
+ const url = parts[2]?.replace(/"/g, "").trim();
1143
+ if (url) newDataset.add(url);
1144
+ }
1145
+ }
1146
+ this.offlineDataset = newDataset;
1147
+ this.datasetLoaded = true;
1148
+ } catch {
1149
+ }
1150
+ }
1151
+ async check(url, options) {
1152
+ const cached = this.cache.get(url);
1153
+ if (cached) {
1154
+ return cached;
1155
+ }
1156
+ let result = null;
1157
+ if (this.datasetLoaded) {
1158
+ if (this.offlineDataset.has(url)) {
1159
+ result = {
1160
+ name: this.name,
1161
+ detector: "urlhaus-offline",
1162
+ category: "provider",
1163
+ severity: "critical",
1164
+ title: "Malware Site Detected (URLHaus)",
1165
+ safe: false,
1166
+ scoreImpact: 100,
1167
+ weight: 100,
1168
+ confidence: 99,
1169
+ message: "URL is listed on URLHaus (offline dataset) as a known malware distribution site.",
1170
+ fatal: true
1171
+ };
1172
+ } else {
1173
+ result = {
1174
+ name: this.name,
1175
+ detector: "urlhaus-offline",
1176
+ category: "provider",
1177
+ severity: "info",
1178
+ title: "Not Listed",
1179
+ safe: true,
1180
+ scoreImpact: 0,
1181
+ confidence: 90,
1182
+ // offline may be slightly stale
1183
+ message: "URL not found in URLHaus database (offline)."
1184
+ };
1185
+ }
1186
+ } else {
1187
+ result = await this.doCheckOnline(url, options);
1188
+ }
1189
+ if (result) {
1190
+ this.cache.set(url, result);
1191
+ }
1192
+ return result;
1193
+ }
1194
+ async doCheckOnline(url, options) {
1195
+ try {
1196
+ const form = new URLSearchParams();
1197
+ form.append("url", url);
1198
+ const controller = new AbortController();
1199
+ const timeout = 3e3;
1200
+ let timeoutId;
1201
+ const signal = options?.signal || (() => {
1202
+ timeoutId = setTimeout(() => controller.abort(), timeout);
1203
+ return controller.signal;
1204
+ })();
1205
+ try {
1206
+ const response = await fetch("https://urlhaus-api.abuse.ch/v1/url/", {
1207
+ method: "POST",
1208
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1209
+ body: form.toString(),
1210
+ signal
1211
+ });
1212
+ if (!response.ok) throw new Error("Network response was not ok");
1213
+ const data = await response.json();
1214
+ if (data && data.query_status === "ok") {
1215
+ return {
1216
+ name: this.name,
1217
+ detector: "urlhaus-online",
1218
+ category: "provider",
1219
+ severity: "critical",
1220
+ title: "Malware Site Detected (URLHaus)",
1221
+ safe: false,
1222
+ scoreImpact: 100,
1223
+ weight: 100,
1224
+ // Very high severity
1225
+ confidence: 99,
1226
+ message: "URL is listed on URLHaus as a known malware distribution site.",
1227
+ metadata: { threat: data.threat },
1228
+ fatal: true
1229
+ };
1230
+ }
1231
+ return {
1232
+ name: this.name,
1233
+ detector: "urlhaus-online",
1234
+ category: "provider",
1235
+ severity: "info",
1236
+ title: "Not Listed",
1237
+ safe: true,
1238
+ scoreImpact: 0,
1239
+ confidence: 100,
1240
+ message: "URL not found in URLHaus database."
1241
+ };
1242
+ } finally {
1243
+ if (timeoutId) clearTimeout(timeoutId);
1244
+ }
1245
+ } catch {
1246
+ return {
1247
+ name: this.name,
1248
+ detector: "urlhaus-error",
1249
+ category: "provider",
1250
+ severity: "medium",
1251
+ title: "Provider Check Failed",
1252
+ safe: true,
1253
+ scoreImpact: 0,
1254
+ confidence: 0,
1255
+ message: "URLHaus check failed or timed out. Skipping."
1256
+ };
1257
+ }
1258
+ }
1259
+ };
1260
+
1261
+ // src/providers/openphish.ts
1262
+ var OpenPhishProvider = class {
1263
+ name = "OpenPhish Provider";
1264
+ cache = new LRUCache({ maxSize: 1e3, ttlMs: 1e3 * 60 * 60 * 24 });
1265
+ // 24h cache
1266
+ async check(url, options) {
1267
+ const cached = this.cache.get(url);
1268
+ if (cached) {
1269
+ return cached;
1270
+ }
1271
+ const result = await this.doCheck(url, options);
1272
+ if (result) {
1273
+ this.cache.set(url, result);
1274
+ }
1275
+ return result;
1276
+ }
1277
+ async doCheck(_url, _options) {
1278
+ try {
1279
+ return {
1280
+ name: this.name,
1281
+ detector: "openphish-simulated",
1282
+ category: "provider",
1283
+ severity: "info",
1284
+ title: "OpenPhish Not Listed",
1285
+ safe: true,
1286
+ scoreImpact: 0,
1287
+ confidence: 100,
1288
+ message: "OpenPhish check passed (Simulated)."
1289
+ };
1290
+ } catch {
1291
+ return {
1292
+ name: this.name,
1293
+ detector: "openphish-error",
1294
+ category: "provider",
1295
+ severity: "medium",
1296
+ title: "Provider Check Failed",
1297
+ safe: true,
1298
+ scoreImpact: 0,
1299
+ confidence: 0,
1300
+ message: "OpenPhish check failed or timed out. Skipping."
1301
+ };
1302
+ }
1303
+ }
1304
+ };
1305
+
1306
+ // src/core/events.ts
1307
+ var EventEmitter = class {
1308
+ listeners = /* @__PURE__ */ new Map();
1309
+ on(event, callback) {
1310
+ if (!this.listeners.has(event)) {
1311
+ this.listeners.set(event, /* @__PURE__ */ new Set());
1312
+ }
1313
+ this.listeners.get(event).add(callback);
1314
+ }
1315
+ off(event, callback) {
1316
+ const eventListeners = this.listeners.get(event);
1317
+ if (eventListeners) {
1318
+ eventListeners.delete(callback);
1319
+ }
1320
+ }
1321
+ emit(event, arg1, arg2) {
1322
+ const eventListeners = this.listeners.get(event);
1323
+ if (eventListeners) {
1324
+ if (arg2 !== void 0) {
1325
+ for (const listener of eventListeners) {
1326
+ listener(arg1, arg2);
1327
+ }
1328
+ } else if (arg1 !== void 0) {
1329
+ for (const listener of eventListeners) {
1330
+ listener(arg1);
1331
+ }
1332
+ } else {
1333
+ for (const listener of eventListeners) {
1334
+ listener();
1335
+ }
1336
+ }
1337
+ }
1338
+ }
1339
+ };
1340
+
1341
+ // src/core/plugin.ts
1342
+ var PluginManager = class {
1343
+ plugins = [];
1344
+ register(plugin) {
1345
+ if (this.plugins.some((p) => p.name === plugin.name)) {
1346
+ throw new Error(`Plugin ${plugin.name} is already registered.`);
1347
+ }
1348
+ this.plugins.push(plugin);
1349
+ }
1350
+ async initializeAll() {
1351
+ await Promise.all(
1352
+ this.plugins.map((p) => p.initialize ? p.initialize() : Promise.resolve())
1353
+ );
1354
+ }
1355
+ async disposeAll() {
1356
+ await Promise.all(
1357
+ this.plugins.map((p) => p.dispose ? p.dispose() : Promise.resolve())
1358
+ );
1359
+ }
1360
+ getPluginsByType(type) {
1361
+ return this.plugins.filter((p) => p.type === type);
1362
+ }
1363
+ getAll() {
1364
+ return this.plugins;
1365
+ }
1366
+ };
1367
+
1368
+ // src/engine/consensus.ts
1369
+ var ConsensusEngine = class {
1370
+ config;
1371
+ constructor(config) {
1372
+ this.config = {
1373
+ baseScore: 100,
1374
+ riskThresholds: {
1375
+ suspicious: 75,
1376
+ dangerous: 40
1377
+ },
1378
+ ...config
1379
+ };
1380
+ }
1381
+ evaluate(results) {
1382
+ let score = this.config.baseScore;
1383
+ let accumulatedConfidence = 0;
1384
+ let totalWeight = 0;
1385
+ const reasons = [];
1386
+ const validResults = results.filter((r) => r !== null);
1387
+ if (validResults.length === 0) {
1388
+ return {
1389
+ score: this.config.baseScore,
1390
+ trustScore: 100,
1391
+ confidence: 0,
1392
+ riskLevel: "SAFE",
1393
+ reasons: ["No checks performed"],
1394
+ safe: true,
1395
+ summary: "No data available to verify this URL."
1396
+ };
1397
+ }
1398
+ let hasCritical = false;
1399
+ for (const res of validResults) {
1400
+ const weight = res.weight ?? 1;
1401
+ let conf = res.confidence !== void 0 ? res.confidence : 100;
1402
+ if (conf <= 1 && conf > 0) conf = conf * 100;
1403
+ const impact = res.scoreContribution ?? res.scoreImpact ?? 0;
1404
+ const adjustedPenalty = impact * weight * (conf / 100);
1405
+ score -= adjustedPenalty;
1406
+ accumulatedConfidence += conf * weight;
1407
+ totalWeight += weight;
1408
+ if (res.severity === "critical" || res.fatal) hasCritical = true;
1409
+ if (!res.safe || impact > 0 || res.severity === "high" || res.severity === "critical") {
1410
+ const title = res.title ?? res.name;
1411
+ reasons.push(`${title}: ${res.description ?? res.message}`);
1412
+ }
1413
+ }
1414
+ score = Math.max(0, Math.min(100, Math.round(score)));
1415
+ let trustScore = score;
1416
+ const averageConfidence = totalWeight > 0 ? Math.round(accumulatedConfidence / totalWeight) : 0;
1417
+ let riskLevel = "SAFE";
1418
+ if (score <= this.config.riskThresholds.dangerous || hasCritical) {
1419
+ riskLevel = "DANGEROUS";
1420
+ trustScore = 0;
1421
+ } else if (score <= this.config.riskThresholds.suspicious) {
1422
+ riskLevel = "SUSPICIOUS";
1423
+ }
1424
+ let summary = "This website appears legitimate.";
1425
+ if (riskLevel === "DANGEROUS") {
1426
+ summary = "This website should not be visited. It has been flagged as dangerous.";
1427
+ } else if (riskLevel === "SUSPICIOUS") {
1428
+ summary = "This website contains suspicious elements and should be approached with caution.";
1429
+ }
1430
+ return {
1431
+ score,
1432
+ trustScore,
1433
+ confidence: averageConfidence,
1434
+ riskLevel,
1435
+ reasons,
1436
+ safe: riskLevel === "SAFE",
1437
+ summary
1438
+ };
1439
+ }
1440
+ };
1441
+
1442
+ // src/engine/policy.ts
1443
+ var PolicyEngine = class {
1444
+ policies = /* @__PURE__ */ new Map();
1445
+ constructor() {
1446
+ this.registerBuiltIns();
1447
+ }
1448
+ registerBuiltIns() {
1449
+ this.policies.set("strict", (ctx) => {
1450
+ if (ctx.riskLevel !== "SAFE" || ctx.score > 0) {
1451
+ return { decision: "block", action: "Strict policy blocked any non-zero risk score" };
1452
+ }
1453
+ return { decision: "allow", action: "Strict policy allowed safe URL" };
1454
+ });
1455
+ this.policies.set("balanced", (ctx) => {
1456
+ if (ctx.riskLevel === "DANGEROUS") {
1457
+ return { decision: "block", action: "Blocked dangerous URL" };
1458
+ }
1459
+ if (ctx.riskLevel === "SUSPICIOUS") {
1460
+ return { decision: "warn", action: "Display warning screen" };
1461
+ }
1462
+ return { decision: "allow", action: "Allowed safe URL" };
1463
+ });
1464
+ this.policies.set("enterprise", (ctx) => {
1465
+ if (ctx.riskLevel === "DANGEROUS") {
1466
+ return { decision: "block", action: "Enterprise policy blocked dangerous URL" };
1467
+ }
1468
+ if (ctx.score > 20) {
1469
+ return { decision: "review", action: "Sent to SOC for manual review" };
1470
+ }
1471
+ if (ctx.riskLevel === "SUSPICIOUS") {
1472
+ return { decision: "warn", action: "Warn user before proceeding" };
1473
+ }
1474
+ return { decision: "allow", action: "Enterprise allowed safe URL" };
1475
+ });
1476
+ }
1477
+ register(name, policy) {
1478
+ this.policies.set(name, policy);
1479
+ }
1480
+ evaluate(policyName, ctx) {
1481
+ const name = policyName || "balanced";
1482
+ const policy = this.policies.get(name);
1483
+ if (!policy) {
1484
+ return this.policies.get("balanced")(ctx);
1485
+ }
1486
+ return policy(ctx);
1487
+ }
1488
+ };
1489
+
1490
+ // src/engine/rules.ts
1491
+ var RuleEnginePlugin = class {
1492
+ id = "core:rule-engine";
1493
+ name = "RuleEngine";
1494
+ version = "1.0.0";
1495
+ description = "Executes custom user-defined and built-in rules";
1496
+ author = "SafeLink Team";
1497
+ type = "heuristic";
1498
+ capabilities = ["custom-rules", "policy-enforcement"];
1499
+ priority = 10;
1500
+ weight = 1;
1501
+ rules = /* @__PURE__ */ new Map();
1502
+ constructor() {
1503
+ this.registerBuiltIns();
1504
+ }
1505
+ registerBuiltIns() {
1506
+ this.rules.set("require-https", (ctx) => {
1507
+ if (ctx.url.startsWith("http://") && ctx.options.checkHttps !== false) {
1508
+ return {
1509
+ name: "Rule: require-https",
1510
+ detector: "rule-engine",
1511
+ category: "network",
1512
+ severity: "high",
1513
+ safe: false,
1514
+ scoreImpact: 20,
1515
+ confidence: 1,
1516
+ message: "HTTP is not allowed by policy",
1517
+ fatal: false
1518
+ };
1519
+ }
1520
+ return null;
1521
+ });
1522
+ }
1523
+ register(name, rule) {
1524
+ this.rules.set(name, rule);
1525
+ }
1526
+ async execute(ctx) {
1527
+ for (const [, rule] of this.rules) {
1528
+ try {
1529
+ const res = await rule(ctx);
1530
+ if (res) {
1531
+ return res;
1532
+ }
1533
+ } catch {
1534
+ }
1535
+ }
1536
+ return null;
1537
+ }
1538
+ };
1539
+
1540
+ // src/plugins/core/provider.ts
1541
+ var ProviderPluginAdapter = class {
1542
+ constructor(provider) {
1543
+ this.provider = provider;
1544
+ }
1545
+ provider;
1546
+ type = "provider";
1547
+ version = "1.0.0";
1548
+ description = "External threat intelligence provider adapter";
1549
+ author = "SafeLink Team";
1550
+ capabilities = ["threat-intelligence"];
1551
+ priority = 50;
1552
+ weight = 1;
1553
+ get id() {
1554
+ return `provider:${this.provider.name.toLowerCase()}`;
1555
+ }
1556
+ get name() {
1557
+ return this.provider.name;
1558
+ }
1559
+ async execute(ctx) {
1560
+ const urlToTest = ctx.state.finalUrl || ctx.normalizedUrl;
1561
+ const res = await this.provider.check(urlToTest, ctx.options);
1562
+ if (!res) return null;
1563
+ return { ...res, confidence: res.safe ? 80 : 100 };
1564
+ }
1565
+ };
1566
+
1567
+ // src/plugins/core/basic.ts
1568
+ var UrlValidationPlugin = class {
1569
+ id = "core:url-validation";
1570
+ name = "UrlValidation";
1571
+ version = "1.0.0";
1572
+ description = "Validates URL structure and syntax";
1573
+ author = "SafeLink Team";
1574
+ type = "network";
1575
+ capabilities = ["url-parsing", "syntax-check"];
1576
+ priority = 100;
1577
+ weight = 1;
1578
+ async execute(ctx) {
1579
+ const res = validateUrl(ctx.normalizedUrl);
1580
+ return { ...res, confidence: 100, fatal: !res.safe };
1581
+ }
1582
+ };
1583
+ var IpValidationPlugin = class {
1584
+ id = "core:ip-validation";
1585
+ name = "IpValidation";
1586
+ version = "1.0.0";
1587
+ description = "Validates IP addresses and checks for loopback/private IPs";
1588
+ author = "SafeLink Team";
1589
+ type = "network";
1590
+ capabilities = ["ip-check", "ssrf-prevention"];
1591
+ priority = 90;
1592
+ weight = 1;
1593
+ async execute(ctx) {
1594
+ const res = validateIp(ctx.normalizedUrl);
1595
+ return { ...res, confidence: 100, fatal: !res.safe };
1596
+ }
1597
+ };
1598
+ var HttpsValidationPlugin = class {
1599
+ id = "core:https-validation";
1600
+ name = "HttpsValidation";
1601
+ version = "1.0.0";
1602
+ description = "Validates HTTPS certificate and connection";
1603
+ author = "SafeLink Team";
1604
+ type = "network";
1605
+ capabilities = ["tls-check", "certificate-validation"];
1606
+ priority = 80;
1607
+ weight = 1;
1608
+ async execute(ctx) {
1609
+ if (ctx.options.checkHttps === false) {
1610
+ return null;
1611
+ }
1612
+ const timeout = ctx.options.timeout ?? 5e3;
1613
+ const res = await validateHttps(ctx.normalizedUrl, timeout, ctx.options.signal);
1614
+ return { ...res, confidence: 95 };
1615
+ }
1616
+ };
1617
+
1618
+ // src/plugins/core/redirect.ts
1619
+ var RedirectPlugin = class {
1620
+ id = "core:redirect-trace";
1621
+ name = "RedirectTrace";
1622
+ version = "1.0.0";
1623
+ description = "Traces URL redirects to final destination";
1624
+ author = "SafeLink Team";
1625
+ type = "network";
1626
+ capabilities = ["redirect-following", "chain-analysis"];
1627
+ priority = 95;
1628
+ weight = 1;
1629
+ async execute(ctx) {
1630
+ try {
1631
+ const trace = await traceRedirects(ctx.normalizedUrl, ctx.options);
1632
+ ctx.state.redirectTrace = trace;
1633
+ ctx.state.finalUrl = trace.finalUrl;
1634
+ let scoreImpact = 0;
1635
+ let message = `Followed ${trace.redirectCount} redirects.`;
1636
+ let safe = true;
1637
+ let severity = "info";
1638
+ if (trace.anomalies.includes("LOOP")) {
1639
+ scoreImpact += 40;
1640
+ message = "Redirect loop detected.";
1641
+ safe = false;
1642
+ severity = "critical";
1643
+ }
1644
+ if (trace.anomalies.includes("MAX_REDIRECTS_EXCEEDED")) {
1645
+ scoreImpact += 20;
1646
+ message = "Maximum redirects exceeded.";
1647
+ safe = false;
1648
+ severity = severity === "info" ? "high" : severity;
1649
+ }
1650
+ if (trace.anomalies.includes("PROTOCOL_DOWNGRADE")) {
1651
+ scoreImpact += 30;
1652
+ message = "Protocol downgrade (HTTPS to HTTP) detected.";
1653
+ safe = false;
1654
+ severity = severity === "info" ? "critical" : severity;
1655
+ }
1656
+ return {
1657
+ name: this.name,
1658
+ detector: "redirect-trace",
1659
+ category: "redirect",
1660
+ severity,
1661
+ title: "Redirect Trace Analysis",
1662
+ safe,
1663
+ scoreImpact,
1664
+ message,
1665
+ confidence: 95
1666
+ };
1667
+ } catch (e) {
1668
+ return {
1669
+ name: this.name,
1670
+ detector: "redirect-trace",
1671
+ category: "redirect",
1672
+ severity: "medium",
1673
+ title: "Redirect Trace Failure",
1674
+ safe: false,
1675
+ scoreImpact: 10,
1676
+ message: `Redirect trace failed: ${e instanceof Error ? e.message : "Unknown error"}`,
1677
+ confidence: 80
1678
+ };
1679
+ }
1680
+ }
1681
+ };
1682
+
1683
+ // src/plugins/core/heuristics.ts
1684
+ var ShortenerPlugin = class {
1685
+ id = "core:shortener";
1686
+ name = "ShortenerDetection";
1687
+ version = "1.0.0";
1688
+ description = "Detects if the URL is a known link shortener";
1689
+ author = "SafeLink Team";
1690
+ type = "heuristic";
1691
+ capabilities = ["url-expansion", "shortener-detection"];
1692
+ priority = 85;
1693
+ weight = 1;
1694
+ async execute(ctx) {
1695
+ const res = validateShortener(ctx.normalizedUrl, ctx.options.customShorteners);
1696
+ ctx.state.isShortener = res.metadata?.isShortener === true;
1697
+ return { ...res, confidence: 95 };
1698
+ }
1699
+ };
1700
+ var PunycodePlugin = class {
1701
+ id = "core:punycode";
1702
+ name = "PunycodeDetection";
1703
+ version = "1.0.0";
1704
+ description = "Detects Punycode and homograph attacks in domains";
1705
+ author = "SafeLink Team";
1706
+ type = "heuristic";
1707
+ capabilities = ["homograph-detection", "punycode-analysis"];
1708
+ priority = 75;
1709
+ weight = 1;
1710
+ async execute(ctx) {
1711
+ const urlToTest = ctx.state.finalUrl || ctx.normalizedUrl;
1712
+ const res = validatePunycode(urlToTest);
1713
+ return { ...res, confidence: 100 };
1714
+ }
1715
+ };
1716
+ var HeuristicsPlugin = class {
1717
+ id = "core:heuristics";
1718
+ name = "GeneralHeuristics";
1719
+ version = "1.0.0";
1720
+ description = "General URL heuristics like length and entropy";
1721
+ author = "SafeLink Team";
1722
+ type = "heuristic";
1723
+ capabilities = ["entropy-analysis", "pattern-matching"];
1724
+ priority = 70;
1725
+ weight = 1.5;
1726
+ async execute(ctx) {
1727
+ const urlToTest = ctx.state.finalUrl || ctx.normalizedUrl;
1728
+ const res = validateHeuristics(urlToTest);
1729
+ return { ...res, confidence: 85 };
1730
+ }
1731
+ };
1732
+
1733
+ // src/core/factory.ts
1734
+ var DefaultPluginFactory = class {
1735
+ /**
1736
+ * Registers the core suite of verification plugins into the provided PluginManager.
1737
+ * This decoupled factory ensures the core orchestrator isn't tightly bound to concrete plugin implementations.
1738
+ */
1739
+ static registerCorePlugins(manager) {
1740
+ manager.register(new UrlValidationPlugin());
1741
+ manager.register(new ShortenerPlugin());
1742
+ manager.register(new IpValidationPlugin());
1743
+ manager.register(new HeuristicsPlugin());
1744
+ manager.register(new RedirectPlugin());
1745
+ manager.register(new PunycodePlugin());
1746
+ manager.register(new HttpsValidationPlugin());
1747
+ }
1748
+ };
1749
+
1750
+ // src/checker.ts
1751
+ var SafeLinkError = class extends Error {
1752
+ constructor(message) {
1753
+ super(message);
1754
+ this.name = "SafeLinkError";
1755
+ }
1756
+ };
1757
+ var TimeoutError = class extends SafeLinkError {
1758
+ constructor(message) {
1759
+ super(message);
1760
+ this.name = "TimeoutError";
1761
+ }
1762
+ };
1763
+ var SafeLinkChecker = class extends EventEmitter {
1764
+ cache = null;
1765
+ metadataCache = new LRUCache({ maxSize: 500, ttlMs: 1e3 * 60 * 60 });
1766
+ options;
1767
+ pluginManager;
1768
+ consensusEngine;
1769
+ policyEngine;
1770
+ constructor(options = {}) {
1771
+ super();
1772
+ this.options = options;
1773
+ this.pluginManager = new PluginManager();
1774
+ this.consensusEngine = new ConsensusEngine();
1775
+ this.policyEngine = new PolicyEngine();
1776
+ if (options.cache === true || options.cache === void 0) {
1777
+ this.cache = defaultCache;
1778
+ } else if (options.cache !== false) {
1779
+ this.cache = options.cache;
1780
+ }
1781
+ DefaultPluginFactory.registerCorePlugins(this.pluginManager);
1782
+ this.pluginManager.register(new RuleEnginePlugin());
1783
+ if (options.providers) {
1784
+ for (const p of options.providers) {
1785
+ if (p === "openphish") this.use(new OpenPhishProvider());
1786
+ else if (p === "urlhaus") this.use(new URLHausProvider());
1787
+ else this.use(p);
1788
+ }
1789
+ }
1790
+ }
1791
+ /**
1792
+ * Adds a legacy Provider or a new VerificationPlugin to the checker.
1793
+ */
1794
+ use(plugin) {
1795
+ if ("check" in plugin) {
1796
+ this.pluginManager.register(new ProviderPluginAdapter(plugin));
1797
+ } else {
1798
+ this.pluginManager.register(plugin);
1799
+ }
1800
+ return this;
1801
+ }
1802
+ async getMetadata(url) {
1803
+ const cached = this.metadataCache.get(url);
1804
+ if (cached) return cached;
1805
+ const result = await extractMetadata(url, this.options.timeout);
1806
+ if (result) {
1807
+ this.metadataCache.set(url, result);
1808
+ }
1809
+ return result;
1810
+ }
1811
+ /**
1812
+ * Verifies a single URL through the core engine and any registered providers.
1813
+ * Caches results if configured.
1814
+ *
1815
+ * @param url The URL to check.
1816
+ * @param runtimeOptions Options overriding the global checker options for this specific call.
1817
+ * @returns A detailed VerificationResult including the final risk score.
1818
+ */
1819
+ async verify(url, runtimeOptions = {}) {
1820
+ const mergedOptions = Object.keys(runtimeOptions).length === 0 ? this.options : { ...this.options, ...runtimeOptions };
1821
+ try {
1822
+ if (!mergedOptions.bypassCache && this.cache) {
1823
+ const cached = this.cache.get(url);
1824
+ if (cached) {
1825
+ const res = { ...cached, fromCache: true };
1826
+ if (this.options.onComplete) this.options.onComplete(res);
1827
+ return res;
1828
+ }
1829
+ }
1830
+ let baseResult;
1831
+ if (mergedOptions.mode === "cloud") {
1832
+ baseResult = await this.verifyCloud(url, mergedOptions);
1833
+ } else {
1834
+ baseResult = await this.verifyLocal(url, mergedOptions);
1835
+ }
1836
+ if (!mergedOptions.bypassCache && this.cache) {
1837
+ this.cache.set(url, { ...baseResult, fromCache: false });
1838
+ }
1839
+ this.emit("onComplete", baseResult);
1840
+ if (this.options.onComplete) this.options.onComplete(baseResult);
1841
+ return baseResult;
1842
+ } catch (error) {
1843
+ if (error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError")) {
1844
+ throw new TimeoutError(`Verification timed out for ${url}`);
1845
+ }
1846
+ this.emit("onError", error, url);
1847
+ if (this.options.onError) this.options.onError(error, url);
1848
+ throw error;
1849
+ }
1850
+ }
1851
+ async verifyCloud(url, mergedOptions) {
1852
+ if (!mergedOptions.endpoint) throw new SafeLinkError("Cloud mode requires an endpoint configuration.");
1853
+ const headers = { "Content-Type": "application/json" };
1854
+ if (mergedOptions.apiKey) headers["Authorization"] = `Bearer ${mergedOptions.apiKey}`;
1855
+ const response = await fetch(`${mergedOptions.endpoint.replace(/\/$/, "")}/v1/verify`, {
1856
+ method: "POST",
1857
+ headers,
1858
+ body: JSON.stringify({ url }),
1859
+ signal: mergedOptions.signal ?? null
1860
+ });
1861
+ if (!response.ok) {
1862
+ const errText = await response.text().catch(() => "Unknown error");
1863
+ throw new SafeLinkError(`Cloud API Error: ${response.status} - ${errText}`);
1864
+ }
1865
+ return await response.json();
1866
+ }
1867
+ async verifyLocal(url, mergedOptions) {
1868
+ await this.pluginManager.initializeAll();
1869
+ this.emit("onStart", url);
1870
+ if (this.options.onStart) this.options.onStart(url);
1871
+ const normalizedUrl = normalizeLink(url, mergedOptions);
1872
+ const ctx = {
1873
+ url,
1874
+ normalizedUrl,
1875
+ options: mergedOptions,
1876
+ state: {}
1877
+ };
1878
+ const checks = [];
1879
+ const plugins = this.pluginManager.getAll();
1880
+ for (const plugin of plugins) {
1881
+ try {
1882
+ const res = await plugin.execute(ctx);
1883
+ if (res) {
1884
+ checks.push(res);
1885
+ if (res.fatal) {
1886
+ break;
1887
+ }
1888
+ }
1889
+ } catch (e) {
1890
+ this.emit("pluginError", plugin.name, e);
1891
+ }
1892
+ }
1893
+ const engineResult = this.consensusEngine.evaluate(checks);
1894
+ const policyCtx = {
1895
+ riskLevel: engineResult.riskLevel,
1896
+ score: engineResult.score,
1897
+ confidence: engineResult.confidence
1898
+ };
1899
+ const policyResult = this.policyEngine.evaluate(mergedOptions.policy, policyCtx);
1900
+ return {
1901
+ // Legacy
1902
+ url,
1903
+ normalizedUrl,
1904
+ safe: engineResult.safe,
1905
+ score: engineResult.score,
1906
+ confidence: engineResult.confidence,
1907
+ riskLevel: engineResult.riskLevel,
1908
+ reasons: engineResult.reasons,
1909
+ recommendations: [],
1910
+ // Can be generated by an AI plugin later
1911
+ redirectChain: ctx.state.redirectTrace?.chain || [],
1912
+ redirectTrace: ctx.state.redirectTrace || { chain: [], finalUrl: normalizedUrl, redirectCount: 0, anomalies: [] },
1913
+ checks,
1914
+ fromCache: false,
1915
+ // XTI
1916
+ trustScore: engineResult.trustScore,
1917
+ summary: engineResult.summary,
1918
+ decision: policyResult.decision,
1919
+ action: policyResult.action,
1920
+ policy: mergedOptions.policy || "balanced",
1921
+ evidence: checks
1922
+ // evidence is heavily structured CheckResult[]
1923
+ };
1924
+ }
1925
+ /**
1926
+ * Concurrently verifies multiple URLs with a bounded concurrency limit.
1927
+ * Results are returned in the exact same order as the input array.
1928
+ *
1929
+ * @param urls Array of URLs to verify.
1930
+ * @param runtimeOptions Options overriding the global checker options.
1931
+ * @param concurrency The maximum number of concurrent verifications (defaults to 5).
1932
+ * @returns Array of VerificationResult corresponding to the input URLs.
1933
+ */
1934
+ async verifyLinks(urls, runtimeOptions = {}, concurrency = 5) {
1935
+ const mergedOptions = Object.keys(runtimeOptions).length === 0 ? this.options : { ...this.options, ...runtimeOptions };
1936
+ if (mergedOptions.mode === "cloud") {
1937
+ if (!mergedOptions.endpoint) throw new SafeLinkError("Cloud mode requires an endpoint configuration.");
1938
+ const headers = { "Content-Type": "application/json" };
1939
+ if (mergedOptions.apiKey) headers["Authorization"] = `Bearer ${mergedOptions.apiKey}`;
1940
+ const response = await fetch(`${mergedOptions.endpoint.replace(/\/$/, "")}/v1/verify/batch`, {
1941
+ method: "POST",
1942
+ headers,
1943
+ body: JSON.stringify({ urls }),
1944
+ signal: mergedOptions.signal ?? null
1945
+ });
1946
+ if (!response.ok) {
1947
+ const errText = await response.text().catch(() => "Unknown error");
1948
+ throw new SafeLinkError(`Cloud API Error: ${response.status} - ${errText}`);
1949
+ }
1950
+ const results2 = await response.json();
1951
+ return results2;
1952
+ }
1953
+ const results = new Array(urls.length);
1954
+ let index = 0;
1955
+ const worker = async () => {
1956
+ while (index < urls.length) {
1957
+ const currentIndex = index++;
1958
+ const url = urls[currentIndex];
1959
+ results[currentIndex] = await this.verify(url, runtimeOptions);
1960
+ }
1961
+ };
1962
+ const workers = Array(Math.min(concurrency, urls.length)).fill(null).map(() => worker());
1963
+ await Promise.all(workers);
1964
+ return results;
1965
+ }
1966
+ };
1967
+
1968
+ exports.LRUCache = LRUCache;
1969
+ exports.MemoryCache = LRUCache;
1970
+ exports.OpenPhishProvider = OpenPhishProvider;
1971
+ exports.SafeLinkChecker = SafeLinkChecker;
1972
+ exports.SafeLinkError = SafeLinkError;
1973
+ exports.TimeoutError = TimeoutError;
1974
+ exports.URLHausProvider = URLHausProvider;
1975
+ exports.defaultCache = defaultCache;
1976
+ exports.normalizeLink = normalizeLink;
1977
+ exports.traceRedirects = traceRedirects;
1978
+ exports.validateHeuristics = validateHeuristics;
1979
+ exports.validateHttps = validateHttps;
1980
+ exports.validateIp = validateIp;
1981
+ exports.validatePunycode = validatePunycode;
1982
+ exports.validateShortener = validateShortener;
1983
+ exports.validateUrl = validateUrl;
1984
+ exports.verifyLink = verifyLink;
1985
+ //# sourceMappingURL=index.cjs.map
1986
+ //# sourceMappingURL=index.cjs.map