@slicedearth/whoisleuth-cli 2.2.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 +12 -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 +60 -11
  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 +6 -4
  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 +171 -51
  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 +3 -2
  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
@@ -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.2.0",
36
+ "version": "2.3.0",
37
37
  "dependencies": {
38
38
  "@peculiar/x509": "2.0.0",
39
39
  "maxmind": "5.0.7",
@@ -1,13 +1,10 @@
1
- import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, MAX_CASES, MAX_NOTES_PER_CASE, deterministicId, caseTimestampOrNull, normalizeCase, normalizeDomain, normalizeEvidenceHistory, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, } from "./case-record-model.mjs";
1
+ import { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, MAX_CASES, MAX_NOTES_PER_CASE, deterministicId, caseTimestampOrNull, normalizeCase, normalizeDomain, normalizeEvidenceHistory, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, isValidDisposition, isValidSource, isValidStatus, } from "./case-record-model.mjs";
2
2
  import { CASE_REPORT_SCHEMA, PUBLIC_CASE_SCHEMA_VERSION, caseReportVersionMatchesCase, CLI_CASE_PACK_CURRENT_REDACTION_KEYS, CLI_CASE_PACK_INTEGRITY_KEYS, CLI_CASE_PACK_LIMITATIONS, CLI_CASE_PACK_PACKET_KEYS, CLI_CASE_PACK_PUBLIC_REPORT_KEYS, CLI_CASE_PACK_REPORT_KEYS, CLI_CASE_PACK_ROOT_KEYS, CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, } from "../contracts/case-portability.mjs";
3
3
  import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
4
4
  import { assertBoundedJsonStructure } from "../../lib/bounded-json.mjs";
5
5
  import { inspectCaseBrandProfileIds, unionCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
6
6
  import { caseInvestigationBranchReferences, mergeCaseInvestigationBranches, normalizeCaseInvestigationBranches, } from "./case-investigation-branch-model.mjs";
7
- import { buildCaseClosureLinkContext, mergeCaseActions, mergeCaseAssertions, mergeCaseClosureHistories, mergeCaseDecisions, mergeCaseEvidencePins, mergeCaseManualTrail, mergeCaseObservedEffectHistories, mergeCaseSightings, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, } from "./case-response-model.mjs";
8
- const STATUS_VALUES = new Set(CASE_STATUSES.map((item) => item.value));
9
- const DISPOSITION_VALUES = new Set(CASE_DISPOSITIONS.map((item) => item.value));
10
- const SOURCE_VALUES = new Set(CASE_SOURCES.map((item) => item.value));
7
+ import { buildCaseClosureLinkContext, mergeCaseActions, mergeCaseClosureHistories, mergeCaseObservedEffectHistories, MAX_CASE_ASSERTIONS, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_CASE_MANUAL_TRAIL_EVENTS, MAX_CASE_SIGHTINGS, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, } from "./case-response-model.mjs";
11
8
  export const MAX_CASE_INPUT_RECORDS = 2_000;
12
9
  function compareCodeUnits(left, right) {
13
10
  return left < right ? -1 : left > right ? 1 : 0;
@@ -51,7 +48,7 @@ export function normalizeCaseStore(raw) {
51
48
  throw new TypeError(`Unversioned Case stores are retired. Export or reset them explicitly before using Case schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
52
49
  }
53
50
  if (!CASE_IMPORT_VERSIONS.includes(sourceVersion) && sourceVersion < CASE_SCHEMA_VERSION) {
54
- throw new TypeError(`Case schema ${sourceVersion} is not part of the supported compatibility boundary. Exact published schemas 12 and 13 migrate to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
51
+ throw new TypeError(`Case schema ${sourceVersion} is not part of the supported compatibility boundary. Exact published schemas ${CASE_IMPORT_VERSIONS.filter((version) => version < CASE_SCHEMA_VERSION).join(', ')} migrate to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
55
52
  }
56
53
  if (sourceVersion > CASE_SCHEMA_VERSION) {
57
54
  throw new TypeError(`Case schema ${sourceVersion} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
@@ -92,6 +89,17 @@ function assertCurrentCaseShape(raw) {
92
89
  if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'routeObservedAt')) {
93
90
  throw new TypeError(`Case schema ${CASE_SCHEMA_VERSION} response actions must declare their route observation time, including null; unreleased local Case checkpoints are not interpreted as the current format and no data was changed.`);
94
91
  }
92
+ if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'routeReviewAfter')) {
93
+ throw new TypeError(`Case schema ${CASE_SCHEMA_VERSION} response actions must declare their route review deadline, including null; unreleased local Case checkpoints are not interpreted as the current format and no data was changed.`);
94
+ }
95
+ }
96
+ const decisions = itemRecord.decisions;
97
+ for (const decision of Array.isArray(decisions) ? decisions : []) {
98
+ const record = objectRecord(decision);
99
+ if (Object.keys(record).length > 0
100
+ && (!Object.hasOwn(record, 'confidence') || !Object.hasOwn(record, 'confidenceBasis'))) {
101
+ throw new TypeError(`Case schema ${CASE_SCHEMA_VERSION} analyst decisions must declare confidence and its basis; unreleased local Case checkpoints are not interpreted as the current format and no data was changed.`);
102
+ }
95
103
  }
96
104
  }
97
105
  }
@@ -140,9 +148,6 @@ function boundedCaseList(raw) {
140
148
  * @param {string} [nowIso]
141
149
  * @returns {CaseRecord}
142
150
  */
143
- function importScalar(value, valid) {
144
- return typeof value === 'string' && valid.has(value) ? value : undefined;
145
- }
146
151
  /**
147
152
  * Validates one imported record into a patch. Unlike normalizeCase, absent or
148
153
  * invalid scalar fields stay `undefined` (never defaulted) and a missing/invalid
@@ -174,6 +179,8 @@ function extractImportPatch(raw, importedVersion) {
174
179
  const pinIds = new Set(evidencePins.map((item) => item.id));
175
180
  const actions = normalizeCaseActions(record.actions, normalizedFallback, { ...timestampOptions, validEvidencePinIds: pinIds });
176
181
  const assertions = normalizeCaseAssertions(record.assertions, normalizedFallback, pinIds, timestampOptions);
182
+ const decisions = normalizeCaseDecisions(record.decisions, normalizedFallback, pinIds, timestampOptions);
183
+ const manualTrail = normalizeCaseManualTrail(record.manualTrail, normalizedFallback, timestampOptions);
177
184
  const sightings = normalizeCaseSightings(record.sightings, normalizedFallback, pinIds, timestampOptions);
178
185
  const observedEffects = normalizeCaseObservedEffectHistory(importedVersion >= 13 ? record.observedEffects : undefined, normalizedFallback, pinIds, new Set(sightings.map((item) => item.id)), timestampOptions);
179
186
  const closures = normalizeCaseClosureHistory(importedVersion >= 13 ? record.closures : undefined, normalizedFallback, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), timestampOptions, buildCaseClosureLinkContext(observedEffects, actions));
@@ -181,17 +188,26 @@ function extractImportPatch(raw, importedVersion) {
181
188
  const brandProfileReferences = importedVersion >= 12
182
189
  ? inspectCaseBrandProfileIds(record.brandProfileIds)
183
190
  : { ids: [], omitted: 0 };
191
+ const authoredHistoryOmitted = [
192
+ [record.evidencePins, evidencePins],
193
+ [record.decisions, decisions],
194
+ [record.assertions, assertions],
195
+ [record.manualTrail, manualTrail],
196
+ [record.sightings, sightings],
197
+ ].reduce((total, [candidates, retained]) => total
198
+ + Math.max(0, (Array.isArray(candidates) ? candidates.length : 0) - retained.length), 0);
184
199
  return {
185
200
  domain,
186
201
  rawId: typeof record.id === 'string' ? record.id : null,
187
- status: importScalar(record.status, STATUS_VALUES),
188
- disposition: importScalar(record.disposition, DISPOSITION_VALUES),
202
+ status: isValidStatus(record.status) ? record.status : undefined,
203
+ disposition: isValidDisposition(record.disposition) ? record.disposition : undefined,
189
204
  reviewReasonCode: Object.hasOwn(record, 'reviewReasonCode')
190
205
  ? normalizeReviewReasonCode(record.reviewReasonCode)
191
206
  : undefined,
192
207
  brandProfileIds: brandProfileReferences.ids,
193
208
  brandProfileReferencesOmitted: brandProfileReferences.omitted,
194
- source: importScalar(record.source, SOURCE_VALUES),
209
+ authoredHistoryOmitted,
210
+ source: isValidSource(record.source) ? record.source : undefined,
195
211
  evidenceHistory: normalizeEvidenceHistory(rawEvidence, {
196
212
  source: 'import',
197
213
  fallback: importFallback,
@@ -199,10 +215,10 @@ function extractImportPatch(raw, importedVersion) {
199
215
  caseDomain: domain,
200
216
  }),
201
217
  evidencePins,
202
- decisions: normalizeCaseDecisions(record.decisions, normalizedFallback, pinIds, timestampOptions),
218
+ decisions,
203
219
  actions,
204
220
  assertions,
205
- manualTrail: normalizeCaseManualTrail(record.manualTrail, normalizedFallback, timestampOptions),
221
+ manualTrail,
206
222
  sightings,
207
223
  observedEffects,
208
224
  closures,
@@ -229,6 +245,22 @@ function unionNotes(a, b) {
229
245
  const notes = [...byId.values()].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt));
230
246
  return notes.slice(Math.max(0, notes.length - MAX_NOTES_PER_CASE));
231
247
  }
248
+ function retainLocalAuthoredRecords(local, imported, maximum) {
249
+ const records = [...local];
250
+ const retainedIds = new Set(records.map((item) => item.id));
251
+ let omitted = 0;
252
+ for (const item of imported) {
253
+ if (retainedIds.has(item.id))
254
+ continue;
255
+ if (records.length >= maximum) {
256
+ omitted += 1;
257
+ continue;
258
+ }
259
+ records.push(item);
260
+ retainedIds.add(item.id);
261
+ }
262
+ return { records, omitted };
263
+ }
232
264
  // Additive, deduplicated union of two evidence histories. Identical material
233
265
  // collapses (earliest firstCapturedAt, latest capturedAt), distinct snapshots
234
266
  // are retained subject to the per-case bound, and an older import can never
@@ -274,16 +306,29 @@ function caseFromPatch(patch, now) {
274
306
  function applyImportPatch(local, patch) {
275
307
  const importNewer = patch.updatedAt !== null && Date.parse(patch.updatedAt) > Date.parse(local.updatedAt);
276
308
  const fallback = patch.updatedAt || local.updatedAt;
277
- const evidencePins = mergeCaseEvidencePins(local.evidencePins, patch.evidencePins, fallback);
309
+ const pinSelection = retainLocalAuthoredRecords(local.evidencePins, patch.evidencePins, MAX_CASE_EVIDENCE_PINS);
310
+ const evidencePins = normalizeCaseEvidencePins(pinSelection.records, fallback);
278
311
  const pinIds = new Set(evidencePins.map((item) => item.id));
279
312
  const actions = mergeCaseActions(local.actions, patch.actions, fallback, pinIds);
280
- const assertions = mergeCaseAssertions(local.assertions, patch.assertions, fallback, pinIds);
313
+ const assertionSelection = retainLocalAuthoredRecords(local.assertions, patch.assertions, MAX_CASE_ASSERTIONS);
314
+ const assertions = normalizeCaseAssertions(assertionSelection.records, fallback, pinIds);
281
315
  const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
282
- const sightings = mergeCaseSightings(local.sightings, patch.sightings, fallback, pinIds);
316
+ const sightingSelection = retainLocalAuthoredRecords(local.sightings, patch.sightings, MAX_CASE_SIGHTINGS);
317
+ const sightings = normalizeCaseSightings(sightingSelection.records, fallback, pinIds);
283
318
  const sightingIds = new Set(sightings.map((item) => item.id));
284
319
  const observedEffects = mergeCaseObservedEffectHistories(local.observedEffects, patch.observedEffects, fallback, pinIds, sightingIds);
285
320
  const closures = mergeCaseClosureHistories(local.closures, patch.closures, fallback, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), buildCaseClosureLinkContext(observedEffects, actions));
286
321
  const brandProfileReferences = unionCaseBrandProfileIds(local.brandProfileIds, patch.brandProfileIds);
322
+ const decisionSelection = retainLocalAuthoredRecords(local.decisions, patch.decisions, MAX_CASE_DECISIONS);
323
+ const decisions = normalizeCaseDecisions(decisionSelection.records, fallback, pinIds);
324
+ const trailSelection = retainLocalAuthoredRecords(local.manualTrail, patch.manualTrail, MAX_CASE_MANUAL_TRAIL_EVENTS);
325
+ const manualTrail = normalizeCaseManualTrail(trailSelection.records, fallback);
326
+ const authoredHistoryOmitted = patch.authoredHistoryOmitted
327
+ + pinSelection.omitted
328
+ + decisionSelection.omitted
329
+ + assertionSelection.omitted
330
+ + trailSelection.omitted
331
+ + sightingSelection.omitted;
287
332
  return { record: {
288
333
  ...local,
289
334
  status: patch.status !== undefined && importNewer ? patch.status : local.status,
@@ -293,10 +338,10 @@ function applyImportPatch(local, patch) {
293
338
  source: patch.source !== undefined && importNewer ? patch.source : local.source,
294
339
  evidenceHistory: mergeEvidenceHistories(local.evidenceHistory, patch.evidenceHistory, local.domain),
295
340
  evidencePins,
296
- decisions: mergeCaseDecisions(local.decisions, patch.decisions, fallback, pinIds),
341
+ decisions,
297
342
  actions,
298
343
  assertions,
299
- manualTrail: mergeCaseManualTrail(local.manualTrail, patch.manualTrail, fallback),
344
+ manualTrail,
300
345
  sightings,
301
346
  observedEffects,
302
347
  closures,
@@ -305,7 +350,7 @@ function applyImportPatch(local, patch) {
305
350
  notes: unionNotes(local.notes, patch.notes),
306
351
  createdAt: patch.createdAt && Date.parse(patch.createdAt) < Date.parse(local.createdAt) ? patch.createdAt : local.createdAt,
307
352
  updatedAt: importNewer ? (patch.updatedAt ?? local.updatedAt) : local.updatedAt,
308
- }, brandProfileReferencesOmitted: brandProfileReferences.omitted };
353
+ }, brandProfileReferencesOmitted: brandProfileReferences.omitted, authoredHistoryOmitted };
309
354
  }
310
355
  function pickFreeId(preferred, domain, used) {
311
356
  const wanted = safeId(preferred);
@@ -418,7 +463,7 @@ function caseCollectionImportEnvelope(importedRaw) {
418
463
  * reinterpreted.
419
464
  * @param {CaseRecord[]} localCases
420
465
  * @param {unknown} importedRaw
421
- * @returns {{ cases: CaseRecord[], added: number, updated: number, skipped: number, brandProfileReferencesOmitted: number }}
466
+ * @returns {{ cases: CaseRecord[], added: number, updated: number, skipped: number, brandProfileReferencesOmitted: number, authoredHistoryOmitted: number }}
422
467
  */
423
468
  export function mergeCases(localCases, importedRaw) {
424
469
  assertBoundedJsonStructure(importedRaw, 'Case import', CASE_INPUT_JSON_LIMITS);
@@ -426,7 +471,7 @@ export function mergeCases(localCases, importedRaw) {
426
471
  const importedVersion = parseStoreVersion(importedEnvelope);
427
472
  if (importedVersion !== null && Number.isSafeInteger(importedVersion) && importedVersion < CASE_SCHEMA_VERSION
428
473
  && !CASE_IMPORT_VERSIONS.includes(importedVersion)) {
429
- throw new Error(`Case schema ${importedVersion} is not part of the supported compatibility boundary. Exact published schemas 12 and 13 migrate to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
474
+ throw new Error(`Case schema ${importedVersion} is not part of the supported compatibility boundary. Exact published schemas ${CASE_IMPORT_VERSIONS.filter((version) => version < CASE_SCHEMA_VERSION).join(', ')} migrate to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
430
475
  }
431
476
  if (importedVersion !== null && Number.isSafeInteger(importedVersion) && importedVersion > CASE_SCHEMA_VERSION) {
432
477
  throw new Error(`Case schema ${importedVersion} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
@@ -446,6 +491,7 @@ export function mergeCases(localCases, importedRaw) {
446
491
  const imported = boundedCaseList(importedEnvelope);
447
492
  let skipped = imported.omitted;
448
493
  let brandProfileReferencesOmitted = 0;
494
+ let authoredHistoryOmitted = 0;
449
495
  const fallback = new Date(0).toISOString();
450
496
  for (const item of imported.items) {
451
497
  const patch = extractImportPatch(item, supportedImportedVersion);
@@ -458,6 +504,7 @@ export function mergeCases(localCases, importedRaw) {
458
504
  const merged = applyImportPatch(existing, patch);
459
505
  byDomain.set(patch.domain, merged.record);
460
506
  brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted + merged.brandProfileReferencesOmitted;
507
+ authoredHistoryOmitted += merged.authoredHistoryOmitted;
461
508
  updated += 1;
462
509
  }
463
510
  else if (byDomain.size < MAX_CASES) {
@@ -466,6 +513,7 @@ export function mergeCases(localCases, importedRaw) {
466
513
  usedIds.add(record.id);
467
514
  byDomain.set(patch.domain, record);
468
515
  brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted;
516
+ authoredHistoryOmitted += patch.authoredHistoryOmitted;
469
517
  added += 1;
470
518
  }
471
519
  else {
@@ -478,5 +526,6 @@ export function mergeCases(localCases, importedRaw) {
478
526
  updated,
479
527
  skipped,
480
528
  brandProfileReferencesOmitted,
529
+ authoredHistoryOmitted,
481
530
  };
482
531
  }