@slicedearth/whoisleuth-cli 2.1.0 → 2.3.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.
Files changed (77) hide show
  1. package/README.md +2 -1
  2. package/cli/arguments.mjs +389 -1745
  3. package/cli/artifact-validation/case-response.mjs +55 -30
  4. package/cli/artifact-validation/signed-review.mjs +30 -12
  5. package/cli/bulk-checkpoint.mjs +34 -3
  6. package/cli/case-pack.mjs +39 -92
  7. package/cli/command-argument-grammar.mjs +211 -0
  8. package/cli/command-reference.mjs +200 -213
  9. package/cli/completion.mjs +5 -1
  10. package/cli/discovery-observation-snapshot.mjs +16 -3
  11. package/cli/discovery-scan-command-runner.mjs +19 -0
  12. package/cli/discovery-scan.mjs +6 -0
  13. package/cli/domain-control-observations.mjs +1 -1
  14. package/cli/inline-command-families.mjs +1 -0
  15. package/cli/input.mjs +116 -0
  16. package/cli/interchange-report.mjs +14 -0
  17. package/cli/lookalike-calibration.mjs +7 -2
  18. package/cli/lookup-brief.mjs +4 -2
  19. package/cli/lookup-browser-view.mjs +1 -1
  20. package/cli/mail-header-review.mjs +467 -0
  21. package/cli/review-command-runner.mjs +25 -0
  22. package/cli/risk-calibration.mjs +19 -17
  23. package/cli/runner.mjs +7 -1
  24. package/docs/cli-reference.md +8 -1
  25. package/docs/cli.md +24 -1
  26. package/lib/bounded-json.mjs +9 -6
  27. package/lib/dns-convergence-review.mjs +1 -0
  28. package/lib/dns-intelligence.mjs +37 -3
  29. package/lib/dnssec-chain-validation.mjs +6 -0
  30. package/lib/domain-change-packet.mjs +42 -14
  31. package/lib/domain-control-flight-recorder.mjs +4 -1
  32. package/lib/domain-control-manifest.mjs +16 -16
  33. package/lib/domain-posture-parsers.mjs +114 -3
  34. package/lib/domain-posture.mjs +34 -3
  35. package/lib/evidence-export.mjs +63 -13
  36. package/lib/evidence-report.mjs +2 -2
  37. package/lib/html-signals.mjs +39 -33
  38. package/lib/local-geoip-evidence.mjs +19 -3
  39. package/lib/lookup-response.mjs +5 -4
  40. package/lib/lookup-source-progress.mjs +27 -4
  41. package/lib/nameserver-preflight-review.mjs +1 -0
  42. package/lib/rdap-capabilities.mjs +1 -1
  43. package/lib/registry-insights.mjs +35 -22
  44. package/lib/risk-calibration-summary.mjs +1 -3
  45. package/lib/security-txt.mjs +33 -1
  46. package/lib/static-html-analysis.mjs +69 -13
  47. package/lib/website-technology.mjs +79 -11
  48. package/lib/zone-intent-review.mjs +16 -6
  49. package/package.json +1 -1
  50. package/packages/cases/case-migration-model.mjs +71 -22
  51. package/packages/cases/case-record-contracts.mjs +5 -20
  52. package/packages/cases/case-record-core.mjs +10 -21
  53. package/packages/cases/case-record-decisions.mjs +72 -0
  54. package/packages/cases/case-record-model.mjs +1 -0
  55. package/packages/cases/case-record-operations.mjs +240 -3
  56. package/packages/cases/case-record-projection.mjs +218 -0
  57. package/packages/cases/case-report.mjs +11 -6
  58. package/packages/cases/case-response-model.mjs +43 -6
  59. package/packages/cases/case-response-packet.mjs +78 -27
  60. package/packages/cases/case-storage-model.mjs +6 -2
  61. package/packages/comparison/comparison-ledger-bulk.mjs +9 -10
  62. package/packages/comparison/page-similarity.mjs +3 -1
  63. package/packages/contracts/case-portability.mjs +183 -53
  64. package/packages/contracts/cli-command-semantics.mjs +93 -0
  65. package/packages/contracts/risk-calibration.mjs +15 -10
  66. package/packages/contracts/workspace-portability.mjs +44 -7
  67. package/packages/evidence/domain-control-runtime.mjs +46 -6
  68. package/packages/interchange/external-findings-import.mjs +115 -46
  69. package/packages/investigation/lookup-asset-graph.mjs +35 -27
  70. package/packages/workspace/brand-profile-model.mjs +118 -2
  71. package/packages/workspace/bulk-review-model.mjs +13 -7
  72. package/packages/workspace/bulk-session-model.mjs +5 -4
  73. package/packages/workspace/detection-rule-model.mjs +70 -22
  74. package/packages/workspace/relationship-observation-model.mjs +4 -1
  75. package/packages/workspace/website-snapshot-model.mjs +106 -8
  76. package/packages/workspace/workspace-archive.mjs +2 -0
  77. package/cli/evidence-command-arguments.mjs +0 -156
@@ -50,6 +50,38 @@ function normalizedPublishedUri(value, schemes) {
50
50
  return null;
51
51
  if (url.protocol === 'https:' && !url.hostname)
52
52
  return null;
53
+ if (url.protocol === 'mailto:') {
54
+ let recipients;
55
+ try {
56
+ recipients = decodeURIComponent(url.pathname).split(',');
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ if (!recipients.length || recipients.some((recipient) => {
62
+ const separator = recipient.lastIndexOf('@');
63
+ if (separator <= 0 || separator === recipient.length - 1)
64
+ return true;
65
+ const local = recipient.slice(0, separator);
66
+ const mailDomain = recipient.slice(separator + 1).toLowerCase();
67
+ return !/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+$/iu.test(local)
68
+ || local.startsWith('.') || local.endsWith('.') || local.includes('..')
69
+ || !canonicalHostname(mailDomain);
70
+ }))
71
+ return null;
72
+ }
73
+ if (url.protocol === 'tel:') {
74
+ let number;
75
+ try {
76
+ number = decodeURIComponent(url.pathname);
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ if (!/^\+?[0-9(). -]+(?:;[a-z0-9-]+=[a-z0-9.-]+)*$/iu.test(number)
82
+ || (number.match(/[0-9]/gu)?.length ?? 0) < 3)
83
+ return null;
84
+ }
53
85
  url.search = '';
54
86
  url.hash = '';
55
87
  return url.toString().slice(0, MAX_SECURITY_TXT_URL_LENGTH);
@@ -211,7 +243,7 @@ function parseSecurityTxt(text, options = {}) {
211
243
  const finalComparisonUrl = options.finalUrl ? normalizedCanonicalComparisonUrl(options.finalUrl) : null;
212
244
  const canonicalMatches = boundedCanonical.values.length && finalUrl && finalComparisonUrl
213
245
  ? canonicalComparison.slice(0, MAX_SECURITY_TXT_VALUES)
214
- .some((value) => value.toLowerCase() === finalComparisonUrl.toLowerCase())
246
+ .some((value) => value === finalComparisonUrl)
215
247
  : null;
216
248
  const stale = Boolean(expiresAt && Date.parse(expiresAt) <= (options.now ?? Date.now()));
217
249
  const requiredMalformed = boundedContacts.values.length === 0 || expires.length !== 1;
@@ -73,7 +73,7 @@ const JAVASCRIPT_MEDIA_TYPES = new Set([
73
73
  'text/javascript1.1', 'text/javascript1.2', 'text/javascript1.3', 'text/javascript1.4',
74
74
  'text/javascript1.5', 'text/jscript', 'text/livescript', 'text/x-ecmascript', 'text/x-javascript',
75
75
  ]);
76
- function attributeValue(attributes, name, maximumLength = MAX_FORM_ATTRIBUTE_LENGTH) {
76
+ function attributeValue(attributes, name, maximumLength = MAX_FORM_ATTRIBUTE_LENGTH, lowercase = true) {
77
77
  const attribute = attributes
78
78
  .slice(0, MAX_ATTRIBUTES_PER_TAG)
79
79
  .find((candidate) => candidate.name.toLowerCase() === name);
@@ -84,7 +84,7 @@ function attributeValue(attributes, name, maximumLength = MAX_FORM_ATTRIBUTE_LEN
84
84
  }
85
85
  return {
86
86
  present: true,
87
- value: attribute.value.trim().toLowerCase(),
87
+ value: lowercase ? attribute.value.trim().toLowerCase() : attribute.value.trim(),
88
88
  truncated: attributes.length > MAX_ATTRIBUTES_PER_TAG,
89
89
  };
90
90
  }
@@ -116,8 +116,8 @@ function formMethod(attributes) {
116
116
  truncated: method.truncated,
117
117
  };
118
118
  }
119
- function formAction(attributes, baseUrl) {
120
- const action = attributeValue(attributes, 'action');
119
+ function formAction(attributes, resolutionBase, documentOrigin) {
120
+ const action = attributeValue(attributes, 'action', MAX_FORM_ATTRIBUTE_LENGTH, false);
121
121
  if (!action.present && action.truncated) {
122
122
  return { relationship: 'unclassified', cleartext: false, truncated: true };
123
123
  }
@@ -127,16 +127,16 @@ function formAction(attributes, baseUrl) {
127
127
  if (action.value === null) {
128
128
  return { relationship: 'unclassified', cleartext: false, truncated: true };
129
129
  }
130
- if (!baseUrl) {
130
+ if (!resolutionBase || !documentOrigin) {
131
131
  return { relationship: 'unclassified', cleartext: false, truncated: action.truncated };
132
132
  }
133
133
  try {
134
- const parsed = new URL(action.value, baseUrl);
134
+ const parsed = new URL(action.value, resolutionBase);
135
135
  if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || !parsed.hostname) {
136
136
  return { relationship: 'unclassified', cleartext: false, truncated: action.truncated };
137
137
  }
138
138
  return {
139
- relationship: parsed.origin === baseUrl.origin ? 'sameOrigin' : 'external',
139
+ relationship: parsed.origin === documentOrigin ? 'sameOrigin' : 'external',
140
140
  cleartext: parsed.protocol === 'http:',
141
141
  truncated: action.truncated,
142
142
  };
@@ -238,7 +238,9 @@ function analyzeStaticHtml(value, options = {}) {
238
238
  const supplied = typeof value === 'string' ? value : '';
239
239
  const inputLimitReached = supplied.length > MAX_STATIC_HTML_CHARS;
240
240
  const html = supplied.slice(0, MAX_STATIC_HTML_CHARS);
241
- const baseUrl = safeBaseUrl(options.baseUrl);
241
+ const documentUrl = safeBaseUrl(options.baseUrl);
242
+ let effectiveBaseUrl = documentUrl;
243
+ let baseHrefState = 'absent';
242
244
  const includeVisibleText = options.includeVisibleText === true;
243
245
  const markup = [];
244
246
  const visibleTextParts = [];
@@ -316,6 +318,17 @@ function analyzeStaticHtml(value, options = {}) {
316
318
  let scriptElementSeen = false;
317
319
  let nonVisibleTextDepth = 0;
318
320
  let visibleTextCharacters = 0;
321
+ let titleDepth = 0;
322
+ let titleCharacters = 0;
323
+ const titleParts = [];
324
+ const pendingFormActions = [];
325
+ function appendTitle(chars) {
326
+ if (titleDepth === 0 || !chars || titleCharacters >= 201)
327
+ return;
328
+ const retained = chars.slice(0, 201 - titleCharacters);
329
+ titleParts.push(retained);
330
+ titleCharacters += retained.length;
331
+ }
319
332
  function appendInlineScript(chars) {
320
333
  if (!activeInlineScript || !chars)
321
334
  return;
@@ -374,6 +387,29 @@ function analyzeStaticHtml(value, options = {}) {
374
387
  implicitHeadTextDepth += 1;
375
388
  }
376
389
  const inPublicationHead = insideExplicitHead || (!headScopeClosed && !bodyContentStarted && HEAD_CONTEXT_TAGS.has(tagName));
390
+ if (tagName === 'base' && inPublicationHead && baseHrefState === 'absent') {
391
+ const href = attributeValue(token.attrs, 'href', MAX_FORM_ATTRIBUTE_LENGTH, false);
392
+ if (href.truncated && !href.present) {
393
+ effectiveBaseUrl = documentUrl;
394
+ baseHrefState = 'invalid';
395
+ }
396
+ else if (href.present) {
397
+ try {
398
+ const parsed = href.value !== null && documentUrl ? new URL(href.value, documentUrl) : null;
399
+ if (!parsed || !['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || !parsed.hostname) {
400
+ throw new Error('Invalid document base URL.');
401
+ }
402
+ effectiveBaseUrl = parsed;
403
+ baseHrefState = 'valid';
404
+ }
405
+ catch {
406
+ effectiveBaseUrl = documentUrl;
407
+ baseHrefState = 'invalid';
408
+ }
409
+ }
410
+ }
411
+ if (tagName === 'title' && titleDepth === 0 && titleParts.length === 0)
412
+ titleDepth = 1;
377
413
  if (/^h[1-6]$/u.test(tagName)) {
378
414
  const key = tagName;
379
415
  publicationMetadata.headings[key] += 1;
@@ -566,11 +602,8 @@ function analyzeStaticHtml(value, options = {}) {
566
602
  forms.formsObserved += 1;
567
603
  const method = formMethod(token.attrs);
568
604
  forms.methods[method.value] += 1;
569
- const action = formAction(token.attrs, baseUrl);
570
- forms.actions[action.relationship] += 1;
571
- if (action.cleartext)
572
- forms.actions.cleartext += 1;
573
- if (method.truncated || action.truncated)
605
+ pendingFormActions.push(token.attrs);
606
+ if (method.truncated || token.attrs.length > MAX_ATTRIBUTES_PER_TAG)
574
607
  forms.truncated = true;
575
608
  }
576
609
  }
@@ -612,6 +645,8 @@ function analyzeStaticHtml(value, options = {}) {
612
645
  const tagName = token.tagName.toLowerCase();
613
646
  if (tagName === 'script')
614
647
  activeInlineScript = null;
648
+ if (tagName === 'title' && titleDepth > 0)
649
+ titleDepth = 0;
615
650
  if (tagName === 'head') {
616
651
  insideExplicitHead = false;
617
652
  headScopeClosed = true;
@@ -630,6 +665,7 @@ function analyzeStaticHtml(value, options = {}) {
630
665
  headScopeClosed = true;
631
666
  }
632
667
  appendInlineScript(token.chars);
668
+ appendTitle(token.chars);
633
669
  appendVisibleText(token.chars);
634
670
  },
635
671
  onNullCharacter(token) {
@@ -638,10 +674,12 @@ function analyzeStaticHtml(value, options = {}) {
638
674
  headScopeClosed = true;
639
675
  }
640
676
  appendInlineScript(token.chars);
677
+ appendTitle(token.chars);
641
678
  appendVisibleText(token.chars);
642
679
  },
643
680
  onWhitespaceCharacter(token) {
644
681
  appendInlineScript(token.chars);
682
+ appendTitle(token.chars);
645
683
  appendVisibleText(token.chars);
646
684
  },
647
685
  onComment() { },
@@ -650,6 +688,16 @@ function analyzeStaticHtml(value, options = {}) {
650
688
  };
651
689
  tokenizer = new Tokenizer({ sourceCodeLocationInfo: false }, handler);
652
690
  tokenizer.write(html, true);
691
+ for (const attributes of pendingFormActions) {
692
+ const action = formAction(attributes, effectiveBaseUrl, documentUrl?.origin ?? null);
693
+ forms.actions[action.relationship] += 1;
694
+ if (action.cleartext)
695
+ forms.actions.cleartext += 1;
696
+ if (action.truncated)
697
+ forms.truncated = true;
698
+ }
699
+ if (baseHrefState === 'invalid')
700
+ forms.truncated = true;
653
701
  publicationMetadata.truncated = publicationMetadata.truncated || inputLimitReached || publicationTagLimitReached;
654
702
  publicationMetadata.documentTruncated = inputLimitReached || publicationTagLimitReached;
655
703
  if (publicationMetadata.documentTruncated) {
@@ -671,6 +719,14 @@ function analyzeStaticHtml(value, options = {}) {
671
719
  publicationMetadata.renderBlockingCandidates.total = publicationMetadata.renderBlockingCandidates.script
672
720
  + publicationMetadata.renderBlockingCandidates.stylesheet;
673
721
  return {
722
+ title: (() => {
723
+ const value = titleParts.join('').replace(CONTROL_CHARACTER_RE_GLOBAL, ' ').replace(/\ufffd/gu, ' ').replace(/\s+/gu, ' ').trim();
724
+ if (!value)
725
+ return null;
726
+ return value.length > 200 ? `${value.slice(0, 200)}…` : value;
727
+ })(),
728
+ effectiveBaseUrl: effectiveBaseUrl?.toString() ?? null,
729
+ baseHrefState,
674
730
  markup: markup.join('\n'),
675
731
  visibleText: visibleTextParts.join('').replace(CONTROL_CHARACTER_RE_GLOBAL, ' ').replace(/\s+/gu, ' '),
676
732
  structureTokens,
@@ -9,6 +9,23 @@ import { createObservation } from "../packages/evidence/observation.mjs";
9
9
  import { MAX_STATIC_HTML_CHARS, MAX_TAG_LENGTH, MAX_TECHNOLOGY_TAGS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
10
10
  import { TECHNOLOGY_EVIDENCE_ROLE_ORDER, } from "./technology-evidence-role.mjs";
11
11
  import { MAX_EVIDENCE_PER_TECHNOLOGY, MAX_TECHNOLOGY_EVIDENCE_DESCRIPTION_LENGTH, MAX_TECHNOLOGY_FINDINGS, TECHNOLOGY_PROFILE_VERSION, } from "./lookup-child-profile-contract.mjs";
12
+ const TECHNOLOGY_CATEGORIES = Object.freeze([
13
+ 'application runtime',
14
+ 'content management',
15
+ 'commerce',
16
+ 'site builder',
17
+ 'web framework',
18
+ 'static site generator',
19
+ 'web server',
20
+ 'delivery platform',
21
+ ]);
22
+ const TECHNOLOGY_EVIDENCE_SOURCES = Object.freeze([
23
+ 'generator metadata',
24
+ 'static HTML',
25
+ 'resource origin',
26
+ 'HTTP server header',
27
+ 'passive response header',
28
+ ]);
12
29
  const MAX_TECHNOLOGY_HTML_CHARS = MAX_STATIC_HTML_CHARS;
13
30
  const MAX_TECHNOLOGY_TAG_LENGTH = MAX_TAG_LENGTH;
14
31
  const MAX_RESOURCE_ORIGINS = 30;
@@ -38,6 +55,29 @@ function normalizedResourceHosts(value) {
38
55
  }
39
56
  return hosts;
40
57
  }
58
+ function applicationMarkup(markup, resourceHosts, effectiveBaseUrl, documentOrigin) {
59
+ let resolutionBase;
60
+ try {
61
+ resolutionBase = new URL(typeof effectiveBaseUrl === 'string' ? effectiveBaseUrl : 'https://document.invalid/');
62
+ }
63
+ catch {
64
+ resolutionBase = new URL('https://document.invalid/');
65
+ }
66
+ const comparedOrigin = typeof documentOrigin === 'string' && /^https?:\/\//iu.test(documentOrigin)
67
+ ? documentOrigin
68
+ : null;
69
+ return markup.replace(/\s(src|href|poster|data)="([^"]*)"/giu, (attribute, name, value) => {
70
+ try {
71
+ const parsed = new URL(value, resolutionBase);
72
+ const knownExternalHost = resourceHosts.has(parsed.hostname.toLowerCase());
73
+ const offOrigin = comparedOrigin ? parsed.origin !== comparedOrigin : knownExternalHost;
74
+ return knownExternalHost && offOrigin ? ` ${name.toLowerCase()}=""` : attribute;
75
+ }
76
+ catch {
77
+ return attribute;
78
+ }
79
+ });
80
+ }
41
81
  const PASSIVE_TECHNOLOGY_HEADER_NAMES = Object.freeze([
42
82
  'cf-ray',
43
83
  'x-drupal-cache',
@@ -106,6 +146,17 @@ function htmlEvidence(markers, description, confidence = 'high') {
106
146
  matches: ({ html }) => markers.some((marker) => html.includes(marker)),
107
147
  };
108
148
  }
149
+ function resourcePathEvidence(markers, description, confidence = 'high') {
150
+ return {
151
+ source: 'static HTML',
152
+ description,
153
+ confidence,
154
+ matches: ({ html }) => markers.some((marker) => html.includes(marker)),
155
+ roleFor: ({ applicationHtml }) => markers.some((marker) => applicationHtml.includes(marker))
156
+ ? undefined
157
+ : 'embedded_dependency',
158
+ };
159
+ }
109
160
  function resourceEvidence(hosts, description) {
110
161
  return {
111
162
  source: 'resource origin',
@@ -142,7 +193,10 @@ function responseHeaderEvidence(name, pattern, description, confidence = 'high')
142
193
  },
143
194
  };
144
195
  }
145
- function evidenceRole(signature, evidence) {
196
+ function evidenceRole(signature, evidence, context) {
197
+ const contextualRole = context ? evidence.roleFor?.(context) : undefined;
198
+ if (contextualRole)
199
+ return contextualRole;
146
200
  if (evidence.role)
147
201
  return evidence.role;
148
202
  if (evidence.source === 'resource origin')
@@ -159,7 +213,7 @@ const TECHNOLOGY_SIGNATURES = [
159
213
  id: 'wordpress', name: 'WordPress', category: 'content management',
160
214
  evidence: [
161
215
  generatorEvidence(/^wordpress(?:\s|$)/i, 'Generator metadata identifies WordPress.'),
162
- htmlEvidence(['/wp-content/', '/wp-includes/'], 'Static resource paths use WordPress conventions.', 'medium'),
216
+ resourcePathEvidence(['/wp-content/', '/wp-includes/'], 'Static resource paths use WordPress conventions.', 'medium'),
163
217
  ],
164
218
  },
165
219
  {
@@ -222,8 +276,9 @@ const TECHNOLOGY_SIGNATURES = [
222
276
  {
223
277
  id: 'bigcommerce', name: 'BigCommerce', category: 'commerce',
224
278
  minimumEvidenceMatches: 2,
279
+ allowEmbeddedOnly: true,
225
280
  evidence: [
226
- htmlEvidence(['cdn11.bigcommerce.com/s-', 'stencil-utils'], 'Static markup contains BigCommerce storefront asset markers.', 'medium'),
281
+ resourcePathEvidence(['cdn11.bigcommerce.com/s-', 'stencil-utils'], 'Static markup contains BigCommerce storefront asset markers.', 'medium'),
227
282
  resourcePatternEvidence(/^cdn\d+\.bigcommerce\.com$/i, 'A retained resource origin uses BigCommerce storefront delivery infrastructure.'),
228
283
  ],
229
284
  },
@@ -421,28 +476,41 @@ function analyzeWebsiteTechnology(input = {}) {
421
476
  }) : null;
422
477
  const context = {
423
478
  html: htmlAnalysis.markup,
479
+ applicationHtml: '',
424
480
  generator: boundedLowercase(input.generator, MAX_GENERATOR_INPUT),
425
481
  httpServer: boundedLowercase(input.httpServer, MAX_SERVER_INPUT),
426
482
  resourceHosts: normalizedResourceHosts(input.resourceOrigins),
427
483
  responseHeaders: normalizedResponseHeaders(input.responseHeaders),
428
484
  };
485
+ context.applicationHtml = applicationMarkup(context.html, context.resourceHosts, input.effectiveBaseUrl, input.documentOrigin);
429
486
  const findings = [];
430
487
  for (const signature of TECHNOLOGY_SIGNATURES) {
431
- const matched = signature.evidence.filter((evidence) => evidence.matches(context));
432
- if (matched.length < (signature.minimumEvidenceMatches || 1))
433
- continue;
434
- if (signature.requiresNonResourceEvidence && matched.every((evidence) => evidence.source === 'resource origin'))
488
+ const matched = signature.evidence
489
+ .filter((evidence) => evidence.matches(context))
490
+ .map((evidence) => ({ evidence, role: evidenceRole(signature, evidence, context) }));
491
+ const nonEmbeddedMatches = matched.filter((entry) => entry.role !== 'embedded_dependency');
492
+ const minimumEvidenceMatches = signature.minimumEvidenceMatches || 1;
493
+ const qualifiesForPlatform = matched.length >= minimumEvidenceMatches
494
+ && (minimumEvidenceMatches === 1 || nonEmbeddedMatches.length > 0)
495
+ && (!signature.requiresNonResourceEvidence || nonEmbeddedMatches.length > 0);
496
+ const contextualEmbeddedEvidence = matched.filter((entry) => (entry.role === 'embedded_dependency' && entry.evidence.roleFor !== undefined));
497
+ const selected = qualifiesForPlatform
498
+ ? matched
499
+ : signature.allowEmbeddedOnly
500
+ ? contextualEmbeddedEvidence.slice(0, 1)
501
+ : [];
502
+ if (selected.length === 0)
435
503
  continue;
436
- const matchedEvidence = matched.slice(0, MAX_EVIDENCE_PER_TECHNOLOGY).map((evidence) => ({
504
+ const matchedEvidence = selected.slice(0, MAX_EVIDENCE_PER_TECHNOLOGY).map(({ evidence, role }) => ({
437
505
  source: evidence.source,
438
- role: evidenceRole(signature, evidence),
506
+ role,
439
507
  description: evidence.description,
440
508
  }));
441
509
  findings.push({
442
510
  id: signature.id,
443
511
  name: signature.name,
444
512
  category: signature.category,
445
- confidence: matched.some((evidence) => evidence.confidence === 'high') ? 'high' : 'medium',
513
+ confidence: selected.some(({ evidence }) => evidence.confidence === 'high') ? 'high' : 'medium',
446
514
  roles: TECHNOLOGY_EVIDENCE_ROLE_ORDER.filter((role) => matchedEvidence.some((evidence) => evidence.role === role)),
447
515
  evidence: matchedEvidence,
448
516
  });
@@ -493,4 +561,4 @@ function analyzeWebsiteTechnology(input = {}) {
493
561
  browserLibraryProfile,
494
562
  };
495
563
  }
496
- export { MAX_EVIDENCE_PER_TECHNOLOGY, MAX_TECHNOLOGY_EVIDENCE_DESCRIPTION_LENGTH, MAX_TECHNOLOGY_FINDINGS, MAX_TECHNOLOGY_HTML_CHARS, MAX_TECHNOLOGY_TAGS, PASSIVE_TECHNOLOGY_HEADER_NAMES, TECHNOLOGY_PROFILE_VERSION, TECHNOLOGY_SIGNATURE_CATALOGUE, analyzeWebsiteTechnology, captureTechnologyResponseHeaders, };
564
+ export { MAX_EVIDENCE_PER_TECHNOLOGY, MAX_TECHNOLOGY_EVIDENCE_DESCRIPTION_LENGTH, MAX_TECHNOLOGY_FINDINGS, MAX_TECHNOLOGY_HTML_CHARS, MAX_TECHNOLOGY_TAGS, PASSIVE_TECHNOLOGY_HEADER_NAMES, TECHNOLOGY_CATEGORIES, TECHNOLOGY_EVIDENCE_SOURCES, TECHNOLOGY_PROFILE_VERSION, TECHNOLOGY_SIGNATURE_CATALOGUE, analyzeWebsiteTechnology, captureTechnologyResponseHeaders, };
@@ -142,7 +142,11 @@ export function normaliseRdata(type, rawValue, origin, nameMode = 'absolute') {
142
142
  const family = type === 'A' ? 4 : 6;
143
143
  if (tokens.length !== 1 || isIP(tokens[0] ?? '') !== family)
144
144
  throw new TypeError(`${type} data must contain one valid address.`);
145
- return { value: tokens[0].toLowerCase(), valueTreatment: 'normalised' };
145
+ const supplied = tokens[0];
146
+ const normalised = family === 4
147
+ ? supplied.split('.').map((part) => String(Number(part))).join('.')
148
+ : new URL(`http://[${supplied}]/`).hostname.slice(1, -1).toLowerCase();
149
+ return { value: normalised, valueTreatment: 'normalised' };
146
150
  }
147
151
  if (type === 'CNAME' || type === 'NS') {
148
152
  if (tokens.length !== 1)
@@ -152,7 +156,12 @@ export function normaliseRdata(type, rawValue, origin, nameMode = 'absolute') {
152
156
  if (type === 'MX') {
153
157
  if (tokens.length !== 2)
154
158
  throw new TypeError('MX data must contain preference and exchange.');
155
- return { value: `${integerToken(tokens[0], 0, 65_535, 'MX preference')} ${host(tokens[1], 'MX exchange')}`, valueTreatment: 'normalised' };
159
+ const preference = integerToken(tokens[0], 0, 65_535, 'MX preference');
160
+ const exchangeToken = tokens[1];
161
+ if (exchangeToken === '.' && preference !== 0)
162
+ throw new TypeError('A Null MX exchange must use preference 0.');
163
+ const exchange = exchangeToken === '.' ? '.' : host(exchangeToken, 'MX exchange');
164
+ return { value: `${preference} ${exchange}`, valueTreatment: 'normalised' };
156
165
  }
157
166
  if (type === 'CAA') {
158
167
  if (tokens.length < 3)
@@ -380,7 +389,6 @@ function parseBindZone(zoneText, initialOrigin) {
380
389
  if (tokens.length !== 2)
381
390
  throw new TypeError('$ORIGIN requires one domain name.');
382
391
  origin = domainName(tokens[1], origin, '$ORIGIN', 'master-file');
383
- lastOwner = origin;
384
392
  continue;
385
393
  }
386
394
  if (directive === '$TTL')
@@ -388,10 +396,12 @@ function parseBindZone(zoneText, initialOrigin) {
388
396
  if (directive?.startsWith('$'))
389
397
  throw new TypeError(`${directive} is deliberately unsupported.`);
390
398
  let offset = 0;
391
- const ownerToken = entry.ownerOmitted ? lastOwner : tokens[offset++];
392
- if (!ownerToken)
399
+ const ownerToken = entry.ownerOmitted ? null : tokens[offset++];
400
+ if (!entry.ownerOmitted && !ownerToken)
393
401
  throw new TypeError('Record owner is missing.');
394
- const owner = ownerName(ownerToken, origin, 'record owner', 'master-file');
402
+ const owner = entry.ownerOmitted
403
+ ? lastOwner
404
+ : ownerName(ownerToken, origin, 'record owner', 'master-file');
395
405
  lastOwner = owner;
396
406
  let ttl = null;
397
407
  if (/^\d+$/u.test(tokens[offset] ?? ''))
package/package.json CHANGED
@@ -33,7 +33,7 @@
33
33
  "bugs": {
34
34
  "url": "https://github.com/slicedearth/whoisleuth/issues"
35
35
  },
36
- "version": "2.1.0",
36
+ "version": "2.3.0",
37
37
  "dependencies": {
38
38
  "@peculiar/x509": "2.0.0",
39
39
  "maxmind": "5.0.7",