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