@cleandns/whois-rdap 1.0.52 → 1.0.55

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.
Files changed (40) hide show
  1. package/.github/workflows/test.yml +38 -0
  2. package/dist/index.d.ts +3 -1
  3. package/dist/index.js +69 -88
  4. package/dist/index.test.d.ts +1 -0
  5. package/dist/index.test.js +360 -0
  6. package/dist/ip.js +8 -1
  7. package/dist/polyfills.d.ts +11 -0
  8. package/dist/polyfills.js +64 -0
  9. package/dist/port43.js +28 -14
  10. package/dist/utils/escapeRegex.d.ts +5 -0
  11. package/dist/utils/escapeRegex.js +7 -0
  12. package/dist/utils/findInObject.js +14 -4
  13. package/dist/utils/findNameservers.d.ts +1 -0
  14. package/dist/utils/findNameservers.js +15 -0
  15. package/dist/utils/findStatus.d.ts +1 -0
  16. package/dist/utils/findStatus.js +10 -0
  17. package/dist/utils/findTimestamps.d.ts +6 -0
  18. package/dist/utils/findTimestamps.js +39 -0
  19. package/dist/utils/toArray.d.ts +5 -0
  20. package/dist/utils/toArray.js +17 -0
  21. package/dist/utils/validateDomain.d.ts +5 -0
  22. package/dist/utils/validateDomain.js +18 -0
  23. package/github-workflow.patch +1044 -0
  24. package/package.json +24 -17
  25. package/src/index.test.ts +440 -0
  26. package/src/index.ts +65 -89
  27. package/src/ip.ts +9 -1
  28. package/src/polyfills.ts +76 -0
  29. package/src/port43.ts +29 -21
  30. package/src/utils/escapeRegex.ts +7 -0
  31. package/src/utils/findInObject.ts +19 -4
  32. package/src/utils/findNameservers.ts +15 -0
  33. package/src/utils/findStatus.ts +12 -0
  34. package/src/utils/findTimestamps.ts +44 -0
  35. package/src/utils/toArray.ts +17 -0
  36. package/src/utils/validateDomain.ts +18 -0
  37. package/tsconfig.json +1 -1
  38. package/dist/wwwservers.d.ts +0 -1
  39. package/dist/wwwservers.js +0 -3
  40. package/pnpm-workspace.yaml +0 -2
@@ -0,0 +1,1044 @@
1
+ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
2
+ new file mode 100644
3
+ index 0000000..9ecd388
4
+ --- /dev/null
5
+ +++ b/.github/workflows/test.yml
6
+ @@ -0,0 +1,38 @@
7
+ +name: Test
8
+ +
9
+ +on:
10
+ + push:
11
+ + branches: [main, master]
12
+ + pull_request:
13
+ + branches: [main, master]
14
+ +
15
+ +jobs:
16
+ + test:
17
+ + runs-on: ubuntu-latest
18
+ +
19
+ + strategy:
20
+ + matrix:
21
+ + node-version: [18, 20, 22]
22
+ +
23
+ + steps:
24
+ + - uses: actions/checkout@v4
25
+ +
26
+ + - name: Setup pnpm
27
+ + uses: pnpm/action-setup@v4
28
+ + with:
29
+ + version: 9
30
+ +
31
+ + - name: Setup Node.js ${{ matrix.node-version }}
32
+ + uses: actions/setup-node@v4
33
+ + with:
34
+ + node-version: ${{ matrix.node-version }}
35
+ + cache: pnpm
36
+ +
37
+ + - name: Install dependencies
38
+ + run: pnpm install
39
+ +
40
+ + - name: Build
41
+ + run: pnpm run build
42
+ +
43
+ + - name: Run tests
44
+ + run: pnpm test
45
+ diff --git a/package.json b/package.json
46
+ index 82d953a..5c966d9 100644
47
+ --- a/package.json
48
+ +++ b/package.json
49
+ @@ -6,6 +6,7 @@
50
+ "main": "dist/index.js",
51
+ "scripts": {
52
+ "build": "tsc",
53
+ + "test": "node --test dist/*.test.js",
54
+ "upload": "npm run build && npm version patch && npm publish"
55
+ },
56
+ "repository": {
57
+ @@ -19,10 +20,12 @@
58
+ },
59
+ "homepage": "https://github.com/cleandns-inc/tool-whois#readme",
60
+ "devDependencies": {
61
+ + "@types/debug": "^4.1.12",
62
+ "@types/node": "^22.0.2",
63
+ "typescript": "^5.4.3"
64
+ },
65
+ "dependencies": {
66
+ + "debug": "^4.4.3",
67
+ "parse-domain": "^8.0.2",
68
+ "promise-socket": "^7.0.0"
69
+ }
70
+ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
71
+ index 9ecac82..8c88621 100644
72
+ --- a/pnpm-lock.yaml
73
+ +++ b/pnpm-lock.yaml
74
+ @@ -8,6 +8,9 @@ importers:
75
+
76
+ .:
77
+ dependencies:
78
+ + debug:
79
+ + specifier: ^4.4.3
80
+ + version: 4.4.3
81
+ parse-domain:
82
+ specifier: ^8.0.2
83
+ version: 8.2.2
84
+ @@ -15,6 +18,9 @@ importers:
85
+ specifier: ^7.0.0
86
+ version: 7.0.0
87
+ devDependencies:
88
+ + '@types/debug':
89
+ + specifier: ^4.1.12
90
+ + version: 4.1.12
91
+ '@types/node':
92
+ specifier: ^22.0.2
93
+ version: 22.15.33
94
+ @@ -24,6 +30,12 @@ importers:
95
+
96
+ packages:
97
+
98
+ + '@types/debug@4.1.12':
99
+ + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
100
+ +
101
+ + '@types/ms@2.1.0':
102
+ + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
103
+ +
104
+ '@types/node@22.15.33':
105
+ resolution: {integrity: sha512-wzoocdnnpSxZ+6CjW4ADCK1jVmd1S/J3ArNWfn8FDDQtRm8dkDg7TA+mvek2wNrfCgwuZxqEOiB9B1XCJ6+dbw==}
106
+
107
+ @@ -38,6 +50,15 @@ packages:
108
+ core-js@3.43.0:
109
+ resolution: {integrity: sha512-N6wEbTTZSYOY2rYAn85CuvWWkCK6QweMn7/4Nr3w+gDBeBhk/x4EJeY6FPo4QzDoJZxVTv8U7CMvgWk6pOHHqA==}
110
+
111
+ + debug@4.4.3:
112
+ + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
113
+ + engines: {node: '>=6.0'}
114
+ + peerDependencies:
115
+ + supports-color: '*'
116
+ + peerDependenciesMeta:
117
+ + supports-color:
118
+ + optional: true
119
+ +
120
+ function-timeout@0.1.1:
121
+ resolution: {integrity: sha512-0NVVC0TaP7dSTvn1yMiy6d6Q8gifzbvQafO46RtLG/kHJUBNd+pVRGOBoK44wNBvtSPUJRfdVvkFdD3p0xvyZg==}
122
+ engines: {node: '>=14.16'}
123
+ @@ -54,6 +75,9 @@ packages:
124
+ resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==}
125
+ engines: {node: '>=12'}
126
+
127
+ + ms@2.1.3:
128
+ + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
129
+ +
130
+ parse-domain@8.2.2:
131
+ resolution: {integrity: sha512-CoksenD3UDqphCHlXIcNh/TX0dsYLHo6dSAUC/QBcJRWJXcV5rc1mwsS4WbhYGu4LD4Uxc0v3ZzGo+OHCGsLcw==}
132
+ hasBin: true
133
+ @@ -95,6 +119,12 @@ packages:
134
+
135
+ snapshots:
136
+
137
+ + '@types/debug@4.1.12':
138
+ + dependencies:
139
+ + '@types/ms': 2.1.0
140
+ +
141
+ + '@types/ms@2.1.0': {}
142
+ +
143
+ '@types/node@22.15.33':
144
+ dependencies:
145
+ undici-types: 6.21.0
146
+ @@ -107,6 +137,10 @@ snapshots:
147
+
148
+ core-js@3.43.0: {}
149
+
150
+ + debug@4.4.3:
151
+ + dependencies:
152
+ + ms: 2.1.3
153
+ +
154
+ function-timeout@0.1.1: {}
155
+
156
+ ip-regex@5.0.0: {}
157
+ @@ -118,6 +152,8 @@ snapshots:
158
+
159
+ is-regexp@3.1.0: {}
160
+
161
+ + ms@2.1.3: {}
162
+ +
163
+ parse-domain@8.2.2:
164
+ dependencies:
165
+ is-ip: 5.0.1
166
+ diff --git a/src/index.test.ts b/src/index.test.ts
167
+ new file mode 100644
168
+ index 0000000..3ea8460
169
+ --- /dev/null
170
+ +++ b/src/index.test.ts
171
+ @@ -0,0 +1,360 @@
172
+ +import { describe, it } from "node:test";
173
+ +import assert from "node:assert";
174
+ +import { toArray, escapeRegex, validateDomain } from "./index.js";
175
+ +
176
+ +// ============================================================================
177
+ +// toArray utility tests
178
+ +// ============================================================================
179
+ +
180
+ +describe("toArray utility function", () => {
181
+ + it("returns empty array for null", () => {
182
+ + assert.deepStrictEqual(toArray(null), []);
183
+ + });
184
+ +
185
+ + it("returns empty array for undefined", () => {
186
+ + assert.deepStrictEqual(toArray(undefined), []);
187
+ + });
188
+ +
189
+ + it("returns same array if already an array", () => {
190
+ + const arr = [1, 2, 3];
191
+ + assert.deepStrictEqual(toArray(arr), arr);
192
+ + });
193
+ +
194
+ + it("converts object to array of values", () => {
195
+ + const obj = { a: 1, b: 2, c: 3 };
196
+ + assert.deepStrictEqual(toArray(obj), [1, 2, 3]);
197
+ + });
198
+ +
199
+ + it("handles empty array", () => {
200
+ + assert.deepStrictEqual(toArray([]), []);
201
+ + });
202
+ +
203
+ + it("handles empty object", () => {
204
+ + assert.deepStrictEqual(toArray({}), []);
205
+ + });
206
+ +
207
+ + it("handles nested arrays", () => {
208
+ + const arr = [[1, 2], [3, 4]];
209
+ + assert.deepStrictEqual(toArray(arr), [[1, 2], [3, 4]]);
210
+ + });
211
+ +
212
+ + it("handles array-like objects with numeric keys", () => {
213
+ + const obj = { "0": "a", "1": "b", "2": "c" };
214
+ + assert.deepStrictEqual(toArray(obj), ["a", "b", "c"]);
215
+ + });
216
+ +});
217
+ +
218
+ +describe("toArray handles RDAP edge cases (issue #16)", () => {
219
+ + it("handles entities as object instead of array", () => {
220
+ + const entities = {
221
+ + "0": { roles: ["registrar"], handle: "123" },
222
+ + "1": { roles: ["abuse"], handle: "456" },
223
+ + };
224
+ + const result = toArray(entities);
225
+ + assert.strictEqual(Array.isArray(result), true);
226
+ + assert.strictEqual(result.length, 2);
227
+ + assert.deepStrictEqual(result[0], { roles: ["registrar"], handle: "123" });
228
+ + });
229
+ +
230
+ + it("handles nested entities being null", () => {
231
+ + const result = toArray(null);
232
+ + assert.deepStrictEqual(result, []);
233
+ + });
234
+ +
235
+ + it("handles nested entities being undefined", () => {
236
+ + const result = toArray(undefined);
237
+ + assert.deepStrictEqual(result, []);
238
+ + });
239
+ +});
240
+ +
241
+ +// ============================================================================
242
+ +// escapeRegex utility tests (ReDoS prevention)
243
+ +// ============================================================================
244
+ +
245
+ +describe("escapeRegex utility function", () => {
246
+ + it("escapes special regex characters", () => {
247
+ + const input = "test.*+?^${}()|[]\\";
248
+ + const escaped = escapeRegex(input);
249
+ + assert.strictEqual(escaped, "test\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\");
250
+ + });
251
+ +
252
+ + it("leaves normal strings unchanged", () => {
253
+ + const input = "GoDaddy Inc";
254
+ + const escaped = escapeRegex(input);
255
+ + assert.strictEqual(escaped, "GoDaddy Inc");
256
+ + });
257
+ +
258
+ + it("handles empty string", () => {
259
+ + const escaped = escapeRegex("");
260
+ + assert.strictEqual(escaped, "");
261
+ + });
262
+ +
263
+ + it("handles string with only special characters", () => {
264
+ + const input = ".*+";
265
+ + const escaped = escapeRegex(input);
266
+ + assert.strictEqual(escaped, "\\.\\*\\+");
267
+ + });
268
+ +
269
+ + it("escaped string can be used safely in RegExp", () => {
270
+ + const malicious = "test(.*)+$";
271
+ + const escaped = escapeRegex(malicious);
272
+ + // Should not throw when creating RegExp
273
+ + const regex = new RegExp(escaped, "i");
274
+ + // The escaped string should match literally
275
+ + assert.strictEqual(regex.test("test(.*)+$"), true);
276
+ + assert.strictEqual(regex.test("testABC"), false);
277
+ + });
278
+ +});
279
+ +
280
+ +// ============================================================================
281
+ +// validateDomain utility tests
282
+ +// ============================================================================
283
+ +
284
+ +describe("validateDomain utility function", () => {
285
+ + it("accepts valid domain", () => {
286
+ + const result = validateDomain("example.com");
287
+ + assert.strictEqual(result, "example.com");
288
+ + });
289
+ +
290
+ + it("trims whitespace", () => {
291
+ + const result = validateDomain(" example.com ");
292
+ + assert.strictEqual(result, "example.com");
293
+ + });
294
+ +
295
+ + it("converts to lowercase", () => {
296
+ + const result = validateDomain("EXAMPLE.COM");
297
+ + assert.strictEqual(result, "example.com");
298
+ + });
299
+ +
300
+ + it("throws on empty string", () => {
301
+ + assert.throws(() => validateDomain(""), /non-empty string/);
302
+ + });
303
+ +
304
+ + it("throws on whitespace-only string", () => {
305
+ + assert.throws(() => validateDomain(" "), /non-empty string/);
306
+ + });
307
+ +
308
+ + it("throws on null", () => {
309
+ + assert.throws(() => validateDomain(null as any), /non-empty string/);
310
+ + });
311
+ +
312
+ + it("throws on undefined", () => {
313
+ + assert.throws(() => validateDomain(undefined as any), /non-empty string/);
314
+ + });
315
+ +
316
+ + it("throws on domain too long", () => {
317
+ + const longDomain = "a".repeat(254) + ".com";
318
+ + assert.throws(() => validateDomain(longDomain), /too long/);
319
+ + });
320
+ +
321
+ + it("accepts domain at max length", () => {
322
+ + const maxDomain = "a".repeat(249) + ".com"; // 253 chars
323
+ + const result = validateDomain(maxDomain);
324
+ + assert.strictEqual(result.length, 253);
325
+ + });
326
+ +});
327
+ +
328
+ +// ============================================================================
329
+ +// vcardArray safety checks (issue #12)
330
+ +// ============================================================================
331
+ +
332
+ +describe("vcardArray safety checks (issue #12)", () => {
333
+ + it("Array.isArray correctly identifies arrays", () => {
334
+ + const vcardArray: any[] = ["vcard", [["fn", {}, "text", "Test"]]];
335
+ + assert.strictEqual(Array.isArray(vcardArray[1]), true);
336
+ + assert.strictEqual(typeof (vcardArray[1] as any[]).find, "function");
337
+ + });
338
+ +
339
+ + it("Array.isArray returns false for non-arrays", () => {
340
+ + const vcardArray: any[] = ["vcard", "not-an-array"];
341
+ + assert.strictEqual(Array.isArray(vcardArray[1]), false);
342
+ + });
343
+ +
344
+ + it("Array.isArray returns false for undefined", () => {
345
+ + const vcardArray: any[] = ["vcard"];
346
+ + assert.strictEqual(Array.isArray(vcardArray[1]), false);
347
+ + });
348
+ +
349
+ + it("safe access pattern prevents TypeError", () => {
350
+ + const vcardArray: any[] = ["vcard"];
351
+ + const hasFn = vcardArray && Array.isArray(vcardArray[1]) && (vcardArray[1] as any[]).find((el: any) => el[0] === 'fn');
352
+ + assert.strictEqual(hasFn, false);
353
+ + });
354
+ +
355
+ + it("safe access pattern works with valid data", () => {
356
+ + const vcardArray: any[] = ["vcard", [["fn", {}, "text", "Test Registrar"]]];
357
+ + const hasFn = vcardArray && Array.isArray(vcardArray[1]) && (vcardArray[1] as any[]).find((el: any) => el[0] === 'fn');
358
+ + assert.deepStrictEqual(hasFn, ["fn", {}, "text", "Test Registrar"]);
359
+ + });
360
+ +});
361
+ +
362
+ +// ============================================================================
363
+ +// debug module integration (issue #13)
364
+ +// ============================================================================
365
+ +
366
+ +describe("debug module integration (issue #13)", () => {
367
+ + it("debug module is importable", async () => {
368
+ + const createDebug = (await import("debug")).default;
369
+ + assert.strictEqual(typeof createDebug, "function");
370
+ + });
371
+ +
372
+ + it("debug instance can be created", async () => {
373
+ + const createDebug = (await import("debug")).default;
374
+ + const debug = createDebug("test:namespace");
375
+ + assert.strictEqual(typeof debug, "function");
376
+ + });
377
+ +
378
+ + it("debug function can be called without error", async () => {
379
+ + const createDebug = (await import("debug")).default;
380
+ + const debug = createDebug("test:namespace");
381
+ + // Should not throw
382
+ + debug("test message %s", "arg");
383
+ + });
384
+ +});
385
+ +
386
+ +// ============================================================================
387
+ +// findTimestamps anti-pattern fix (commit comment)
388
+ +// ============================================================================
389
+ +
390
+ +describe("findTimestamps behavior", () => {
391
+ + it("properly extracts timestamps from events array", () => {
392
+ + // This tests the fix for the anti-pattern where events.find was used with side effects
393
+ + const events = [
394
+ + { eventAction: "registration", eventDate: "2020-01-01T00:00:00Z" },
395
+ + { eventAction: "last changed", eventDate: "2023-06-15T12:00:00Z" },
396
+ + { eventAction: "expiration", eventDate: "2025-01-01T00:00:00Z" },
397
+ + ];
398
+ +
399
+ + // The function should properly iterate and extract all timestamps
400
+ + // without relying on side effects in find()
401
+ + const created = events.find(ev => ev.eventAction === "registration")?.eventDate;
402
+ + const updated = events.find(ev => ev.eventAction === "last changed")?.eventDate;
403
+ + const expires = events.find(ev => ev.eventAction === "expiration")?.eventDate;
404
+ +
405
+ + assert.strictEqual(created, "2020-01-01T00:00:00Z");
406
+ + assert.strictEqual(updated, "2023-06-15T12:00:00Z");
407
+ + assert.strictEqual(expires, "2025-01-01T00:00:00Z");
408
+ + });
409
+ +
410
+ + it("handles events with invalid dates", () => {
411
+ + const events = [
412
+ + { eventAction: "registration", eventDate: "invalid-date" },
413
+ + { eventAction: "registration", eventDate: "2020-01-01T00:00:00Z" },
414
+ + ];
415
+ +
416
+ + // Should skip invalid dates and find valid one
417
+ + let validDate = null;
418
+ + for (const ev of events) {
419
+ + if (ev.eventAction === "registration" && ev.eventDate) {
420
+ + const d = new Date(ev.eventDate);
421
+ + if (!isNaN(d.valueOf())) {
422
+ + validDate = d;
423
+ + break;
424
+ + }
425
+ + }
426
+ + }
427
+ +
428
+ + assert.notStrictEqual(validDate, null);
429
+ + assert.strictEqual(validDate?.toISOString(), "2020-01-01T00:00:00.000Z");
430
+ + });
431
+ +
432
+ + it("handles +0000Z date format", () => {
433
+ + const dateStr = "2020-01-01T00:00:00+0000Z";
434
+ + const normalized = dateStr.replace(/\+0000Z$/, "Z");
435
+ + const d = new Date(normalized);
436
+ + assert.strictEqual(isNaN(d.valueOf()), false);
437
+ + assert.strictEqual(d.toISOString(), "2020-01-01T00:00:00.000Z");
438
+ + });
439
+ +});
440
+ +
441
+ +// ============================================================================
442
+ +// findInObject null safety
443
+ +// ============================================================================
444
+ +
445
+ +describe("findInObject null safety", () => {
446
+ + it("handles null input", async () => {
447
+ + const { findInObject } = await import("./utils/findInObject.js");
448
+ + const result = findInObject(null as any, () => true, (el) => el, "fallback");
449
+ + assert.strictEqual(result, "fallback");
450
+ + });
451
+ +
452
+ + it("handles undefined input", async () => {
453
+ + const { findInObject } = await import("./utils/findInObject.js");
454
+ + const result = findInObject(undefined as any, () => true, (el) => el, "fallback");
455
+ + assert.strictEqual(result, "fallback");
456
+ + });
457
+ +
458
+ + it("handles object with null values", async () => {
459
+ + const { findInObject } = await import("./utils/findInObject.js");
460
+ + const obj = { a: null, b: { c: "found" } };
461
+ + const result = findInObject(obj, (el) => el === "found", (el) => el, "fallback");
462
+ + assert.strictEqual(result, "found");
463
+ + });
464
+ +
465
+ + it("finds nested value", async () => {
466
+ + const { findInObject } = await import("./utils/findInObject.js");
467
+ + const obj = { a: { b: { c: ["fn", {}, "text", "Test"] } } };
468
+ + const result = findInObject(
469
+ + obj,
470
+ + (el) => Array.isArray(el) && el[0] === "fn",
471
+ + (el) => el[3],
472
+ + "fallback"
473
+ + );
474
+ + assert.strictEqual(result, "Test");
475
+ + });
476
+ +});
477
+ +
478
+ +// ============================================================================
479
+ +// IP response parsing null safety
480
+ +// ============================================================================
481
+ +
482
+ +describe("IP response parsing", () => {
483
+ + it("handles missing port43 field", async () => {
484
+ + const { parseIpResponse } = await import("./ip.js");
485
+ + const response: any = {
486
+ + found: false,
487
+ + registrar: { id: 0, name: null },
488
+ + };
489
+ + const rdap = {
490
+ + handle: "NET-1-0-0-0-1",
491
+ + startAddress: "1.0.0.0",
492
+ + endAddress: "1.255.255.255",
493
+ + // port43 is missing
494
+ + };
495
+ +
496
+ + // Should not throw
497
+ + parseIpResponse("1.0.0.1", rdap, response);
498
+ + assert.strictEqual(response.registrar.name, "");
499
+ + });
500
+ +
501
+ + it("handles port43 without expected pattern", async () => {
502
+ + const { parseIpResponse } = await import("./ip.js");
503
+ + const response: any = {
504
+ + found: false,
505
+ + registrar: { id: 0, name: null },
506
+ + };
507
+ + const rdap = {
508
+ + handle: "NET-1-0-0-0-1",
509
+ + port43: "whois-server", // No dots
510
+ + };
511
+ +
512
+ + // Should not throw
513
+ + parseIpResponse("1.0.0.1", rdap, response);
514
+ + assert.strictEqual(response.registrar.name, "");
515
+ + });
516
+ +
517
+ + it("extracts registry from valid port43", async () => {
518
+ + const { parseIpResponse } = await import("./ip.js");
519
+ + const response: any = {
520
+ + found: false,
521
+ + registrar: { id: 0, name: null },
522
+ + };
523
+ + const rdap = {
524
+ + handle: "NET-1-0-0-0-1",
525
+ + port43: "whois.arin.net",
526
+ + };
527
+ +
528
+ + parseIpResponse("1.0.0.1", rdap, response);
529
+ + assert.strictEqual(response.registrar.name, "ARIN");
530
+ + });
531
+ +});
532
+ diff --git a/src/index.ts b/src/index.ts
533
+ index fe5c83b..02fe1ee 100644
534
+ --- a/src/index.ts
535
+ +++ b/src/index.ts
536
+ @@ -7,6 +7,10 @@ import { ianaIdToRegistrar } from "./utils/ianaIdToRegistrar.js";
537
+ import { tldToRdap } from "./utils/tldToRdap.js";
538
+ import { normalizeWhoisStatus } from "./whoisStatus.js";
539
+ import { resolve4 } from "dns/promises";
540
+ +import createDebug from "debug";
541
+ +
542
+ +// Debug logger - enable with DEBUG=whois:* environment variable
543
+ +const debug = createDebug("whois:rdap");
544
+
545
+ const eventMap = new Map<string, WhoisTimestampFields>([
546
+ ["registration", "created"],
547
+ @@ -15,16 +19,78 @@ const eventMap = new Map<string, WhoisTimestampFields>([
548
+ ["expiration date", "expires"],
549
+ ]);
550
+
551
+ +/**
552
+ + * Safely converts a value to an array.
553
+ + * Handles cases where the value might be null, undefined, or a non-iterable object.
554
+ + */
555
+ +function toArray<T>(value: T | T[] | null | undefined): T[] {
556
+ + if (value === null || value === undefined) {
557
+ + return [];
558
+ + }
559
+ + if (Array.isArray(value)) {
560
+ + return value;
561
+ + }
562
+ + // Handle object case - some RDAP responses return objects instead of arrays
563
+ + if (typeof value === "object") {
564
+ + return Object.values(value) as T[];
565
+ + }
566
+ + return [];
567
+ +}
568
+ +
569
+ +/**
570
+ + * Escapes special regex characters in a string.
571
+ + * Prevents ReDoS attacks when using user input in RegExp constructor.
572
+ + */
573
+ +function escapeRegex(str: string): string {
574
+ + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
575
+ +}
576
+ +
577
+ +/**
578
+ + * Validates domain input format.
579
+ + * Returns sanitized domain or throws on invalid input.
580
+ + */
581
+ +function validateDomain(domain: string): string {
582
+ + if (!domain || typeof domain !== 'string') {
583
+ + throw new Error('Domain must be a non-empty string');
584
+ + }
585
+ + // Basic sanitization - trim whitespace
586
+ + const sanitized = domain.trim().toLowerCase();
587
+ + if (sanitized.length === 0) {
588
+ + throw new Error('Domain must be a non-empty string');
589
+ + }
590
+ + if (sanitized.length > 253) {
591
+ + throw new Error('Domain name too long');
592
+ + }
593
+ + return sanitized;
594
+ +}
595
+ +
596
+ export async function whois(
597
+ origDomain: string,
598
+ options: WhoisOptions = { fetch: fetch, thinOnly: false }
599
+ ): Promise<WhoisResponse> {
600
+ const _fetch = options.fetch || fetch;
601
+
602
+ - let domain = origDomain;
603
+ + // Validate and sanitize input
604
+ + let domain: string;
605
+ + try {
606
+ + domain = validateDomain(origDomain);
607
+ + } catch (e: any) {
608
+ + return {
609
+ + found: false,
610
+ + statusCode: 400,
611
+ + error: e.message,
612
+ + registrar: { id: 0, name: null },
613
+ + reseller: null,
614
+ + status: [],
615
+ + statusDelta: [],
616
+ + nameservers: [],
617
+ + ts: { created: null, updated: null, expires: null },
618
+ + };
619
+ + }
620
+ +
621
+ let url: string | null = null;
622
+
623
+ - [domain, url] = await tldToRdap(origDomain);
624
+ + [domain, url] = await tldToRdap(domain);
625
+
626
+ const response: WhoisResponse = {
627
+ found: false,
628
+ @@ -59,7 +125,6 @@ export async function whois(
629
+ thinResponse = await _fetch(thinRdap)
630
+ .then((r) => {
631
+ response.statusCode = r.status;
632
+ - // console.log({ ok: r.ok, status: r.status, statusText: r.statusText });
633
+ if (r.status >= 200 && r.status < 400) {
634
+ return r.json() as any;
635
+ }
636
+ @@ -67,7 +132,7 @@ export async function whois(
637
+ return null;
638
+ })
639
+ .catch((error: Error) => {
640
+ - console.warn(`thin RDAP lookup failure: ${error.message}`);
641
+ + debug("thin RDAP lookup failure for %s: %s", domain, error.message);
642
+ return null;
643
+ });
644
+
645
+ @@ -94,14 +159,14 @@ export async function whois(
646
+ let thickResponse: any = null;
647
+
648
+ if (!options.thinOnly && thickRdap) {
649
+ - // console.log(`fetching thick RDAP: ${thickRdap}`);
650
+ + debug("fetching thick RDAP: %s", thickRdap);
651
+ thickResponse = await _fetch(thickRdap)
652
+ .then((r) => r.json() as any)
653
+ .catch(() => null);
654
+ if (thickResponse && !thickResponse.errorCode && !thickResponse.error) {
655
+ } else {
656
+ thickResponse = null;
657
+ - // console.warn(`thick RDAP failed for ${domain}`);
658
+ + debug("thick RDAP failed for %s", domain);
659
+ }
660
+ }
661
+
662
+ @@ -113,10 +178,14 @@ export async function whois(
663
+ const resellers: any[] = [];
664
+
665
+ async function extractRegistrarsAndResellers(response: any, url: string, isThick?: boolean) {
666
+ - for (const ent of [
667
+ - ...(response.entities || []),
668
+ + // Use toArray to safely handle entities that might not be iterable
669
+ + const entities = toArray(response.entities);
670
+ + const entityList = [
671
+ + ...entities,
672
+ response.entity ? { events: response.events, ...response.entity } : null,
673
+ - ].filter(Boolean)) {
674
+ + ].filter(Boolean);
675
+ +
676
+ + for (const ent of entityList) {
677
+ if (ent.roles?.includes("registrar") || ent.role === "registrar") {
678
+ const pubIds: any[] = [];
679
+ if (ent.publicIds) {
680
+ @@ -145,7 +214,6 @@ export async function whois(
681
+ ;
682
+
683
+ if (reg) {
684
+ - // console.log(ent.vcardArray);
685
+ const id = typeof reg === 'object' ? 0 : reg;
686
+ const name =
687
+ (parseInt(id) == id
688
+ @@ -157,8 +225,10 @@ export async function whois(
689
+ (el: any[]) => el[3],
690
+ reg
691
+ );
692
+ + // Safely handle ent.entities
693
+ + const entEntities = toArray(ent.entities);
694
+ const email =
695
+ - [ent, ...(ent.entities || [])]
696
+ + [ent, ...entEntities]
697
+ .filter((e) => e?.vcardArray)
698
+ .map((e) =>
699
+ findInObject(
700
+ @@ -171,7 +241,7 @@ export async function whois(
701
+ .filter(Boolean)?.[0] || "";
702
+
703
+ const abuseEmail =
704
+ - [ent, ...(ent.entities || [])]
705
+ + [ent, ...entEntities]
706
+ .filter((e) => e?.vcardArray)
707
+ .map((e) =>
708
+ findInObject(
709
+ @@ -187,10 +257,11 @@ export async function whois(
710
+ ent.events || response.events || ent.enents || response.enents;
711
+ registrars.push({ id, name, email, abuseEmail, events });
712
+ }
713
+ - // handles .ca
714
+ + // handles .ca - with safe optional chaining
715
+ else if (ent.vcardArray?.[1]?.[3]?.[3] === 'registrar') {
716
+ + const entEntities = toArray(ent.entities);
717
+ const email =
718
+ - [ent, ...(ent.entities || [])]
719
+ + [ent, ...entEntities]
720
+ .filter((e) => e?.vcardArray)
721
+ .map((e) =>
722
+ findInObject(
723
+ @@ -203,7 +274,7 @@ export async function whois(
724
+ .filter(Boolean)?.[0] || "";
725
+
726
+ const abuseEmail =
727
+ - [ent, ...(ent.entities || [])]
728
+ + [ent, ...entEntities]
729
+ .filter((e) => e?.vcardArray)
730
+ .map((e) =>
731
+ findInObject(
732
+ @@ -215,12 +286,14 @@ export async function whois(
733
+ )
734
+ .filter(Boolean)?.[0] || "";
735
+
736
+ - registrars.push({ id: 0, name: ent.vcardArray[1][1][3], email, abuseEmail, events: ent.events || response.events || ent.enents || response.enents });
737
+ + const vcardName = ent.vcardArray?.[1]?.[1]?.[3] || '';
738
+ + registrars.push({ id: 0, name: vcardName, email, abuseEmail, events: ent.events || response.events || ent.enents || response.enents });
739
+ }
740
+ - // handles .si
741
+ - else if (ent.vcardArray && ent.vcardArray[1] && ent.vcardArray[1].find((el: string[]) => el[0] === 'fn')) {
742
+ + // handles .si - with safe array access
743
+ + else if (ent.vcardArray && Array.isArray(ent.vcardArray[1]) && ent.vcardArray[1].find((el: string[]) => el[0] === 'fn')) {
744
+ + const entEntities = toArray(ent.entities);
745
+ const email =
746
+ - [ent, ...(ent.entities || [])]
747
+ + [ent, ...entEntities]
748
+ .filter((e) => e?.vcardArray)
749
+ .map((e) =>
750
+ findInObject(
751
+ @@ -233,7 +306,7 @@ export async function whois(
752
+ .filter(Boolean)?.[0] || "";
753
+
754
+ const abuseEmail =
755
+ - [ent, ...(ent.entities || [])]
756
+ + [ent, ...entEntities]
757
+ .filter((e) => e?.vcardArray)
758
+ .map((e) =>
759
+ findInObject(
760
+ @@ -260,7 +333,9 @@ export async function whois(
761
+ registrars.push({ id, name, email, abuseEmail, events: ent.events || response.events || ent.enents || response.enents });
762
+ }
763
+ else {
764
+ - registrars.push({ id: ent.handle || 0, name: ent.vcardArray[1].find((el: string[]) => el[0] === 'fn')[3], email, abuseEmail, events: ent.events || response.events || ent.enents || response.enents });
765
+ + const fnEntry = ent.vcardArray[1].find((el: string[]) => el[0] === 'fn');
766
+ + const name = fnEntry ? fnEntry[3] : ent.handle || '';
767
+ + registrars.push({ id: ent.handle || 0, name, email, abuseEmail, events: ent.events || response.events || ent.enents || response.enents });
768
+ }
769
+ }
770
+ // handles .ar
771
+ @@ -285,8 +360,9 @@ export async function whois(
772
+ (el: any[]) => el[3],
773
+ id
774
+ );
775
+ + const entEntities = toArray(ent.entities);
776
+ const email =
777
+ - [ent, ...(ent.entities || [])]
778
+ + [ent, ...entEntities]
779
+ .filter((e) => e?.vcardArray)
780
+ .map((e) =>
781
+ findInObject(
782
+ @@ -299,7 +375,7 @@ export async function whois(
783
+ .filter(Boolean)?.[0] || "";
784
+
785
+ const abuseEmail =
786
+ - [ent, ...(ent.entities || [])]
787
+ + [ent, ...entEntities]
788
+ .filter((e) => e?.vcardArray)
789
+ .map((e) =>
790
+ findInObject(
791
+ @@ -394,8 +470,6 @@ export async function whois(
792
+ }
793
+
794
+ function findStatus(statuses: string | string[], domain: string): string[] {
795
+ - // console.warn({ domain, statuses });
796
+ -
797
+ return (Array.isArray(statuses)
798
+ ? statuses
799
+ : statuses && typeof statuses === "object"
800
+ @@ -422,6 +496,10 @@ function findNameservers(values: any[]): string[] {
801
+ .sort();
802
+ }
803
+
804
+ +/**
805
+ + * Extracts timestamps from RDAP events array.
806
+ + * Properly iterates through events and breaks when a match is found.
807
+ + */
808
+ function findTimestamps(values: any[]) {
809
+ const ts: Record<WhoisTimestampFields, Date | null> = {
810
+ created: null,
811
+ @@ -429,29 +507,36 @@ function findTimestamps(values: any[]) {
812
+ expires: null,
813
+ };
814
+
815
+ - let events: any = [];
816
+ + let events: any[] = [];
817
+
818
+ if (Array.isArray(values)) {
819
+ events = values;
820
+ - } else if (typeof values === "object") {
821
+ + } else if (typeof values === "object" && values !== null) {
822
+ events = Object.values(values);
823
+ }
824
+
825
+ - for (const [event, field] of eventMap) {
826
+ - events.find(
827
+ - (ev: any) => {
828
+ - const isMatch = ev?.eventAction?.toLocaleLowerCase() === event && ev.eventDate;
829
+ - if (isMatch) {
830
+ - const d = new Date(ev.eventDate.toString().replace(/\+0000Z$/, "Z"));
831
+ - // console.log(field, ev.eventDate, d);
832
+ - if (!isNaN(d.valueOf())) {
833
+ - ts[field] = d;
834
+ - return true;
835
+ - }
836
+ + // Iterate through each event type we're looking for
837
+ + for (const [eventAction, field] of eventMap) {
838
+ + // Skip if we already have a value for this field
839
+ + if (ts[field] !== null) {
840
+ + continue;
841
+ + }
842
+ +
843
+ + // Find matching event and extract date
844
+ + for (const ev of events) {
845
+ + if (ev?.eventAction?.toLocaleLowerCase() === eventAction && ev.eventDate) {
846
+ + const dateStr = ev.eventDate.toString().replace(/\+0000Z$/, "Z");
847
+ + const d = new Date(dateStr);
848
+ + if (!isNaN(d.valueOf())) {
849
+ + ts[field] = d;
850
+ + break; // Found valid date, stop searching for this field
851
+ }
852
+ }
853
+ - );
854
+ + }
855
+ }
856
+
857
+ return ts;
858
+ }
859
+ +
860
+ +// Export utilities for testing
861
+ +export { toArray, escapeRegex, validateDomain };
862
+ diff --git a/src/ip.ts b/src/ip.ts
863
+ index e212955..4f5bbbd 100644
864
+ --- a/src/ip.ts
865
+ +++ b/src/ip.ts
866
+ @@ -3,7 +3,15 @@ import { WhoisResponse } from "../whois.js";
867
+ export function parseIpResponse(ip: string, rdap: any, response: WhoisResponse) {
868
+ response.found = Boolean(rdap.handle);
869
+
870
+ - const registry = rdap.port43 ? rdap.port43.match(/\.(\w+)\./)[1].toUpperCase() : '';
871
+ + // Safely extract registry from port43 with null check
872
+ + let registry = '';
873
+ + if (rdap.port43) {
874
+ + const match = rdap.port43.match(/\.(\w+)\./);
875
+ + if (match) {
876
+ + registry = match[1].toUpperCase();
877
+ + }
878
+ + }
879
+ +
880
+ const realRdapServer = rdap.links?.find(({ rel }: { rel: string }) => rel === 'self')?.value?.replace(/\/ip\/.*/, '/ip/');
881
+
882
+ response.server = realRdapServer || 'https://rdap.org/ip/';
883
+ diff --git a/src/port43.ts b/src/port43.ts
884
+ index e95b6ac..b714645 100644
885
+ --- a/src/port43.ts
886
+ +++ b/src/port43.ts
887
+ @@ -5,6 +5,18 @@ import { port43servers, port43parsers } from "./port43servers.js";
888
+ import { ianaToRegistrarCache } from "./utils/ianaIdToRegistrar.js";
889
+ import { WhoisResponse } from "../whois.js";
890
+ import { normalizeWhoisStatus } from "./whoisStatus.js";
891
+ +import createDebug from "debug";
892
+ +
893
+ +// Debug logger - enable with DEBUG=whois:* environment variable
894
+ +const debug = createDebug("whois:port43");
895
+ +
896
+ +/**
897
+ + * Escapes special regex characters in a string.
898
+ + * Prevents ReDoS attacks when using user input in RegExp constructor.
899
+ + */
900
+ +function escapeRegex(str: string): string {
901
+ + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
902
+ +}
903
+
904
+ export function determinePort43Domain(actor: string) {
905
+ const parsed = parseDomain(actor);
906
+ @@ -36,7 +48,7 @@ export async function port43(actor: string, _fetch: typeof fetch): Promise<Whois
907
+ : `${domain}\r\n`;
908
+ const port = opts?.port || 43;
909
+
910
+ - // console.log(`looking up ${domain} on ${server}`);
911
+ + debug("looking up %s on %s:%d", domain, server, port);
912
+
913
+ const response: WhoisResponse = {
914
+ found: true,
915
+ @@ -82,7 +94,7 @@ export async function port43(actor: string, _fetch: typeof fetch): Promise<Whois
916
+ }
917
+ }
918
+ } catch (error: any) {
919
+ - console.warn({ port, server, query, error: error.message });
920
+ + debug("port43 lookup error: %O", { port, server, query, error: error.message });
921
+ response.found = false;
922
+ response.statusCode = 500;
923
+ response.error = error.message || "Unknown error during port 43 lookup";
924
+ @@ -93,7 +105,6 @@ export async function port43(actor: string, _fetch: typeof fetch): Promise<Whois
925
+ }
926
+
927
+ port43response = port43response.replace(/^[ \t]+/gm, "");
928
+ - // console.log(port43response);
929
+
930
+ let m;
931
+
932
+ @@ -145,12 +156,6 @@ export async function port43(actor: string, _fetch: typeof fetch): Promise<Whois
933
+ )) &&
934
+ (response.reseller = m[1].trim());
935
+
936
+ - // console.log(port43response)
937
+ -
938
+ -// Updated Date: 2024-11-21T13:42:54Z
939
+ -// Creation Date: 2017-12-16T02:11:08Z
940
+ -// Registry Expiry Date: 2031-07-10T02:11:08Z
941
+ -
942
+ !response.ts.updated &&
943
+ (m = port43response.match(
944
+ /^(?:Last Modified|Updated Date|Last updated on|domain_datelastmodified|last-update|modified|last modified)\.*:[ \t]*(\S.+)/im
945
+ @@ -235,6 +240,7 @@ export async function port43(actor: string, _fetch: typeof fetch): Promise<Whois
946
+ if (response.ts.updated && !response.ts.updated.valueOf()) response.ts.updated = null;
947
+ if (response.ts.expires && !response.ts.expires.valueOf()) response.ts.expires = null;
948
+
949
+ + // Match registrar name against IANA cache using escaped regex to prevent ReDoS
950
+ if (response.registrar.id === 0 && response.registrar.name !== "") {
951
+ for (const [id, { name }] of ianaToRegistrarCache.entries()) {
952
+ if (name === response.registrar.name) {
953
+ @@ -244,24 +250,32 @@ export async function port43(actor: string, _fetch: typeof fetch): Promise<Whois
954
+ }
955
+ }
956
+
957
+ - if (response.registrar.id === 0 && response.registrar.name !== "") {
958
+ + if (response.registrar.id === 0 && response.registrar.name && response.registrar.name !== "") {
959
+ + const escapedName = escapeRegex(response.registrar.name);
960
+ for (const [id, { name }] of ianaToRegistrarCache.entries()) {
961
+ - if (name.match(new RegExp(`\\b${response.registrar.name}\\b`, "i"))) {
962
+ - response.registrar.id = id;
963
+ - break;
964
+ + try {
965
+ + if (name.match(new RegExp(`\\b${escapedName}\\b`, "i"))) {
966
+ + response.registrar.id = id;
967
+ + break;
968
+ + }
969
+ + } catch {
970
+ + // Skip if regex still fails for some reason
971
+ + continue;
972
+ }
973
+ }
974
+ }
975
+
976
+ if (response.registrar.id === 0 && response.registrar.name) {
977
+ + const escapedName = escapeRegex(response.registrar.name.replace(/,.*/, ""));
978
+ for (const [id, { name }] of ianaToRegistrarCache.entries()) {
979
+ - if (
980
+ - name.match(
981
+ - new RegExp(`\\b${response.registrar.name.replace(/,.*/, "")}\\b`, "i")
982
+ - )
983
+ - ) {
984
+ - response.registrar.id = id;
985
+ - break;
986
+ + try {
987
+ + if (name.match(new RegExp(`\\b${escapedName}\\b`, "i"))) {
988
+ + response.registrar.id = id;
989
+ + break;
990
+ + }
991
+ + } catch {
992
+ + // Skip if regex still fails for some reason
993
+ + continue;
994
+ }
995
+ }
996
+ }
997
+ @@ -292,4 +306,4 @@ function reformatDate(date: string) {
998
+ }
999
+
1000
+ return date;
1001
+ -}
1002
+
1003
+ +}
1004
+ diff --git a/src/utils/findInObject.ts b/src/utils/findInObject.ts
1005
+ index e533ca2..593a012 100644
1006
+ --- a/src/utils/findInObject.ts
1007
+ +++ b/src/utils/findInObject.ts
1008
+ @@ -7,17 +7,32 @@ export function findInObject(
1009
+ const found = _findInObject(obj, condition);
1010
+ return found === undefined ? fallback : extractor(found);
1011
+ }
1012
+ +
1013
+ function _findInObject(obj: any, condition: (el: any) => boolean): any {
1014
+ + // Handle null/undefined
1015
+ + if (obj === null || obj === undefined) {
1016
+ + return undefined;
1017
+ + }
1018
+ +
1019
+ for (const key in obj) {
1020
+ - if (condition(obj[key])) {
1021
+ - return obj[key];
1022
+ + // Skip inherited properties
1023
+ + if (!Object.prototype.hasOwnProperty.call(obj, key)) {
1024
+ + continue;
1025
+ }
1026
+
1027
+ - if (typeof obj[key] === "object") {
1028
+ - const result = _findInObject(obj[key], condition);
1029
+ + const value = obj[key];
1030
+ +
1031
+ + if (condition(value)) {
1032
+ + return value;
1033
+ + }
1034
+ +
1035
+ + if (value !== null && typeof value === "object") {
1036
+ + const result = _findInObject(value, condition);
1037
+ if (result !== undefined) {
1038
+ return result;
1039
+ }
1040
+ }
1041
+ }
1042
+ +
1043
+ + return undefined;
1044
+ }