@slicedearth/whoisleuth-cli 1.45.0 → 1.47.4

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 (133) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +59 -6
  4. package/bin/whoisleuth.mjs +5 -3
  5. package/cli/arguments.mjs +256 -9
  6. package/cli/artifact-structure.mjs +1833 -0
  7. package/cli/artifact-verify.mjs +242 -49
  8. package/cli/bounded-json.mjs +1 -0
  9. package/cli/bulk-checkpoint.mjs +2 -3
  10. package/cli/bulk-command-runner.mjs +2 -5
  11. package/cli/case-pack.mjs +819 -34
  12. package/cli/command-reference.mjs +48 -24
  13. package/cli/completion.mjs +16 -8
  14. package/cli/ct-event-intake.mjs +32 -2
  15. package/cli/discovery-scan-command-runner.mjs +4 -5
  16. package/cli/discovery-workflow.mjs +2 -3
  17. package/cli/doctor.mjs +2 -2
  18. package/cli/domain-control-monitor.mjs +21 -15
  19. package/cli/domain-control-observations.mjs +25 -2
  20. package/cli/evidence-command-runner.mjs +17 -11
  21. package/cli/evidence-signing.mjs +73 -45
  22. package/cli/export-evidence.mjs +11 -4
  23. package/cli/formatters/html.mjs +3 -3
  24. package/cli/formatters/terminal.mjs +3 -0
  25. package/cli/input.mjs +57 -0
  26. package/cli/interchange-report.mjs +226 -0
  27. package/cli/investigation-manifest.mjs +16 -17
  28. package/cli/investigation-run.mjs +71 -23
  29. package/cli/local-mmdb-review.mjs +45 -14
  30. package/cli/lookup-command-runner.mjs +2 -2
  31. package/cli/lookup-diff.mjs +9 -0
  32. package/cli/network-command-runner.mjs +78 -0
  33. package/cli/offline-evidence-review.mjs +28 -8
  34. package/cli/page-compare.mjs +8 -5
  35. package/cli/registry-cohort.mjs +385 -61
  36. package/cli/registry-support.mjs +2 -1
  37. package/cli/retained-artifact-diff.mjs +376 -0
  38. package/cli/risk-calibration.mjs +7 -5
  39. package/cli/runner.mjs +147 -185
  40. package/cli/saved-lookup.mjs +26 -3
  41. package/cli/sharing-review.mjs +12 -6
  42. package/docs/cli-reference.md +444 -70
  43. package/docs/cli.md +24 -5
  44. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  45. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  46. package/frontend/src/lib/analysis/brand-profile-model.js +29 -5
  47. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  48. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  49. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  50. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  51. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  52. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  53. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  54. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  55. package/frontend/src/lib/analysis/case-evidence-model.js +8 -0
  56. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  57. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  58. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  59. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  60. package/frontend/src/lib/analysis/case-record-operations.js +42 -12
  61. package/frontend/src/lib/analysis/case-report.js +39 -6
  62. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  63. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  64. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  65. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  66. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  67. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  68. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  69. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  70. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  71. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  72. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  73. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  74. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  75. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  76. package/frontend/src/lib/analysis/lookup-decision-support.js +61 -6
  77. package/frontend/src/lib/analysis/lookup-presentation.js +62 -0
  78. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  79. package/frontend/src/lib/analysis/utils.js +26 -3
  80. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  81. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  82. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  83. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  84. package/frontend/src/lib/analysis/workspace-archive.js +104 -14
  85. package/lib/application-version.mjs +3 -0
  86. package/lib/availability.mjs +14 -12
  87. package/lib/bounded-file.mjs +13 -0
  88. package/lib/bounded-json-response.mjs +17 -4
  89. package/lib/bounded-json.mjs +249 -0
  90. package/lib/certificate-extension-profile.mjs +2 -1
  91. package/lib/cryptographic-assurance.mjs +155 -0
  92. package/lib/ct-search.mjs +87 -47
  93. package/lib/dns-intelligence.mjs +3 -2
  94. package/lib/dns-resolver-selection.mjs +6 -4
  95. package/lib/dnssec-chain-validation.mjs +1053 -0
  96. package/lib/dnssec-evidence-validation.mjs +10 -3
  97. package/lib/domain-change-packet.mjs +7 -5
  98. package/lib/domain-control-manifest.mjs +17 -129
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/evidence-export.mjs +751 -78
  101. package/lib/evidence-report-markdown.mjs +3 -0
  102. package/lib/evidence-report.mjs +5 -0
  103. package/lib/favicon.mjs +8 -5
  104. package/lib/http-evidence-bounds.mjs +3 -0
  105. package/lib/http-intelligence.mjs +2 -5
  106. package/lib/interchange-fidelity-registry.mjs +122 -0
  107. package/lib/local-geoip-evidence.mjs +2 -1
  108. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  109. package/lib/lookup-response-contract.mjs +691 -3
  110. package/lib/lookup.mjs +2 -2
  111. package/lib/observation.mjs +7 -6
  112. package/lib/outbound-identity.mjs +2 -3
  113. package/lib/perceptual-hash.mjs +12 -4
  114. package/lib/portable-generator.mjs +21 -0
  115. package/lib/provider-policy-admission.mjs +9 -4
  116. package/lib/rdap-normalization.mjs +18 -5
  117. package/lib/rdap-registrar.mjs +5 -1
  118. package/lib/registry-capabilities.mjs +3 -0
  119. package/lib/registry-capability-catalogue.mjs +12 -2
  120. package/lib/registry-comparison.mjs +28 -11
  121. package/lib/risk-calibration-summary.mjs +338 -0
  122. package/lib/rpki-evidence.mjs +9 -8
  123. package/lib/safe-fetch.mjs +59 -10
  124. package/lib/security-txt.mjs +26 -3
  125. package/lib/service-binding-dns.mjs +6 -1
  126. package/lib/smtp-transport-review.mjs +784 -0
  127. package/lib/sslbl-certificates.generated.mjs +136 -134
  128. package/lib/threat-intelligence-runtime.mjs +3 -11
  129. package/lib/threat-intelligence-types.mjs +18 -1
  130. package/lib/tls-intelligence.mjs +11 -5
  131. package/lib/tlsa-evidence.mjs +37 -7
  132. package/package.json +1 -1
  133. package/third-party-notices.txt +95 -5817
@@ -0,0 +1,79 @@
1
+ const MAX_COLLECTION_SCAN = 256;
2
+ const MAX_DEPTH = 8;
3
+ const MAX_SCALAR_LENGTH = 2_000;
4
+ const MAX_TRAVERSAL_NODES = 1_024;
5
+ const MAX_SERIALISED_LENGTH = 8_192;
6
+ function record(value) {
7
+ return value && typeof value === 'object' && !Array.isArray(value)
8
+ ? value
9
+ : null;
10
+ }
11
+ function boundedJsonString(value, state) {
12
+ if (value.length > MAX_SCALAR_LENGTH)
13
+ state.truncated = true;
14
+ return JSON.stringify(value.slice(0, MAX_SCALAR_LENGTH));
15
+ }
16
+ function stableJson(value, depth, state) {
17
+ if (depth > MAX_DEPTH) {
18
+ state.truncated = true;
19
+ return '"[bounded depth]"';
20
+ }
21
+ if (state.nodes >= MAX_TRAVERSAL_NODES) {
22
+ state.truncated = true;
23
+ return '"[bounded traversal]"';
24
+ }
25
+ state.nodes += 1;
26
+ if (value === null)
27
+ return 'null';
28
+ if (typeof value === 'string')
29
+ return boundedJsonString(value, state);
30
+ if (typeof value === 'number')
31
+ return Number.isFinite(value) ? String(value) : 'null';
32
+ if (typeof value === 'boolean')
33
+ return value ? 'true' : 'false';
34
+ if (Array.isArray(value)) {
35
+ const values = [];
36
+ const limit = Math.min(value.length, MAX_COLLECTION_SCAN);
37
+ for (let index = 0; index < limit && state.nodes < MAX_TRAVERSAL_NODES; index += 1) {
38
+ values.push(stableJson(value[index], depth + 1, state));
39
+ }
40
+ if (values.length < value.length)
41
+ state.truncated = true;
42
+ return `[${values.join(',')}]`;
43
+ }
44
+ const item = record(value);
45
+ if (!item)
46
+ return 'null';
47
+ const keys = [];
48
+ for (const key in item) {
49
+ if (!Object.hasOwn(item, key))
50
+ continue;
51
+ if (keys.length >= MAX_COLLECTION_SCAN) {
52
+ state.truncated = true;
53
+ break;
54
+ }
55
+ keys.push(key);
56
+ }
57
+ keys.sort();
58
+ const values = [];
59
+ for (const key of keys) {
60
+ if (state.nodes >= MAX_TRAVERSAL_NODES) {
61
+ state.truncated = true;
62
+ break;
63
+ }
64
+ values.push(`${boundedJsonString(key, state)}:${stableJson(item[key], depth + 1, state)}`);
65
+ }
66
+ if (values.length < keys.length)
67
+ state.truncated = true;
68
+ return `{${values.join(',')}}`;
69
+ }
70
+ export function projectComparisonLedgerJson(value) {
71
+ const state = { truncated: false, nodes: 0 };
72
+ const serialised = stableJson(value, 0, state);
73
+ if (serialised.length > MAX_SERIALISED_LENGTH)
74
+ state.truncated = true;
75
+ return Object.freeze({ value: serialised.slice(0, MAX_SERIALISED_LENGTH), truncated: state.truncated });
76
+ }
77
+ export function stableComparisonLedgerJson(value) {
78
+ return projectComparisonLedgerJson(value).value;
79
+ }
@@ -0,0 +1,51 @@
1
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/gu;
2
+ const MAX_ID_PARTS = 32;
3
+ const MAX_ID_PART_LENGTH = 500;
4
+ function stableHash(value) {
5
+ let first = 0x811c9dc5;
6
+ let second = 0;
7
+ for (let index = 0; index < value.length; index += 1) {
8
+ const code = value.charCodeAt(index);
9
+ first ^= code;
10
+ first = Math.imul(first, 0x01000193);
11
+ second = (second + code) | 0;
12
+ second = (second + (second << 10)) | 0;
13
+ second ^= second >>> 6;
14
+ }
15
+ second = (second + (second << 3)) | 0;
16
+ second ^= second >>> 11;
17
+ second = (second + (second << 15)) | 0;
18
+ return `${(first >>> 0).toString(36).padStart(7, '0')}${(second >>> 0).toString(36).padStart(7, '0')}`;
19
+ }
20
+ export function stableComparisonLedgerId(prefix, parts) {
21
+ const encoded = parts.slice(0, MAX_ID_PARTS).map((part) => {
22
+ let value = '';
23
+ try {
24
+ value = part === null || part === undefined || ['string', 'number', 'boolean', 'bigint'].includes(typeof part)
25
+ ? String(part ?? '')
26
+ : Object.prototype.toString.call(part);
27
+ }
28
+ catch {
29
+ value = '[unreadable]';
30
+ }
31
+ const bounded = value.slice(0, MAX_ID_PART_LENGTH);
32
+ const typed = `${typeof part}:${value.length}:${bounded}`;
33
+ return `${typed.length}:${typed}`;
34
+ }).join('');
35
+ const material = `${parts.length}:${encoded}`;
36
+ return `${prefix.slice(0, 64)}-${stableHash(material)}`;
37
+ }
38
+ export function safeComparisonLedgerHref(value) {
39
+ if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean')
40
+ return '';
41
+ const href = String(value).replace(CONTROL_RE, ' ').replace(/\s+/gu, ' ').trim().slice(0, 500).trim();
42
+ if (!href.startsWith('/') || href.startsWith('//') || href.includes('\\'))
43
+ return '';
44
+ try {
45
+ const base = new URL('https://comparison-ledger.invalid/');
46
+ return new URL(href, base).origin === base.origin ? href : '';
47
+ }
48
+ catch {
49
+ return '';
50
+ }
51
+ }
@@ -0,0 +1,145 @@
1
+ import { canonicalArtifactJsonFor, resolveArtifactCanonicalization, SORTED_JSON_V1, SORTED_JSON_V2, } from "./artifact-integrity.js";
2
+ import { normalizeDomain } from "./case-record-core.js";
3
+ import { canonicalDomainControlRecordList } from "./domain-control-records.js";
4
+ export const DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA = 'whoisleuth.domain-control-manifest-input';
5
+ export const DOMAIN_CONTROL_PASSPORT_SCHEMA = 'whoisleuth.domain-control-manifest';
6
+ export const DOMAIN_CONTROL_PASSPORT_VERSION = 1;
7
+ export const DOMAIN_CONTROL_MANIFEST_VERSION = 2;
8
+ export const MAX_DOMAIN_CONTROL_PASSPORT_ENTRIES = 100;
9
+ export const DOMAIN_CONTROL_PASSPORT_LIMITATIONS = Object.freeze([
10
+ 'This analyst-authored manifest records intended domain-control state. It does not collect evidence or change registrar, DNS, mail, or certificate configuration.',
11
+ 'Empty desired fields are unconfigured rather than claims that a record should be absent.',
12
+ ]);
13
+ const INPUT_KEYS = new Set(['schema', 'version', 'expiresAt', 'entries']);
14
+ const MANIFEST_KEYS = new Set(['schema', 'version', 'generatedAt', 'expiresAt', 'entries', 'limitations', 'integrity']);
15
+ const ENTRY_KEYS = new Set(['domain', 'nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock', 'renewalReviewAt', 'note']);
16
+ const INTEGRITY_KEYS = new Set(['algorithm', 'canonicalization', 'digestSha256']);
17
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
18
+ function record(value) {
19
+ return value && typeof value === 'object' && !Array.isArray(value)
20
+ ? value
21
+ : null;
22
+ }
23
+ function exactKeys(value, allowed, label) {
24
+ const unknown = Object.keys(value).find((key) => !allowed.has(key));
25
+ if (unknown)
26
+ throw new TypeError(`${label} contains unknown field: ${unknown}.`);
27
+ }
28
+ function text(value, maximum = 300) {
29
+ if (typeof value !== 'string' || value.length > maximum * 4 || CONTROL_RE.test(value))
30
+ return null;
31
+ return value.replace(/\s+/gu, ' ').trim().slice(0, maximum) || null;
32
+ }
33
+ function timestamp(value) {
34
+ const candidate = text(value, 64);
35
+ if (!candidate)
36
+ return null;
37
+ const parsed = Date.parse(candidate);
38
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
39
+ }
40
+ function hostnames(value) {
41
+ if (!Array.isArray(value))
42
+ return [];
43
+ return [...new Set(value.slice(0, 128).flatMap((item) => {
44
+ const normalized = normalizeDomain(item);
45
+ return normalized ? [normalized] : [];
46
+ }))].sort().slice(0, 32);
47
+ }
48
+ function digest(value) {
49
+ const candidate = text(value, 64)?.toLowerCase() ?? '';
50
+ return /^[a-f0-9]{64}$/u.test(candidate) ? candidate : null;
51
+ }
52
+ function normalizeEntry(value) {
53
+ const source = record(value);
54
+ if (!source)
55
+ return null;
56
+ exactKeys(source, ENTRY_KEYS, 'Domain control manifest entry');
57
+ const domain = normalizeDomain(source.domain);
58
+ if (!domain)
59
+ return null;
60
+ return Object.freeze({
61
+ domain,
62
+ nameservers: Object.freeze(hostnames(source.nameservers)),
63
+ ds: Object.freeze(canonicalDomainControlRecordList(source.ds, 'ds')),
64
+ mx: Object.freeze(canonicalDomainControlRecordList(source.mx, 'mx')),
65
+ caa: Object.freeze(canonicalDomainControlRecordList(source.caa, 'caa')),
66
+ tlsIssuer: text(source.tlsIssuer, 300)?.toLowerCase() ?? null,
67
+ tlsSpkiSha256: digest(source.tlsSpkiSha256),
68
+ registrarLock: source.registrarLock === 'required' || source.registrarLock === 'not_required'
69
+ ? source.registrarLock
70
+ : null,
71
+ renewalReviewAt: timestamp(source.renewalReviewAt),
72
+ note: text(source.note, 500),
73
+ });
74
+ }
75
+ export function buildUnsignedDomainControlPassport(input, generatedAtValue) {
76
+ const source = record(input);
77
+ if (!source || source.schema !== DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA || source.version !== DOMAIN_CONTROL_PASSPORT_VERSION) {
78
+ throw new TypeError(`Domain control manifest input must use ${DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA} version ${DOMAIN_CONTROL_PASSPORT_VERSION}.`);
79
+ }
80
+ exactKeys(source, INPUT_KEYS, 'Domain control manifest input');
81
+ const generatedAt = timestamp(generatedAtValue);
82
+ const expiresAt = timestamp(source.expiresAt);
83
+ if (!generatedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt)) {
84
+ throw new TypeError('Domain control manifest expiry must be a valid time after generation.');
85
+ }
86
+ if (!Array.isArray(source.entries) || source.entries.length < 1 || source.entries.length > MAX_DOMAIN_CONTROL_PASSPORT_ENTRIES) {
87
+ throw new TypeError(`Domain control manifest input must contain between 1 and ${MAX_DOMAIN_CONTROL_PASSPORT_ENTRIES} entries.`);
88
+ }
89
+ const entries = source.entries.map(normalizeEntry);
90
+ if (entries.some((entry) => entry === null))
91
+ throw new TypeError('Domain control manifest contains an invalid entry.');
92
+ const normalizedEntries = entries;
93
+ if (new Set(normalizedEntries.map((entry) => entry.domain)).size !== normalizedEntries.length) {
94
+ throw new TypeError('Domain control manifest entries must use unique domains.');
95
+ }
96
+ return Object.freeze({
97
+ schema: DOMAIN_CONTROL_PASSPORT_SCHEMA,
98
+ version: DOMAIN_CONTROL_MANIFEST_VERSION,
99
+ generatedAt,
100
+ expiresAt,
101
+ entries: Object.freeze([...normalizedEntries].sort((left, right) => left.domain.localeCompare(right.domain))),
102
+ limitations: DOMAIN_CONTROL_PASSPORT_LIMITATIONS,
103
+ });
104
+ }
105
+ export function normalizeDomainControlPassportDocument(value) {
106
+ const source = record(value);
107
+ const integrity = record(source?.integrity);
108
+ if (!source
109
+ || source.schema !== DOMAIN_CONTROL_PASSPORT_SCHEMA
110
+ || !integrity
111
+ || integrity.algorithm !== 'SHA-256'
112
+ || typeof integrity.digestSha256 !== 'string'
113
+ || !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
114
+ throw new TypeError('Domain control manifest has an unsupported or malformed structure.');
115
+ }
116
+ exactKeys(source, MANIFEST_KEYS, 'Domain control manifest');
117
+ exactKeys(integrity, INTEGRITY_KEYS, 'Domain control manifest integrity');
118
+ const canonicalization = resolveArtifactCanonicalization(source.version, integrity.canonicalization, [
119
+ { version: DOMAIN_CONTROL_PASSPORT_VERSION, canonicalization: SORTED_JSON_V1, explicit: true },
120
+ { version: DOMAIN_CONTROL_MANIFEST_VERSION, canonicalization: SORTED_JSON_V2, explicit: true },
121
+ ], 'Domain control manifest');
122
+ if (!Array.isArray(source.limitations)
123
+ || source.limitations.length !== DOMAIN_CONTROL_PASSPORT_LIMITATIONS.length
124
+ || source.limitations.some((item, index) => item !== DOMAIN_CONTROL_PASSPORT_LIMITATIONS[index])) {
125
+ throw new TypeError('Domain control manifest has an unsupported or malformed structure.');
126
+ }
127
+ const baseUnsigned = buildUnsignedDomainControlPassport({
128
+ schema: DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA,
129
+ version: DOMAIN_CONTROL_PASSPORT_VERSION,
130
+ expiresAt: source.expiresAt,
131
+ entries: source.entries,
132
+ }, source.generatedAt);
133
+ const unsigned = Object.freeze({ ...baseUnsigned, version: source.version });
134
+ const { integrity: _integrity, ...suppliedUnsigned } = source;
135
+ const canonicalUnsigned = canonicalArtifactJsonFor(unsigned, canonicalization);
136
+ if (canonicalArtifactJsonFor(suppliedUnsigned, canonicalization) !== canonicalUnsigned) {
137
+ throw new TypeError('Domain control manifest must use its canonical normalised content.');
138
+ }
139
+ return {
140
+ manifest: source,
141
+ unsigned,
142
+ canonicalUnsigned,
143
+ canonicalization,
144
+ };
145
+ }
@@ -0,0 +1,88 @@
1
+ import { normalizeDomain } from "./case-record-core.js";
2
+ export const MAX_CANONICAL_DOMAIN_CONTROL_RECORDS = 32;
3
+ function record(value) {
4
+ return value && typeof value === 'object' && !Array.isArray(value)
5
+ ? value
6
+ : {};
7
+ }
8
+ function text(value, maximum = 500) {
9
+ if (typeof value !== 'string' || value.length > maximum * 4 || /[\u0000-\u001f\u007f]/u.test(value))
10
+ return '';
11
+ return value.replace(/\s+/gu, ' ').trim().slice(0, maximum).trim();
12
+ }
13
+ function integer(value, maximum) {
14
+ const candidate = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/u.test(value.trim()) ? Number(value) : NaN;
15
+ return Number.isSafeInteger(candidate) && candidate >= 0 && candidate <= maximum ? candidate : null;
16
+ }
17
+ function exchange(value) {
18
+ const candidate = text(value, 253).toLowerCase();
19
+ if (candidate === '.')
20
+ return '.';
21
+ return normalizeDomain(candidate) ?? '';
22
+ }
23
+ export function canonicalMxRecord(value) {
24
+ const item = record(value);
25
+ if (Object.keys(item).length) {
26
+ const host = exchange(item.exchange ?? item.host ?? item.value);
27
+ const priority = integer(item.priority ?? item.preference, 65_535);
28
+ return host && priority !== null ? `${priority} ${host}` : '';
29
+ }
30
+ const candidate = text(value, 500).toLowerCase();
31
+ const match = /^(\d{1,5})\s+(.+)$/u.exec(candidate);
32
+ if (match) {
33
+ const priority = integer(match[1], 65_535);
34
+ const host = exchange(match[2]);
35
+ return priority !== null && host ? `${priority} ${host}` : '';
36
+ }
37
+ return exchange(candidate);
38
+ }
39
+ function caaValue(value) {
40
+ return text(value, 500).replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, '$1$2').trim().toLowerCase();
41
+ }
42
+ export function canonicalCaaRecord(value) {
43
+ const item = record(value);
44
+ if (Object.keys(item).length) {
45
+ const flags = integer(item.critical ?? item.flags ?? 0, 255);
46
+ const tag = text(item.tag, 32).toLowerCase();
47
+ const payload = caaValue(item.value);
48
+ return flags !== null && /^(?:issue|issuewild|iodef)$/u.test(tag) && payload
49
+ ? `${flags} ${tag} ${payload}`
50
+ : '';
51
+ }
52
+ const candidate = text(value, 600);
53
+ const match = /^(\d{1,3})\s+(issue|issuewild|iodef)\s+(.+)$/iu.exec(candidate);
54
+ if (!match)
55
+ return '';
56
+ const flags = integer(match[1], 255);
57
+ const tag = match[2]?.toLowerCase() ?? '';
58
+ const payload = caaValue(match[3]);
59
+ return flags !== null && payload ? `${flags} ${tag} ${payload}` : '';
60
+ }
61
+ export function canonicalDsRecord(value) {
62
+ const item = record(value);
63
+ if (Object.keys(item).length) {
64
+ const keyTag = integer(item.keyTag ?? item.key_tag, 65_535);
65
+ const algorithm = integer(item.algorithm, 255);
66
+ const digestType = integer(item.digestType ?? item.digest_type, 255);
67
+ const digest = text(item.digest, 1024).replace(/\s+/gu, '').toLowerCase();
68
+ return keyTag !== null && algorithm !== null && digestType !== null && /^[a-f0-9]{2,1024}$/u.test(digest)
69
+ ? `${keyTag} ${algorithm} ${digestType} ${digest}`
70
+ : '';
71
+ }
72
+ const candidate = text(value, 1200);
73
+ const match = /^(\d{1,5})\s+(\d{1,3})\s+(\d{1,3})\s+([a-f0-9\s]+)$/iu.exec(candidate);
74
+ if (!match)
75
+ return '';
76
+ return canonicalDsRecord({ keyTag: match[1], algorithm: match[2], digestType: match[3], digest: match[4] });
77
+ }
78
+ export function canonicalDomainControlRecordList(value, kind) {
79
+ if (!Array.isArray(value))
80
+ return [];
81
+ const normalizer = kind === 'mx' ? canonicalMxRecord : kind === 'caa' ? canonicalCaaRecord : canonicalDsRecord;
82
+ return [...new Set(value
83
+ .slice(0, MAX_CANONICAL_DOMAIN_CONTROL_RECORDS * 4)
84
+ .map(normalizer)
85
+ .filter(Boolean))]
86
+ .sort()
87
+ .slice(0, MAX_CANONICAL_DOMAIN_CONTROL_RECORDS);
88
+ }
@@ -1,6 +1,6 @@
1
1
  import { normalizeDomain, openOrCreateCase, updateCase, } from "./case-model.js";
2
2
  export const EXTERNAL_FINDINGS_SCHEMA = 'whoisleuth.external-findings';
3
- export const EXTERNAL_FINDINGS_VERSION = 3;
3
+ export const EXTERNAL_FINDINGS_VERSION = 4;
4
4
  export const MAX_EXTERNAL_FINDINGS_IMPORT_BYTES = 384 * 1024;
5
5
  export const MAX_EXTERNAL_FINDINGS = 100;
6
6
  export const MAX_EXTERNAL_FINDINGS_PER_DOMAIN = 20;
@@ -38,12 +38,18 @@ const STRUCTURED_OBSERVATION_KEYS = new Set([
38
38
  'value',
39
39
  'issuer',
40
40
  'notAfter',
41
+ 'eventId',
42
+ 'logId',
43
+ 'certificateSha256',
44
+ 'dnsNameCount',
45
+ 'namesComplete',
41
46
  ]);
42
47
  const STRUCTURED_OBSERVATION_SCHEMAS = new Set([
43
48
  'whoisleuth.domain-observation-rows',
44
49
  'whoisleuth.dns-observation-rows',
45
50
  'whoisleuth.certificate-observation-rows',
46
51
  ]);
52
+ const SHA256_RE = /^[a-f0-9]{64}$/u;
47
53
  function record(value) {
48
54
  return value && typeof value === 'object' && !Array.isArray(value)
49
55
  ? value
@@ -86,7 +92,7 @@ function limitations(value, index) {
86
92
  unique.add(requiredText(item, 240, `Finding ${index + 1} limitation`));
87
93
  return [...unique];
88
94
  }
89
- function structuredObservation(value, index) {
95
+ function structuredObservation(value, index, documentVersion) {
90
96
  if (value === undefined || value === null)
91
97
  return null;
92
98
  const item = record(value);
@@ -104,9 +110,44 @@ function structuredObservation(value, index) {
104
110
  const issuer = optionalText(item.issuer, 160, `Finding ${index + 1} certificate issuer`);
105
111
  const notAfter = iso(item.notAfter, `Finding ${index + 1} certificate expiry`, true);
106
112
  const certificateSchema = item.sourceSchema === 'whoisleuth.certificate-observation-rows';
113
+ if (certificateSchema && (field === 'certificateSha256' || field === 'fingerprintSha256') && !SHA256_RE.test(observationValue.toLowerCase())) {
114
+ throw new Error(`Finding ${index + 1} certificate observation value must be a SHA-256 hexadecimal digest.`);
115
+ }
107
116
  if (!certificateSchema && (issuer !== null || notAfter !== null)) {
108
117
  throw new Error(`Finding ${index + 1} non-certificate observation cannot declare certificate metadata.`);
109
118
  }
119
+ const declaresEventMetadata = ['eventId', 'logId', 'certificateSha256', 'dnsNameCount', 'namesComplete']
120
+ .some((key) => item[key] !== undefined && item[key] !== null);
121
+ let eventId = null;
122
+ let logId = null;
123
+ let certificateSha256 = null;
124
+ let dnsNameCount = null;
125
+ let namesComplete = null;
126
+ if (declaresEventMetadata) {
127
+ if (!certificateSchema || documentVersion < 4) {
128
+ throw new Error(`Finding ${index + 1} certificate-event metadata requires external-findings schema version 4.`);
129
+ }
130
+ eventId = requiredText(item.eventId, 64, `Finding ${index + 1} certificate event id`);
131
+ if (!/^[A-Za-z0-9_-]{1,64}$/u.test(eventId)) {
132
+ throw new Error(`Finding ${index + 1} certificate event id is invalid.`);
133
+ }
134
+ logId = requiredText(item.logId, 200, `Finding ${index + 1} certificate log id`);
135
+ certificateSha256 = requiredText(item.certificateSha256, 64, `Finding ${index + 1} certificate digest`).toLowerCase();
136
+ if (!SHA256_RE.test(certificateSha256)) {
137
+ throw new Error(`Finding ${index + 1} certificate event digest must be a SHA-256 hexadecimal digest.`);
138
+ }
139
+ if (certificateSha256 !== observationValue.toLowerCase()) {
140
+ throw new Error(`Finding ${index + 1} certificate event digest must match the structured observation value.`);
141
+ }
142
+ if (!Number.isSafeInteger(item.dnsNameCount) || Number(item.dnsNameCount) < 1 || Number(item.dnsNameCount) > 100) {
143
+ throw new Error(`Finding ${index + 1} certificate DNS name count is invalid.`);
144
+ }
145
+ dnsNameCount = Number(item.dnsNameCount);
146
+ if (typeof item.namesComplete !== 'boolean') {
147
+ throw new Error(`Finding ${index + 1} certificate name completeness is invalid.`);
148
+ }
149
+ namesComplete = item.namesComplete;
150
+ }
110
151
  return {
111
152
  sourceSchema: item.sourceSchema,
112
153
  sourceVersion: 1,
@@ -114,6 +155,11 @@ function structuredObservation(value, index) {
114
155
  value: observationValue,
115
156
  issuer,
116
157
  notAfter,
158
+ eventId,
159
+ logId,
160
+ certificateSha256,
161
+ dnsNameCount,
162
+ namesComplete,
117
163
  };
118
164
  }
119
165
  function findingKey(finding, sourceName) {
@@ -125,6 +171,7 @@ function findingKey(finding, sourceName) {
125
171
  finding.observedAt,
126
172
  finding.completeness,
127
173
  sourceName,
174
+ finding.structuredObservation?.eventId ?? '',
128
175
  ].join('\u0000');
129
176
  }
130
177
  export function parseExternalFindingsDocument(value) {
@@ -133,8 +180,8 @@ export function parseExternalFindingsDocument(value) {
133
180
  throw new Error('External findings must use the documented object shape without additional top-level fields.');
134
181
  }
135
182
  if (root.schema !== EXTERNAL_FINDINGS_SCHEMA
136
- || ![1, 2, EXTERNAL_FINDINGS_VERSION].includes(Number(root.schemaVersion))) {
137
- throw new Error(`External findings must use ${EXTERNAL_FINDINGS_SCHEMA} schema version 1, 2, or ${EXTERNAL_FINDINGS_VERSION}.`);
183
+ || ![1, 2, 3, EXTERNAL_FINDINGS_VERSION].includes(Number(root.schemaVersion))) {
184
+ throw new Error(`External findings must use ${EXTERNAL_FINDINGS_SCHEMA} schema version 1, 2, 3, or ${EXTERNAL_FINDINGS_VERSION}.`);
138
185
  }
139
186
  const sourceValue = record(root.source);
140
187
  if (!sourceValue || !hasOnlyKeys(sourceValue, SOURCE_KEYS)) {
@@ -181,7 +228,7 @@ export function parseExternalFindingsDocument(value) {
181
228
  completeness: item.completeness,
182
229
  limitations: limitations(item.limitations, index),
183
230
  reference: optionalText(item.reference, 500, `Finding ${index + 1} reference`),
184
- structuredObservation: structuredObservation(item.structuredObservation, index),
231
+ structuredObservation: structuredObservation(item.structuredObservation, index, Number(root.schemaVersion)),
185
232
  };
186
233
  const key = findingKey(finding, source.name);
187
234
  if (seen.has(key))
@@ -229,12 +276,28 @@ function existingPinKey(recordValue, finding, sourceName) {
229
276
  && (!observation || (pin.field === observation.field
230
277
  && pin.sourceSchema?.collection === 'external_observations'
231
278
  && pin.sourceSchema.schema === observation.sourceSchema
232
- && pin.sourceSchema.version === observation.sourceVersion)))) ? findingKey(finding, sourceName) : '';
279
+ && pin.sourceSchema.version === observation.sourceVersion
280
+ && (observation.eventId === null || pin.certificateObservation?.eventId === observation.eventId))))) ? findingKey(finding, sourceName) : '';
233
281
  }
234
282
  function structuredPinFields(finding) {
235
283
  const observation = finding.structuredObservation;
236
284
  if (!observation)
237
285
  return {};
286
+ const certificateObservation = observation.eventId
287
+ && observation.logId
288
+ && observation.certificateSha256
289
+ && observation.dnsNameCount !== null
290
+ && observation.namesComplete !== null
291
+ ? {
292
+ eventId: observation.eventId,
293
+ logId: observation.logId,
294
+ certificateSha256: observation.certificateSha256,
295
+ issuer: observation.issuer,
296
+ notAfter: observation.notAfter,
297
+ dnsNameCount: observation.dnsNameCount,
298
+ namesComplete: observation.namesComplete,
299
+ }
300
+ : null;
238
301
  return {
239
302
  field: observation.field,
240
303
  category: finding.category,
@@ -243,6 +306,7 @@ function structuredPinFields(finding) {
243
306
  schema: observation.sourceSchema,
244
307
  version: observation.sourceVersion,
245
308
  },
309
+ certificateObservation,
246
310
  };
247
311
  }
248
312
  export function mergeExternalFindingsIntoCases(current, document, now = new Date().toISOString()) {
@@ -1,6 +1,7 @@
1
- import { canonicalArtifactJson, sha256ArtifactDigest } from "./artifact-integrity.js";
1
+ import { canonicalArtifactJson, canonicalArtifactJsonV2, sha256ArtifactDigest, sha256ArtifactDigestV2, SORTED_JSON_V2, } from "./artifact-integrity.js";
2
2
  export const INVESTIGATION_CAPSULE_SCHEMA = 'whoisleuth.investigation-capsule';
3
- export const INVESTIGATION_CAPSULE_VERSION = 1;
3
+ export const INVESTIGATION_CAPSULE_VERSION = 2;
4
+ export const LEGACY_INVESTIGATION_CAPSULE_VERSION = 1;
4
5
  function safeVersion(value) {
5
6
  return typeof value === 'string' && /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(value)
6
7
  ? value
@@ -46,14 +47,14 @@ function analystProjection(record) {
46
47
  export async function buildInvestigationCapsule(input) {
47
48
  const analystRecords = input.includeAnalystRecords ? analystProjection(input.caseRecord) : null;
48
49
  const [evidenceDigest, briefDigest, graphDigest, analystRecordsDigest] = await Promise.all([
49
- sha256ArtifactDigest(input.lookupEvidence),
50
- sha256ArtifactDigest(input.brief),
51
- sha256ArtifactDigest(input.graph),
52
- analystRecords ? sha256ArtifactDigest(analystRecords) : Promise.resolve(null),
50
+ sha256ArtifactDigestV2(input.lookupEvidence),
51
+ sha256ArtifactDigestV2(input.brief),
52
+ sha256ArtifactDigestV2(input.graph),
53
+ analystRecords ? sha256ArtifactDigestV2(analystRecords) : Promise.resolve(null),
53
54
  ]);
54
55
  const evidenceSchema = safeSchema(input.lookupEvidence.schema);
55
56
  const evidenceVersion = safeSchemaVersion(input.lookupEvidence.schemaVersion);
56
- return {
57
+ const unsigned = {
57
58
  schema: INVESTIGATION_CAPSULE_SCHEMA,
58
59
  schemaVersion: INVESTIGATION_CAPSULE_VERSION,
59
60
  generatedAt: iso(input.generatedAt),
@@ -70,7 +71,6 @@ export async function buildInvestigationCapsule(input) {
70
71
  investigationBrief: input.brief,
71
72
  graphSnapshot: input.graph,
72
73
  analystRecords,
73
- integrity: { algorithm: 'SHA-256', briefDigest, graphDigest, analystRecordsDigest },
74
74
  limitations: [
75
75
  'The Lookup evidence file is linked by digest but is not embedded; keep or share that exact file separately when verification is required.',
76
76
  'Digest verification can detect changed content but does not establish who created the capsule. No digital signature is applied.',
@@ -80,12 +80,36 @@ export async function buildInvestigationCapsule(input) {
80
80
  : 'Analyst decisions, assertions, notes, contacts, actions, and raw source payloads are excluded.',
81
81
  ],
82
82
  };
83
+ return {
84
+ ...unsigned,
85
+ integrity: {
86
+ algorithm: 'SHA-256',
87
+ canonicalization: SORTED_JSON_V2,
88
+ scope: 'capsule excluding integrity',
89
+ briefDigest,
90
+ graphDigest,
91
+ analystRecordsDigest,
92
+ digestSha256: await sha256ArtifactDigestV2(unsigned),
93
+ },
94
+ };
83
95
  }
84
96
  export async function verifyInvestigationCapsule(capsule) {
97
+ const current = capsule.schemaVersion === INVESTIGATION_CAPSULE_VERSION;
98
+ const legacy = capsule.schemaVersion === LEGACY_INVESTIGATION_CAPSULE_VERSION;
99
+ const integrityRecord = capsule.integrity;
100
+ if ((!current && !legacy)
101
+ || (current && (integrityRecord.canonicalization !== SORTED_JSON_V2
102
+ || integrityRecord.scope !== 'capsule excluding integrity'))
103
+ || (legacy && (Object.hasOwn(integrityRecord, 'canonicalization')
104
+ || Object.hasOwn(integrityRecord, 'scope')
105
+ || Object.hasOwn(integrityRecord, 'digestSha256')))) {
106
+ return { valid: false, brief: false, graph: false, analystRecords: null, whole: current ? false : null };
107
+ }
108
+ const projectionDigest = current ? sha256ArtifactDigestV2 : sha256ArtifactDigest;
85
109
  const [briefDigest, graphDigest, analystRecordsDigest] = await Promise.all([
86
- sha256ArtifactDigest(capsule.investigationBrief),
87
- sha256ArtifactDigest(capsule.graphSnapshot),
88
- capsule.analystRecords ? sha256ArtifactDigest(capsule.analystRecords) : Promise.resolve(null),
110
+ projectionDigest(capsule.investigationBrief),
111
+ projectionDigest(capsule.graphSnapshot),
112
+ capsule.analystRecords ? projectionDigest(capsule.analystRecords) : Promise.resolve(null),
89
113
  ]);
90
114
  const brief = briefDigest === capsule.integrity.briefDigest;
91
115
  const graph = graphDigest === capsule.integrity.graphDigest;
@@ -94,12 +118,22 @@ export async function verifyInvestigationCapsule(capsule) {
94
118
  : capsule.integrity.analystRecordsDigest === null
95
119
  ? null
96
120
  : false;
97
- return { valid: brief && graph && analystRecords !== false, brief, graph, analystRecords };
121
+ let whole = null;
122
+ if (current) {
123
+ const { integrity, ...unsigned } = capsule;
124
+ whole = integrity.algorithm === 'SHA-256'
125
+ && integrity.canonicalization === SORTED_JSON_V2
126
+ && integrity.scope === 'capsule excluding integrity'
127
+ && integrity.digestSha256 === await sha256ArtifactDigestV2(unsigned);
128
+ }
129
+ return { valid: brief && graph && analystRecords !== false && whole !== false, brief, graph, analystRecords, whole };
98
130
  }
99
131
  export function investigationCapsuleFilename(capsule) {
100
132
  const target = capsule.target.value.toLowerCase().replace(/[^a-z0-9.-]+/gu, '-').replace(/^-|-$/gu, '').slice(0, 80) || 'lookup';
101
133
  return `whoisleuth-investigation-capsule-${target}-${capsule.generatedAt.slice(0, 10)}.json`;
102
134
  }
103
135
  export function serializeInvestigationCapsule(capsule) {
104
- return `${canonicalArtifactJson(capsule)}\n`;
136
+ return `${capsule.schemaVersion === INVESTIGATION_CAPSULE_VERSION
137
+ ? canonicalArtifactJsonV2(capsule)
138
+ : canonicalArtifactJson(capsule)}\n`;
105
139
  }