@slicedearth/whoisleuth-cli 2.0.1 → 2.2.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 (97) hide show
  1. package/cli/arguments.mjs +21 -5
  2. package/cli/artifact-validation/case-response.mjs +771 -0
  3. package/cli/artifact-validation/investigation-capsule.mjs +426 -0
  4. package/cli/artifact-validation/lookup-evidence.mjs +718 -0
  5. package/cli/artifact-validation/signed-review.mjs +598 -0
  6. package/cli/artifact-validation/structure-primitives.mjs +105 -0
  7. package/cli/artifact-verify.mjs +1 -1
  8. package/cli/assurance-command-runner.mjs +324 -0
  9. package/cli/command-reference.mjs +1175 -843
  10. package/cli/completion.mjs +56 -9
  11. package/cli/discriminated-command-handlers.mjs +11 -0
  12. package/cli/doctor.mjs +2 -2
  13. package/cli/evidence-command-runner.mjs +41 -31
  14. package/cli/export-evidence.mjs +1 -1
  15. package/cli/fail-policy.mjs +33 -8
  16. package/cli/formatters/terminal-command-formats.mjs +437 -0
  17. package/cli/formatters/terminal-lookup.mjs +758 -0
  18. package/cli/formatters/terminal-metadata.mjs +58 -0
  19. package/cli/formatters/terminal-shared.mjs +83 -0
  20. package/cli/formatters/terminal.mjs +5 -1232
  21. package/cli/history-command-runner.mjs +132 -0
  22. package/cli/inline-command-families.mjs +45 -0
  23. package/cli/inline-command-runner.mjs +55 -0
  24. package/cli/investigation-plan.mjs +9 -2
  25. package/cli/investigation-run.mjs +114 -9
  26. package/cli/lookup-brief.mjs +141 -23
  27. package/cli/lookup-browser-view.mjs +289 -0
  28. package/cli/lookup-browser.mjs +3 -288
  29. package/cli/mail-review.mjs +2 -6
  30. package/cli/offline-artifact-validation.mjs +19 -0
  31. package/cli/review-command-runner.mjs +269 -0
  32. package/cli/runner.mjs +15 -972
  33. package/cli/saved-lookup.mjs +19 -2
  34. package/cli/support-command-runner.mjs +229 -0
  35. package/cli/workflow-command-runner.mjs +129 -0
  36. package/docs/cli-reference.md +16 -9
  37. package/docs/cli.md +16 -2
  38. package/lib/availability.mjs +12 -9
  39. package/lib/bounded-contract-normalizers.mjs +2 -9
  40. package/lib/browser-library-profile.mjs +1 -2
  41. package/lib/client-behavior-profile.mjs +1 -2
  42. package/lib/credential-surface-profile.mjs +1 -1
  43. package/lib/domain-posture-analysis.mjs +10 -1
  44. package/lib/domain-posture.mjs +51 -23
  45. package/lib/evidence-export.mjs +47 -5
  46. package/lib/evidence-report-markdown.mjs +4 -0
  47. package/lib/evidence-report.mjs +50 -0
  48. package/lib/generated/cisa-kev-catalog.mjs +34 -4
  49. package/lib/generated/registrar-standing-catalogue.mjs +367 -0
  50. package/lib/json-record.mjs +12 -0
  51. package/lib/lookup-child-profile-contract.mjs +858 -0
  52. package/lib/lookup-contract-primitives.mjs +98 -0
  53. package/lib/lookup-diagnostics.mjs +96 -0
  54. package/lib/lookup-network-evidence-bounds.mjs +1 -0
  55. package/lib/lookup-response.mjs +367 -0
  56. package/lib/lookup.mjs +28 -443
  57. package/lib/page-role-profile.mjs +1 -3
  58. package/lib/registrar-standing-catalogue-contract.mjs +70 -0
  59. package/lib/registrar-standing-contract.mjs +300 -0
  60. package/lib/registrar-standing.mjs +269 -0
  61. package/lib/registry-insights.mjs +2 -8
  62. package/lib/risk-scoring.mjs +26 -10
  63. package/lib/sslbl-certificates.generated.mjs +138 -136
  64. package/lib/structured-data-identity.mjs +1 -3
  65. package/lib/technology-evidence-role.mjs +51 -0
  66. package/lib/threat-intelligence-runtime.mjs +3 -3
  67. package/lib/threat-intelligence-types.mjs +13 -0
  68. package/lib/threatfox-intelligence.mjs +1 -3
  69. package/lib/tls-intelligence.mjs +1 -2
  70. package/lib/urlhaus-intelligence.mjs +1 -3
  71. package/lib/urlscan-intelligence.mjs +1 -3
  72. package/lib/website-security-posture.mjs +1 -2
  73. package/lib/website-technology.mjs +72 -23
  74. package/lib/whois-common-formats.mjs +276 -0
  75. package/lib/whois-dialects.mjs +534 -0
  76. package/lib/whois-parser-support.mjs +305 -0
  77. package/lib/whois-parser.mjs +135 -0
  78. package/lib/whois.mjs +6 -1187
  79. package/package.json +2 -2
  80. package/packages/cases/case-evidence-model.mjs +9 -0
  81. package/packages/cases/case-migration-model.mjs +17 -11
  82. package/packages/cases/case-record-operations.mjs +235 -0
  83. package/packages/cases/case-response-model.mjs +13 -9
  84. package/packages/cases/case-response-packet.mjs +297 -112
  85. package/packages/comparison/page-similarity.mjs +23 -0
  86. package/packages/contracts/case-portability.mjs +235 -61
  87. package/packages/contracts/cli-lookup.mjs +4 -3
  88. package/packages/contracts/domain-control-manifest.mjs +1 -1
  89. package/packages/contracts/investigation-portability.mjs +1 -1
  90. package/packages/contracts/risk-calibration.mjs +2 -2
  91. package/packages/contracts/workspace-portability.mjs +34 -6
  92. package/packages/interchange/external-findings-import.mjs +113 -45
  93. package/packages/investigation/evidence-coverage-ledger.mjs +21 -0
  94. package/packages/investigation/lookup-claim-readiness.mjs +2 -2
  95. package/packages/workspace/website-snapshot-model.mjs +79 -6
  96. package/third-party-notices.txt +1 -1
  97. package/cli/artifact-structure.mjs +0 -2507
@@ -36,6 +36,7 @@ export const CLI_LOOKUP_OPTIONAL_ROOT_KEYS = Object.freeze([
36
36
  'whois',
37
37
  'availability',
38
38
  'registryInsights',
39
+ 'registrarStanding',
39
40
  'reverseDns',
40
41
  'networkContext',
41
42
  'securityTxt',
@@ -59,7 +60,7 @@ export const CLI_LOOKUP_COMPATIBILITY = defineSchemaCompatibility({
59
60
  writeSemantics: 'normalized_rewrite',
60
61
  byteBudget: MAX_CLI_LOOKUP_BYTES,
61
62
  owner: 'packages/contracts/cli-lookup.mts',
62
- note: 'Saved Lookup documents preserve version 1 domain, IP and ASN compatibility while version 2 can additionally retain bounded Deep homepage metadata.',
63
+ note: 'Saved Lookup documents preserve version 1 domain, IP and ASN compatibility while version 2 can additionally retain bounded Deep homepage metadata and optional official registrar-standing context.',
63
64
  });
64
65
  export const CLI_LOOKUP_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
65
66
  id: 'cli-lookup',
@@ -234,7 +235,7 @@ export const CLI_LOOKUP_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
234
235
  id: 'cli-lookup.sensitive-collection.v1',
235
236
  classification: 'analyst_authored_sensitive',
236
237
  projection: 'full_manifest',
237
- includedCategories: ['target-identifiers', 'query-and-mode', 'raw-rdap-publications', 'raw-whois-responses', 'published-contacts', 'source-endpoints', 'source-timings', 'normalised-evidence', 'collection-context'],
238
+ includedCategories: ['target-identifiers', 'query-and-mode', 'raw-rdap-publications', 'raw-whois-responses', 'published-contacts', 'source-endpoints', 'source-timings', 'normalised-evidence', 'registrar-standing-official-projection', 'collection-context'],
238
239
  excludedCategories: ['credentials', 'cookies', 'sessions', 'authorisation-material'],
239
240
  notePolicy: 'not_applicable',
240
241
  retention: 'operator_controlled_output',
@@ -245,7 +246,7 @@ export const CLI_LOOKUP_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
245
246
  id: 'cli-lookup.sensitive-file.v1',
246
247
  classification: 'analyst_authored_sensitive',
247
248
  projection: 'full_manifest',
248
- includedCategories: ['target-identifiers', 'query-and-mode', 'raw-rdap-publications', 'raw-whois-responses', 'published-contacts', 'source-endpoints', 'source-timings', 'normalised-evidence', 'collection-context'],
249
+ includedCategories: ['target-identifiers', 'query-and-mode', 'raw-rdap-publications', 'raw-whois-responses', 'published-contacts', 'source-endpoints', 'source-timings', 'normalised-evidence', 'registrar-standing-official-projection', 'collection-context'],
249
250
  excludedCategories: ['credentials', 'cookies', 'sessions', 'authorisation-material'],
250
251
  notePolicy: 'not_applicable',
251
252
  retention: 'deliberate_local_file',
@@ -428,7 +428,7 @@ export const DOMAIN_CONTROL_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
428
428
  { id: 'domain-control.node.build-document', role: 'builder', runtime: 'node', module: 'lib/domain-control-manifest.mts', exportName: 'buildDomainControlManifest' },
429
429
  { id: 'domain-control.node.verify-integrity', role: 'integrity_verifier', runtime: 'node', module: 'lib/domain-control-manifest.mts', exportName: 'verifyDomainControlManifest' },
430
430
  { id: 'domain-control.node.review', role: 'reviewer', runtime: 'node', module: 'lib/domain-control-manifest.mts', exportName: 'reviewDomainControlManifest' },
431
- { id: 'domain-control.cli.offline-structure', role: 'structure_validator', runtime: 'cli', module: 'cli/artifact-structure.mts', exportName: 'validateSignedDigestArtifactStructure' },
431
+ { id: 'domain-control.cli.offline-structure', role: 'structure_validator', runtime: 'cli', module: 'cli/offline-artifact-validation.mts', exportName: 'validateSignedDigestArtifactStructure' },
432
432
  { id: 'domain-control.cli.offline-verify', role: 'integrity_verifier', runtime: 'cli', module: 'cli/artifact-verify.mts', exportName: 'verifyOfflineArtifact' },
433
433
  { id: 'domain-control.cli.interchange-report', role: 'interchange_reporter', runtime: 'cli', module: 'cli/interchange-report.mts', exportName: 'buildInterchangeFidelityReport' },
434
434
  { id: 'domain-control.cli.sign-package', role: 'signer', runtime: 'cli', module: 'cli/evidence-signing.mts', exportName: 'signEvidencePackage' },
@@ -244,7 +244,7 @@ export const INVESTIGATION_PORTABILITY_LIFECYCLE_FAMILY = defineSchemaLifecycleF
244
244
  ] },
245
245
  ],
246
246
  hooks: [
247
- { id: 'investigation.offline.structure', role: 'structure_validator', runtime: 'cli', module: 'cli/artifact-structure.mts', exportName: 'validateOfflineArtifactStructure' },
247
+ { id: 'investigation.offline.structure', role: 'structure_validator', runtime: 'cli', module: 'cli/offline-artifact-validation.mts', exportName: 'validateOfflineArtifactStructure' },
248
248
  { id: 'investigation.offline.verify', role: 'integrity_verifier', runtime: 'cli', module: 'cli/artifact-verify.mts', exportName: 'verifyOfflineArtifact' },
249
249
  { id: 'investigation.acquisition.build', role: 'builder', runtime: 'shared', module: 'packages/investigation/acquisition-decision-packet.mts', exportName: 'buildAcquisitionDecisionPacket' },
250
250
  { id: 'investigation.passport.build', role: 'builder', runtime: 'shared', module: 'packages/investigation/lookup-claim-passport.mts', exportName: 'buildLookupClaimPassport' },
@@ -338,7 +338,7 @@ export const RISK_CALIBRATION_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
338
338
  id: 'risk-calibration-report-v3-detailed',
339
339
  path: 'test/fixtures/risk-calibration-report-v3-detailed.json',
340
340
  bytes: 7_026,
341
- sha256: '0c905036b05da2feb586199781444d5b575982bb5f6ef585d8f7c2e431363e8d',
341
+ sha256: '5c0c435e81478e90bc0b64aa7c20fd82d131b5261a57d9550df3f03334bc20e1',
342
342
  contentDigestSha256: null,
343
343
  schema: RISK_CALIBRATION_REPORT_SCHEMA,
344
344
  version: RISK_CALIBRATION_REPORT_VERSION,
@@ -352,7 +352,7 @@ export const RISK_CALIBRATION_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
352
352
  id: 'risk-calibration-report-v3-summary',
353
353
  path: 'test/fixtures/risk-calibration-report-v3-summary.json',
354
354
  bytes: 5_486,
355
- sha256: '637e739eac58ec06f3f2c9688fb2c03d5d139536e9629e1e1a811d3c8c7563d0',
355
+ sha256: 'b06c312eed14680ce30fd58a84a93908d29ea9e71bfee8174b90df286b730549',
356
356
  contentDigestSha256: null,
357
357
  schema: RISK_CALIBRATION_REPORT_SCHEMA,
358
358
  version: RISK_CALIBRATION_REPORT_VERSION,
@@ -95,8 +95,8 @@ export const MAX_RELATIONSHIP_OBSERVATION_LIMITATIONS = 8;
95
95
  export const MAX_RELATIONSHIP_OBSERVATION_VALUE_LENGTH = 20_000;
96
96
  export const MAX_RELATIONSHIP_OBSERVATION_STORE_BYTES = 768 * 1024;
97
97
  export const WEBSITE_SNAPSHOT_SCHEMA = 'whoisleuth.website-profile-snapshots';
98
- export const WEBSITE_SNAPSHOT_SCHEMA_VERSION = 4;
99
- export const WEBSITE_SNAPSHOT_BROWSER_SUPPORTED_VERSIONS = Object.freeze([WEBSITE_SNAPSHOT_SCHEMA_VERSION]);
98
+ export const WEBSITE_SNAPSHOT_SCHEMA_VERSION = 5;
99
+ export const WEBSITE_SNAPSHOT_BROWSER_SUPPORTED_VERSIONS = Object.freeze([4, WEBSITE_SNAPSHOT_SCHEMA_VERSION]);
100
100
  export const SUPPORTED_WEBSITE_SNAPSHOT_SCHEMA_VERSIONS = WEBSITE_SNAPSHOT_BROWSER_SUPPORTED_VERSIONS;
101
101
  export const MAX_WEBSITE_SNAPSHOTS = 60;
102
102
  export const MAX_WEBSITE_SNAPSHOTS_PER_DOMAIN = 12;
@@ -265,9 +265,9 @@ export const RELATIONSHIP_OBSERVATION_BROWSER_COMPATIBILITY = defineSchemaCompat
265
265
  export const WEBSITE_SNAPSHOT_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
266
266
  id: 'browser.website-snapshots', kind: 'browser_store', schema: null,
267
267
  currentVersion: WEBSITE_SNAPSHOT_SCHEMA_VERSION, supportedVersions: WEBSITE_SNAPSHOT_BROWSER_SUPPORTED_VERSIONS,
268
- acceptsUnversionedLegacy: false, futureVersionBehavior: 'preserve_without_write', migration: 'exact_current_only',
268
+ acceptsUnversionedLegacy: false, futureVersionBehavior: 'preserve_without_write', migration: 'normalize_to_current',
269
269
  writeSemantics: 'normalized_rewrite', byteBudget: MAX_WEBSITE_SNAPSHOT_STORE_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
270
- note: 'The unchanged public version 4 writer remains the exact browser and export baseline; raw lookup payloads, contact data, certificate bytes, and fetched bodies remain excluded.',
270
+ note: 'Public version 4 records normalise to version 5 with explicit unknown profile provenance; raw lookup payloads, contact data, certificate bytes, and fetched bodies remain excluded.',
271
271
  });
272
272
  export const BULK_SESSION_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
273
273
  id: 'browser.bulk-sessions', kind: 'browser_store', schema: null, currentVersion: BULK_SESSION_SCHEMA_VERSION,
@@ -335,9 +335,9 @@ export const RELATIONSHIP_OBSERVATION_EXPORT_COMPATIBILITY = defineSchemaCompati
335
335
  export const WEBSITE_SNAPSHOT_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
336
336
  id: 'export.website-snapshots', kind: 'export', schema: WEBSITE_SNAPSHOT_SCHEMA,
337
337
  currentVersion: WEBSITE_SNAPSHOT_SCHEMA_VERSION, supportedVersions: SUPPORTED_WEBSITE_SNAPSHOT_SCHEMA_VERSIONS,
338
- acceptsUnversionedLegacy: false, futureVersionBehavior: 'reject', migration: 'exact_current_only',
338
+ acceptsUnversionedLegacy: false, futureVersionBehavior: 'reject', migration: 'normalize_to_current',
339
339
  writeSemantics: 'non_destructive_merge', byteBudget: MAX_WEBSITE_SNAPSHOT_IMPORT_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
340
- note: 'The unchanged public schema 4 archive section merges non-destructively without retaining fetched bodies.',
340
+ note: 'Public schema 4 archive sections normalise to version 5 with explicit unknown profile provenance and merge non-destructively without retaining fetched bodies.',
341
341
  });
342
342
  export const BULK_SESSION_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
343
343
  id: 'export.bulk-sessions', kind: 'export', schema: BULK_SESSION_SCHEMA, currentVersion: BULK_SESSION_SCHEMA_VERSION,
@@ -800,6 +800,20 @@ const WORKSPACE_LIFECYCLE_FIXTURE_SOURCE = Object.freeze([
800
800
  "contentDigestSha256": null,
801
801
  "schema": "whoisleuth.browser.website-snapshot-store",
802
802
  "version": 4,
803
+ "role": "historical",
804
+ "expectation": "normalises_to_current_output",
805
+ "expectedOutputFixtureId": "workspace.browser.website.v5",
806
+ "scope": "repository",
807
+ "shapeId": "workspace.browser.website.shape"
808
+ },
809
+ {
810
+ "id": "workspace.browser.website.v5",
811
+ "path": "test/fixtures/workspace-lifecycle/browser-website-v5.json",
812
+ "bytes": 90,
813
+ "sha256": "16550a8c24d5c7bcfe271086071fee7f3392279990cc6fe91e3b9c95f07d0759",
814
+ "contentDigestSha256": null,
815
+ "schema": "whoisleuth.browser.website-snapshot-store",
816
+ "version": 5,
803
817
  "role": "current",
804
818
  "expectation": "accepted_exact",
805
819
  "expectedOutputFixtureId": null,
@@ -954,6 +968,20 @@ const WORKSPACE_LIFECYCLE_FIXTURE_SOURCE = Object.freeze([
954
968
  "contentDigestSha256": null,
955
969
  "schema": "whoisleuth.website-profile-snapshots",
956
970
  "version": 4,
971
+ "role": "historical",
972
+ "expectation": "normalises_to_current_output",
973
+ "expectedOutputFixtureId": "workspace.portable.website.v5",
974
+ "scope": "repository",
975
+ "shapeId": "workspace.portable.website.shape"
976
+ },
977
+ {
978
+ "id": "workspace.portable.website.v5",
979
+ "path": "test/fixtures/workspace-lifecycle/portable-website-v5.json",
980
+ "bytes": 135,
981
+ "sha256": "0cab1675a632793141107fac330a130e7d300a7726e9963f8409199902bf0d23",
982
+ "contentDigestSha256": null,
983
+ "schema": "whoisleuth.website-profile-snapshots",
984
+ "version": 5,
957
985
  "role": "current",
958
986
  "expectation": "accepted_exact",
959
987
  "expectedOutputFixtureId": null,
@@ -1,4 +1,5 @@
1
1
  import { normalizeDomain, openOrCreateCase, updateCase, } from "../cases/case-model.mjs";
2
+ import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
2
3
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
3
4
  import { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDINGS_IMPORT_BYTES, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, } from "../contracts/external-observation-interchange.mjs";
4
5
  import { hasUnsafeRetainedText } from "./retained-text.mjs";
@@ -311,6 +312,58 @@ function structuredPinFields(finding) {
311
312
  certificateObservation,
312
313
  };
313
314
  }
315
+ function mergeExternalFindingIntoCase(current, caseId, finding, source, now) {
316
+ const target = current.find((candidate) => candidate.id === caseId) ?? null;
317
+ if (!target)
318
+ throw new Error('The selected Case is unavailable for this finding.');
319
+ if (existingPinKey(target, finding, source.name)) {
320
+ return { cases: [...current], record: target, added: false };
321
+ }
322
+ const sourceLimitations = [
323
+ finding.evidenceClass === 'deployment_observation'
324
+ ? `Imported as an observation made by ${source.name}; this browser session did not collect or independently verify it.`
325
+ : `Reported by ${source.name}; WHOISleuth did not collect or independently verify this provider finding.`,
326
+ ...(source.reference ? [`Source reference: ${source.reference}`] : []),
327
+ ...finding.limitations,
328
+ ];
329
+ const existingPinIds = new Set(target.evidencePins.map((pin) => pin.id));
330
+ let updated = updateCase([...current], target.id, {
331
+ evidencePin: {
332
+ ...structuredPinFields(finding),
333
+ label: `External ${finding.category} finding`,
334
+ value: finding.structuredObservation?.value ?? pinValue(finding),
335
+ source: importedSourceLabel(finding, source.name),
336
+ observedAt: finding.observedAt,
337
+ completeness: finding.completeness,
338
+ limitations: sourceLimitations,
339
+ },
340
+ }, now);
341
+ const addedPin = updated.record.evidencePins.find((pin) => !existingPinIds.has(pin.id)) ?? null;
342
+ if (!addedPin)
343
+ throw new Error('The imported finding could not be retained as Case evidence.');
344
+ updated = updateCase(updated.cases, target.id, {
345
+ sighting: {
346
+ state: finding.evidenceClass === 'deployment_observation'
347
+ ? 'observed_by_deployment'
348
+ : 'reported_by_provider',
349
+ category: finding.category === 'dns'
350
+ ? 'delegation'
351
+ : finding.category === 'certificate'
352
+ ? 'certificate'
353
+ : finding.category === 'registration'
354
+ ? 'registration'
355
+ : finding.category === 'http' || finding.category === 'page'
356
+ ? 'website'
357
+ : 'other',
358
+ source: source.name,
359
+ observedAt: finding.observedAt,
360
+ completeness: finding.completeness,
361
+ evidencePinId: addedPin.id,
362
+ limitations: sourceLimitations,
363
+ },
364
+ }, now);
365
+ return { cases: updated.cases, record: updated.record, added: true };
366
+ }
314
367
  export function mergeExternalFindingsIntoCases(current, document, now = new Date().toISOString()) {
315
368
  let cases = [...current];
316
369
  const createdDomains = new Set();
@@ -322,54 +375,12 @@ export function mergeExternalFindingsIntoCases(current, document, now = new Date
322
375
  const opened = openOrCreateCase(cases, { domain: finding.domain, source: 'import' }, now);
323
376
  cases = opened.cases;
324
377
  const target = cases.find((candidate) => candidate.id === opened.record.id) ?? opened.record;
325
- if (existingPinKey(target, finding, document.source.name)) {
378
+ const merged = mergeExternalFindingIntoCase(cases, target.id, finding, document.source, now);
379
+ cases = merged.cases;
380
+ if (!merged.added) {
326
381
  duplicatesSkipped += 1;
327
382
  continue;
328
383
  }
329
- const sourceLimitations = [
330
- finding.evidenceClass === 'deployment_observation'
331
- ? `Imported as an observation made by ${document.source.name}; this browser session did not collect or independently verify it.`
332
- : `Reported by ${document.source.name}; WHOISleuth did not collect or independently verify this provider finding.`,
333
- ...(document.source.reference ? [`Source reference: ${document.source.reference}`] : []),
334
- ...finding.limitations,
335
- ];
336
- const existingPinIds = new Set(target.evidencePins.map((pin) => pin.id));
337
- const updated = updateCase(cases, target.id, {
338
- evidencePin: {
339
- ...structuredPinFields(finding),
340
- label: `External ${finding.category} finding`,
341
- value: finding.structuredObservation?.value ?? pinValue(finding),
342
- source: importedSourceLabel(finding, document.source.name),
343
- observedAt: finding.observedAt,
344
- completeness: finding.completeness,
345
- limitations: sourceLimitations,
346
- },
347
- }, now);
348
- cases = updated.cases;
349
- const addedPin = updated.record.evidencePins.find((pin) => !existingPinIds.has(pin.id)) ?? null;
350
- if (addedPin) {
351
- cases = updateCase(cases, updated.record.id, {
352
- sighting: {
353
- state: finding.evidenceClass === 'deployment_observation'
354
- ? 'observed_by_deployment'
355
- : 'reported_by_provider',
356
- category: finding.category === 'dns'
357
- ? 'delegation'
358
- : finding.category === 'certificate'
359
- ? 'certificate'
360
- : finding.category === 'registration'
361
- ? 'registration'
362
- : finding.category === 'http' || finding.category === 'page'
363
- ? 'website'
364
- : 'other',
365
- source: document.source.name,
366
- observedAt: finding.observedAt,
367
- completeness: finding.completeness,
368
- evidencePinId: addedPin.id,
369
- limitations: sourceLimitations,
370
- },
371
- }, now).cases;
372
- }
373
384
  findingsAdded += 1;
374
385
  if (existing)
375
386
  updatedDomains.add(finding.domain);
@@ -384,3 +395,60 @@ export function mergeExternalFindingsIntoCases(current, document, now = new Date
384
395
  duplicatesSkipped,
385
396
  };
386
397
  }
398
+ function targetedFinding(finding, caseDomain) {
399
+ if (finding.domain === caseDomain)
400
+ return finding;
401
+ const prefix = `Captured hostname ${finding.domain}. `;
402
+ const maximumSummaryLength = 900;
403
+ const remaining = maximumSummaryLength - prefix.length;
404
+ const summary = remaining > 1 && finding.summary.length > remaining
405
+ ? `${prefix}${finding.summary.slice(0, remaining - 1).trimEnd()}…`
406
+ : `${prefix}${finding.summary}`;
407
+ return { ...finding, domain: caseDomain, summary };
408
+ }
409
+ export function externalFindingsCaseTargets(document, caseDomain) {
410
+ if (canonicalRegistrableDomain(caseDomain) !== caseDomain) {
411
+ throw new Error('The selected Case does not have a canonical registrable-domain identity.');
412
+ }
413
+ const targets = new Set();
414
+ for (const finding of document.findings) {
415
+ if (canonicalRegistrableDomain(finding.domain) !== caseDomain) {
416
+ throw new Error(`The imported finding for ${finding.domain} does not belong to the selected Case for ${caseDomain}.`);
417
+ }
418
+ targets.add(finding.domain);
419
+ }
420
+ return Object.freeze([...targets]);
421
+ }
422
+ /**
423
+ * Merges already-validated external findings into one selected Case. A Case is
424
+ * keyed to its registrable domain, while rendered captures may retain an exact
425
+ * hostname. Only same-domain findings are accepted, the exact hostname remains
426
+ * explicit in the retained evidence, and this operation can never open another
427
+ * Case as a side effect.
428
+ */
429
+ export function mergeExternalFindingsIntoCase(current, caseId, document, now = new Date().toISOString()) {
430
+ const selected = current.find((candidate) => candidate.id === caseId) ?? null;
431
+ if (!selected)
432
+ throw new Error('Select an existing Case before importing rendered-capture evidence.');
433
+ externalFindingsCaseTargets(document, selected.domain);
434
+ let cases = [...current];
435
+ let record = selected;
436
+ let findingsAdded = 0;
437
+ let duplicatesSkipped = 0;
438
+ for (const original of document.findings) {
439
+ const finding = targetedFinding(original, selected.domain);
440
+ const merged = mergeExternalFindingIntoCase(cases, selected.id, finding, document.source, now);
441
+ cases = merged.cases;
442
+ record = merged.record;
443
+ if (merged.added)
444
+ findingsAdded += 1;
445
+ else
446
+ duplicatesSkipped += 1;
447
+ }
448
+ return {
449
+ cases,
450
+ record,
451
+ findingsAdded,
452
+ duplicatesSkipped,
453
+ };
454
+ }
@@ -149,6 +149,9 @@ export function buildLookupEvidenceCoverageLedger(input) {
149
149
  const rdapDiagnostic = record(diagnostics.rdap);
150
150
  const whoisDiagnostic = record(diagnostics.whois);
151
151
  const registrarRdap = record(input.registrarRdap);
152
+ const registrarStanding = record(input.registrarStanding);
153
+ const registrarStandingAccreditation = record(registrarStanding.accreditation);
154
+ const registrarStandingCompliance = record(registrarStanding.compliance);
152
155
  const reverseDns = record(input.reverseDns);
153
156
  const observedNetworkContext = record(input.observedNetworkContext);
154
157
  const dnsEvidence = record(input.dnsEvidence);
@@ -199,6 +202,24 @@ export function buildLookupEvidenceCoverageLedger(input) {
199
202
  limitations: stringList(registrarRdap.limitations),
200
203
  });
201
204
  }
205
+ if (registrarStanding.version === 1) {
206
+ const health = [registrarStandingAccreditation.sourceHealth, registrarStandingCompliance.sourceHealth];
207
+ items.push({
208
+ id: 'registrar-standing',
209
+ label: 'Registrar standing',
210
+ category: 'registry',
211
+ status: health.includes('unavailable')
212
+ ? 'unavailable'
213
+ : health.includes('stale')
214
+ ? 'partial'
215
+ : registrarStandingAccreditation.state === 'unknown'
216
+ ? 'unknown'
217
+ : 'complete',
218
+ complete: health.every((state) => state === 'current'),
219
+ truncated: registrarStandingCompliance.truncated,
220
+ limitations: stringList(registrarStanding.limitations),
221
+ });
222
+ }
202
223
  if (reverseDns.source === 'reverse_dns' || record(diagnostics.reverseDns).status) {
203
224
  items.push({
204
225
  id: 'reverse-dns',
@@ -239,8 +239,8 @@ export function buildLookupClaimReadiness(input) {
239
239
  requirement('http-observation', coverage),
240
240
  requirement('page-identity-observation', coverage),
241
241
  requirement('reviewed-case-recipient', coverage, {
242
- state: input.hasCaseSection === true && (input.responseRecipientCount ?? 0) > 0 ? 'complete' : 'unknown',
243
- ...(input.hasCaseSection === true && (input.responseRecipientCount ?? 0) > 0
242
+ state: input.hasReviewedCaseRecipient === true ? 'complete' : 'unknown',
243
+ ...(input.hasReviewedCaseRecipient === true
244
244
  ? {}
245
245
  : { limitation: 'A reviewed case and recipient route are not both available.' }),
246
246
  }),
@@ -7,6 +7,9 @@ const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
7
7
  const DIGEST_RE = /^[a-f0-9]{16,128}$/iu;
8
8
  const SHA256_RE = /^[a-f0-9]{64}$/iu;
9
9
  const SERIAL_RE = /^[a-f0-9]{1,128}$/iu;
10
+ const SNAPSHOT_TECHNOLOGY_ROLES = new Set([
11
+ 'observed_edge', 'application_platform', 'framework_runtime', 'embedded_dependency',
12
+ ]);
10
13
  function record(value) {
11
14
  return ordinaryWorkspaceRecord(value, 'Website-snapshot input');
12
15
  }
@@ -47,7 +50,45 @@ function technology(value) {
47
50
  const name = text(item?.name, 120);
48
51
  if (!id || !name)
49
52
  return null;
50
- return { id, name, category: text(item?.category, 80) || 'technology', confidence: text(item?.confidence, 40) || 'unknown' };
53
+ const roles = values(item?.roles, 4, (role) => {
54
+ const normalized = text(role, 40);
55
+ return SNAPSHOT_TECHNOLOGY_ROLES.has(normalized) ? normalized : null;
56
+ });
57
+ return {
58
+ id,
59
+ name,
60
+ category: text(item?.category, 80) || 'technology',
61
+ confidence: text(item?.confidence, 40) || 'unknown',
62
+ roles,
63
+ };
64
+ }
65
+ function profileVersion(value) {
66
+ return Number.isSafeInteger(value) && Number(value) > 0 && Number(value) <= 10_000
67
+ ? Number(value)
68
+ : null;
69
+ }
70
+ function profileProvenance(value, legacy = false) {
71
+ if (legacy) {
72
+ return {
73
+ technology: { version: null, state: 'legacy_unknown' },
74
+ securityPosture: { version: null, state: 'legacy_unknown' },
75
+ };
76
+ }
77
+ const item = record(value);
78
+ const technologyItem = record(item?.technology);
79
+ const securityPostureItem = record(item?.securityPosture);
80
+ const technologyVersion = profileVersion(technologyItem?.version);
81
+ const securityPostureVersion = profileVersion(securityPostureItem?.version);
82
+ return {
83
+ technology: {
84
+ version: technologyVersion,
85
+ state: technologyVersion === null ? 'legacy_unknown' : 'known',
86
+ },
87
+ securityPosture: {
88
+ version: securityPostureVersion,
89
+ state: securityPostureVersion === null ? 'legacy_unknown' : 'known',
90
+ },
91
+ };
51
92
  }
52
93
  function posture(value) {
53
94
  const item = record(value);
@@ -154,7 +195,7 @@ function identityValues(value) {
154
195
  const formActionOrigins = values(item?.formActionOrigins, 20, origin);
155
196
  return { resourceHosts, trackingIdentifiers, formActionOrigins };
156
197
  }
157
- export function normalizeWebsiteProfileSnapshot(raw) {
198
+ export function normalizeWebsiteProfileSnapshot(raw, sourceVersion) {
158
199
  const value = record(raw);
159
200
  const domain = normalizeDomain(value?.domain);
160
201
  const observedAt = timestamp(value?.observedAt);
@@ -169,6 +210,7 @@ export function normalizeWebsiteProfileSnapshot(raw) {
169
210
  savedAt,
170
211
  complete: value?.complete === true,
171
212
  truncated: value?.truncated === true,
213
+ profileProvenance: profileProvenance(value?.profileProvenance, sourceVersion === 4),
172
214
  technologies: values(value?.technologies, 40, technology),
173
215
  posture: values(value?.posture, 40, posture),
174
216
  identity: identity(value?.identity),
@@ -192,7 +234,10 @@ export function normalizeWebsiteSnapshotStore(raw) {
192
234
  throw new Error('This website-snapshot collection uses an unsupported schema and cannot be read safely.');
193
235
  }
194
236
  const sourceValues = Array.isArray(raw) ? raw : Array.isArray(value?.snapshots) ? value.snapshots : [];
195
- const snapshots = values(sourceValues, MAX_WEBSITE_SNAPSHOTS * 2, (candidate) => normalizeWebsiteProfileSnapshot(candidate));
237
+ const sourceVersion = value?.schema === WEBSITE_SNAPSHOT_SCHEMA && Number.isSafeInteger(value.version)
238
+ ? Number(value.version)
239
+ : undefined;
240
+ const snapshots = values(sourceValues, MAX_WEBSITE_SNAPSHOTS * 2, (candidate) => normalizeWebsiteProfileSnapshot(candidate, sourceVersion));
196
241
  const perDomain = new Map();
197
242
  const retained = [];
198
243
  for (const snapshot of snapshots.sort((left, right) => right.savedAt.localeCompare(left.savedAt))) {
@@ -272,9 +317,29 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
272
317
  dependencyTransitions: [],
273
318
  };
274
319
  }
320
+ const technologyVersionBefore = before.profileProvenance.technology.version;
321
+ const technologyVersionAfter = after.profileProvenance.technology.version;
322
+ const postureVersionBefore = before.profileProvenance.securityPosture.version;
323
+ const postureVersionAfter = after.profileProvenance.securityPosture.version;
324
+ const technologyComparability = technologyVersionBefore === null || technologyVersionAfter === null
325
+ ? 'legacy_unknown'
326
+ : technologyVersionBefore === technologyVersionAfter ? 'comparable' : 'detector_changed';
327
+ const postureComparability = postureVersionBefore === null || postureVersionAfter === null
328
+ ? 'legacy_unknown'
329
+ : postureVersionBefore === postureVersionAfter ? 'comparable' : 'detector_changed';
275
330
  const changes = [
276
- ...compareMap('technology', new Map(before.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}`])), new Map(after.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}`]))),
277
- ...compareMap('posture', new Map(before.posture.map((item) => [item.id, item.state])), new Map(after.posture.map((item) => [item.id, item.state]))),
331
+ ...(technologyComparability === 'comparable' ? compareMap('technology', new Map(before.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}|${item.roles.join(',')}`])), new Map(after.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}|${item.roles.join(',')}`]))) : [{
332
+ field: 'technology.profileVersion',
333
+ state: technologyComparability === 'detector_changed' ? 'changed' : 'incomparable',
334
+ before: technologyVersionBefore === null ? null : String(technologyVersionBefore),
335
+ after: technologyVersionAfter === null ? null : String(technologyVersionAfter),
336
+ }]),
337
+ ...(postureComparability === 'comparable' ? compareMap('posture', new Map(before.posture.map((item) => [item.id, item.state])), new Map(after.posture.map((item) => [item.id, item.state]))) : [{
338
+ field: 'posture.profileVersion',
339
+ state: postureComparability === 'detector_changed' ? 'changed' : 'incomparable',
340
+ before: postureVersionBefore === null ? null : String(postureVersionBefore),
341
+ after: postureVersionAfter === null ? null : String(postureVersionAfter),
342
+ }]),
278
343
  ...compareMap('source', new Map(before.sources.map((item) => [item.source, item.state])), new Map(after.sources.map((item) => [item.source, item.state]))),
279
344
  ...compareMap('dependency', new Map(before.dependencies.map((item) => [`${item.recordType}:${item.target}`, `${item.state}|${item.qualification}`])), new Map(after.dependencies.map((item) => [`${item.recordType}:${item.target}`, `${item.state}|${item.qualification}`]))),
280
345
  ];
@@ -358,5 +423,13 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
358
423
  if (before.complete !== after.complete || before.truncated !== after.truncated) {
359
424
  changes.push({ field: 'completeness', state: 'incomparable', before: `${before.complete}/${before.truncated}`, after: `${after.complete}/${after.truncated}` });
360
425
  }
361
- return { compatible: true, changes, dependencyTransitions };
426
+ return {
427
+ compatible: true,
428
+ profileComparability: {
429
+ technology: technologyComparability,
430
+ securityPosture: postureComparability,
431
+ },
432
+ changes,
433
+ dependencyTransitions,
434
+ };
362
435
  }
@@ -713,7 +713,7 @@ MIT License
713
713
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
714
714
  SOFTWARE
715
715
  ================================================================================
716
- undici@8.10.0
716
+ undici@8.10.1
717
717
  Relationship: direct production dependency
718
718
  Declared licence: MIT
719
719
  Licence source: LICENSE