@slicedearth/whoisleuth-cli 1.36.1 → 1.43.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 (139) hide show
  1. package/README.md +32 -2
  2. package/bin/whoisleuth.mjs +52 -24
  3. package/cli/archive-inspect.mjs +37 -2
  4. package/cli/arguments.mjs +493 -157
  5. package/cli/artifact-verify.mjs +91 -15
  6. package/cli/bulk-checkpoint.mjs +7 -11
  7. package/cli/bulk-command-runner.mjs +19 -2
  8. package/cli/case-pack.mjs +136 -0
  9. package/cli/ci-report.mjs +73 -0
  10. package/cli/collection-preflight.mjs +59 -0
  11. package/cli/command-reference.mjs +395 -0
  12. package/cli/compare.mjs +1 -1
  13. package/cli/completion.mjs +49 -11
  14. package/cli/config-profile.mjs +180 -0
  15. package/cli/ct-event-intake.mjs +159 -0
  16. package/cli/discovery-scan-command-runner.mjs +15 -1
  17. package/cli/doctor.mjs +11 -5
  18. package/cli/domain-control-monitor.mjs +104 -0
  19. package/cli/domain-control-observations.mjs +235 -0
  20. package/cli/evidence-command-arguments.mjs +12 -1
  21. package/cli/evidence-command-runner.mjs +3 -2
  22. package/cli/evidence-signing.mjs +4 -4
  23. package/cli/external-observation-mapping.mjs +162 -0
  24. package/cli/fail-policy.mjs +64 -0
  25. package/cli/formatters/html.mjs +2 -2
  26. package/cli/formatters/json.mjs +13 -1
  27. package/cli/formatters/terminal.mjs +7 -3
  28. package/cli/investigation-manifest.mjs +113 -0
  29. package/cli/investigation-plan.mjs +2 -2
  30. package/cli/investigation-run.mjs +112 -0
  31. package/cli/local-mmdb-review.mjs +76 -0
  32. package/cli/lookalike-calibration.mjs +121 -0
  33. package/cli/lookup-brief.mjs +116 -0
  34. package/cli/lookup-command-runner.mjs +12 -2
  35. package/cli/lookup-diff.mjs +17 -19
  36. package/cli/lookup-plan.mjs +3 -3
  37. package/cli/lookup-reconcile.mjs +141 -0
  38. package/cli/lookup-timeline.mjs +1 -1
  39. package/cli/mail-review.mjs +1 -1
  40. package/cli/network-command-runner.mjs +4 -1
  41. package/cli/offline-evidence-review.mjs +125 -10
  42. package/cli/open-asset-model-bridge.mjs +169 -0
  43. package/cli/registry-cohort.mjs +121 -0
  44. package/cli/registry-doctor.mjs +220 -0
  45. package/cli/registry-fixture-scaffold.mjs +73 -0
  46. package/cli/risk-calibration.mjs +117 -9
  47. package/cli/runner.mjs +561 -280
  48. package/cli/saved-lookup.mjs +2 -2
  49. package/cli/sharing-review.mjs +202 -0
  50. package/cli/source-reliability.mjs +1 -1
  51. package/docs/cli-reference.md +1336 -0
  52. package/docs/cli.md +259 -940
  53. package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
  54. package/frontend/src/lib/analysis/acquisition-due-diligence.js +7 -7
  55. package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
  56. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
  57. package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
  58. package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
  59. package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
  60. package/frontend/src/lib/analysis/case-migration-model.js +6 -1
  61. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  62. package/frontend/src/lib/analysis/case-record-core.js +5 -1
  63. package/frontend/src/lib/analysis/case-record-operations.js +6 -1
  64. package/frontend/src/lib/analysis/case-report.js +573 -0
  65. package/frontend/src/lib/analysis/case-response-model.js +30 -1
  66. package/frontend/src/lib/analysis/case-response-packet.js +7 -7
  67. package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
  68. package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
  69. package/frontend/src/lib/analysis/external-findings-import.js +320 -0
  70. package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
  71. package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
  72. package/frontend/src/lib/analysis/lookup-decision-support.js +487 -0
  73. package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
  74. package/frontend/src/lib/analysis/lookup-presentation.js +64 -0
  75. package/frontend/src/lib/analysis/lookup-response.js +2 -0
  76. package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
  77. package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
  78. package/frontend/src/lib/analysis/page-similarity.js +1 -1
  79. package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
  80. package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
  81. package/frontend/src/lib/analysis/scoring.js +9 -89
  82. package/frontend/src/lib/analysis/shortlist-model.js +8 -4
  83. package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
  84. package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
  85. package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
  86. package/frontend/src/lib/analysis/visualization-models.js +369 -0
  87. package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
  88. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
  89. package/frontend/src/lib/analysis/workspace-archive.js +4 -2
  90. package/lib/abusech-auth.mjs +1 -1
  91. package/lib/analyst-taxonomy.mjs +40 -0
  92. package/lib/availability.mjs +2 -2
  93. package/lib/bounded-contract-normalizers.mjs +33 -0
  94. package/lib/browser-library-profile.mjs +15 -2
  95. package/lib/certificate-extension-profile.mjs +71 -0
  96. package/lib/credential-surface-profile.mjs +1 -1
  97. package/lib/dns-convergence-review.mjs +183 -0
  98. package/lib/dns-delegation-health.mjs +196 -3
  99. package/lib/domain-assurance.mjs +368 -0
  100. package/lib/domain-change-packet.mjs +133 -0
  101. package/lib/domain-change-review.mjs +307 -0
  102. package/lib/domain-control-flight-recorder.mjs +268 -0
  103. package/lib/domain-control-manifest.mjs +50 -7
  104. package/lib/domain-portfolio-review.mjs +151 -0
  105. package/lib/domain-posture.mjs +9 -9
  106. package/lib/encrypted-dns-contract.mjs +1 -1
  107. package/lib/evidence-export.mjs +1 -1
  108. package/lib/evidence-report-markdown.mjs +1 -1
  109. package/lib/evidence-report.mjs +1 -1
  110. package/lib/generated/cisa-kev-catalog.mjs +1668 -0
  111. package/lib/html-signals.mjs +5 -11
  112. package/lib/ip-prefix.mjs +34 -1
  113. package/lib/local-geoip-evidence.mjs +2 -2
  114. package/lib/lookup-response-contract.mjs +346 -0
  115. package/lib/nameserver-preflight-review.mjs +159 -0
  116. package/lib/observed-network-context.mjs +1 -1
  117. package/lib/opportunity-scoring.mjs +107 -0
  118. package/lib/page-fingerprints.mjs +1 -1
  119. package/lib/page-language-signals.mjs +124 -0
  120. package/lib/rdap-capabilities.mjs +6 -6
  121. package/lib/rdap-search-workbench.mjs +96 -6
  122. package/lib/registry-access.mjs +1 -1
  123. package/lib/registry-capability-catalogue.mjs +4 -4
  124. package/lib/registry-insights.mjs +6 -6
  125. package/lib/risk-scoring.mjs +247 -83
  126. package/lib/scoring-evidence-quality.mjs +107 -0
  127. package/lib/security-txt.mjs +3 -3
  128. package/lib/static-html-analysis.mjs +29 -1
  129. package/lib/threat-intelligence-manifests.mjs +1 -1
  130. package/lib/threatfox-intelligence.mjs +1 -1
  131. package/lib/tls-intelligence.mjs +13 -3
  132. package/lib/trust-store-comparison.mjs +146 -0
  133. package/lib/urlhaus-intelligence.mjs +1 -1
  134. package/lib/website-security-posture.mjs +5 -5
  135. package/lib/website-technology.mjs +25 -11
  136. package/lib/whois-chain.mjs +1 -1
  137. package/lib/zone-intent-review.mjs +534 -0
  138. package/package.json +5 -1
  139. package/third-party-notices.txt +911 -19
@@ -36,7 +36,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
36
36
  includedEvidence: ['Exact URLs', 'Observation time', 'Case disposition', 'Selected evidence pins', 'Registrar contact provenance'],
37
37
  excludedEvidence: ['Raw WHOIS or RDAP payloads', 'Unselected contacts', 'Provider secrets', 'Unrelated analyst notes'],
38
38
  redactions: ['Remove unnecessary personal registration data', 'Remove credentials, tokens, and URL fragments'],
39
- attachments: ['Reviewed response packet', 'Optional normalized case report'],
39
+ attachments: ['Reviewed response packet', 'Optional normalised case report'],
40
40
  followUpFields: ['Registrar reference', 'Acknowledgement time', 'Resolution or transfer outcome'],
41
41
  },
42
42
  {
@@ -50,7 +50,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
50
50
  includedEvidence: ['Exact URLs', 'Observation time', 'Registry contact provenance', 'Selected evidence pins', 'Prior action references'],
51
51
  excludedEvidence: ['Raw registry payloads', 'Unselected contacts', 'Secrets', 'Unsupported ownership claims'],
52
52
  redactions: ['Remove unnecessary personal registration data', 'Remove credentials, tokens, and URL fragments'],
53
- attachments: ['Reviewed response packet', 'Optional normalized case report', 'Optional registrar reference'],
53
+ attachments: ['Reviewed response packet', 'Optional normalised case report', 'Optional registrar reference'],
54
54
  followUpFields: ['Registry reference', 'Registrar referral', 'Delegation or status outcome'],
55
55
  },
56
56
  {
@@ -64,7 +64,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
64
64
  includedEvidence: ['Exact URLs', 'Observation time', 'Selected HTTP, TLS, DNS, or page-identity pins', 'Contact provenance'],
65
65
  excludedEvidence: ['Raw response bodies', 'Cookies', 'Secrets', 'Unproven origin-host claims'],
66
66
  redactions: ['Remove credentials, query secrets, fragments, cookies, and unrelated contacts'],
67
- attachments: ['Reviewed response packet', 'Optional normalized case report'],
67
+ attachments: ['Reviewed response packet', 'Optional normalised case report'],
68
68
  followUpFields: ['Provider ticket', 'Content status', 'Infrastructure change outcome'],
69
69
  },
70
70
  {
@@ -78,7 +78,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
78
78
  includedEvidence: ['Exact URLs', 'Observation time', 'Selected evidence pins', 'Contact source and limitations'],
79
79
  excludedEvidence: ['Raw upstream payloads', 'Secrets', 'Unselected case notes', 'Legal conclusions'],
80
80
  redactions: ['Remove credentials, tokens, personal data, and unrelated identifiers'],
81
- attachments: ['Reviewed response packet', 'Optional normalized case report'],
81
+ attachments: ['Reviewed response packet', 'Optional normalised case report'],
82
82
  followUpFields: ['Security reference', 'Triage acknowledgement', 'Remediation outcome'],
83
83
  },
84
84
  {
@@ -88,11 +88,11 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
88
88
  subjectPrefix: 'Reviewed unsafe-site report',
89
89
  requiredContactKind: null,
90
90
  checklist: ['Use the recipient submission form or documented route manually', 'Include exact URLs and current observation time', 'Disclose source gaps and false-positive context'],
91
- evidenceOrder: ['Exact URLs', 'Observed behavior', 'Selected corroborating evidence', 'Limitations'],
91
+ evidenceOrder: ['Exact URLs', 'Observed behaviour', 'Selected corroborating evidence', 'Limitations'],
92
92
  includedEvidence: ['Exact URLs', 'Observation time', 'Observed harm', 'Selected evidence pins', 'Contradictions and cautions'],
93
93
  excludedEvidence: ['Raw provider payloads', 'Secrets', 'Unselected personal data', 'Automated maliciousness claims'],
94
94
  redactions: ['Remove credentials, tokens, URL fragments, and unrelated contacts'],
95
- attachments: ['Reviewed response packet', 'Optional normalized case report'],
95
+ attachments: ['Reviewed response packet', 'Optional normalised case report'],
96
96
  followUpFields: ['Submission reference', 'Review state', 'Listing or delisting outcome'],
97
97
  },
98
98
  {
@@ -106,7 +106,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
106
106
  includedEvidence: ['Case disposition', 'Selected evidence pins', 'Analyst decisions and assertions', 'Action history', 'Exact URLs when relevant'],
107
107
  excludedEvidence: ['Raw upstream payloads', 'Secrets', 'Unselected personal data', 'Unsupported attribution'],
108
108
  redactions: ['Remove credentials, tokens, and personal data not required for the internal decision'],
109
- attachments: ['Reviewed response packet', 'Normalized case report when required by internal policy'],
109
+ attachments: ['Reviewed response packet', 'Normalised case report when required by internal policy'],
110
110
  followUpFields: ['Internal owner', 'Due date', 'Decision', 'Control or escalation outcome'],
111
111
  },
112
112
  ]);
@@ -245,6 +245,42 @@ export function evaluateRuleSet(records, rawRules) {
245
245
  return [];
246
246
  return records.slice(0, 500).map((record) => evaluateDetectionRules(record, rawRules));
247
247
  }
248
+ function ruleSignature(rule) {
249
+ return JSON.stringify({
250
+ match: rule.match,
251
+ conditions: [...rule.conditions]
252
+ .map((condition) => ({ ...condition }))
253
+ .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))),
254
+ });
255
+ }
256
+ export function previewDetectionRule(records, existingRules, rawCandidate) {
257
+ const normalized = normalizeDetectionRule({ ...record(rawCandidate), id: 'preview-rule', enabled: true });
258
+ if (!normalized?.id)
259
+ return null;
260
+ const candidate = { ...normalized, id: normalized.id };
261
+ const sourceRecords = Array.isArray(records) ? records.slice(0, 500) : [];
262
+ const evaluations = evaluateRuleSet(sourceRecords, [candidate]).filter((item) => item.matchedRules.length > 0);
263
+ const recordsById = new Map(sourceRecords.map((item) => [String(record(item).id ?? ''), record(item)]));
264
+ const affectedDispositionCounts = {};
265
+ for (const evaluation of evaluations) {
266
+ const disposition = String(recordsById.get(evaluation.caseId)?.disposition ?? 'unreviewed');
267
+ affectedDispositionCounts[disposition] = (affectedDispositionCounts[disposition] ?? 0) + 1;
268
+ }
269
+ const signature = ruleSignature(candidate);
270
+ const collisionRuleIds = normalizeDetectionRuleStore(existingRules).rules
271
+ .filter((rule) => rule.name.toLowerCase() === candidate.name.toLowerCase() || ruleSignature(rule) === signature)
272
+ .map((rule) => rule.id)
273
+ .slice(0, MAX_DETECTION_RULES);
274
+ return Object.freeze({
275
+ candidate: Object.freeze(candidate),
276
+ matchCount: evaluations.length,
277
+ affectedCaseIds: Object.freeze(evaluations.map((item) => item.caseId).filter(Boolean)),
278
+ affectedDispositionCounts: Object.freeze(affectedDispositionCounts),
279
+ unreviewedMatchCount: affectedDispositionCounts.unreviewed ?? 0,
280
+ collisionRuleIds: Object.freeze(collisionRuleIds),
281
+ limitation: 'This preview evaluates the latest bounded evidence already retained in local cases. It does not save the rule, change a score, refresh evidence, or establish whether a match is correct.',
282
+ });
283
+ }
248
284
  export function mergeDetectionRules(localRaw, importedRaw) {
249
285
  const importedRecord = record(importedRaw);
250
286
  if (typeof importedRecord.schema === 'string' && importedRecord.schema !== DETECTION_RULE_SCHEMA) {
@@ -0,0 +1,328 @@
1
+ const MAX_ENTRIES = 24;
2
+ const MAX_ID_LENGTH = 64;
3
+ const MAX_LABEL_LENGTH = 120;
4
+ const MAX_LIMITATIONS = 8;
5
+ const MAX_LIMITATION_LENGTH = 280;
6
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/gu;
7
+ const COMPLETE_STATES = new Set([
8
+ 'available',
9
+ 'complete',
10
+ 'completed',
11
+ 'observed',
12
+ 'success',
13
+ 'supported',
14
+ ]);
15
+ const PARTIAL_STATES = new Set(['incomplete', 'limited', 'partial', 'truncated']);
16
+ const SKIPPED_STATES = new Set(['disabled', 'not_applicable', 'skipped']);
17
+ const UNAVAILABLE_STATES = new Set([
18
+ 'blocked',
19
+ 'error',
20
+ 'failed',
21
+ 'rate_limited',
22
+ 'timeout',
23
+ 'unavailable',
24
+ ]);
25
+ function boundedText(value, maximum) {
26
+ return String(value ?? '')
27
+ .replace(CONTROL_CHARACTERS, ' ')
28
+ .replace(/\s+/gu, ' ')
29
+ .trim()
30
+ .slice(0, maximum);
31
+ }
32
+ function record(value) {
33
+ return value && typeof value === 'object' && !Array.isArray(value)
34
+ ? value
35
+ : {};
36
+ }
37
+ function stringList(value) {
38
+ return Array.isArray(value) ? value.map((item) => String(item)) : [];
39
+ }
40
+ function rdapLimitations(rdapParsed) {
41
+ if (rdapParsed.serverTruncated !== true)
42
+ return [];
43
+ const reasons = stringList(rdapParsed.serverTruncationReasons).slice(0, MAX_LIMITATIONS);
44
+ return [
45
+ `The registry reported that some RDAP data was omitted.${reasons.length ? ` ${reasons.join(' · ')}.` : ''}`,
46
+ ];
47
+ }
48
+ function dnsLimitations(dnsEvidence) {
49
+ const limitations = stringList(dnsEvidence.limitations);
50
+ const failures = Object.entries(record(dnsEvidence.diagnostics))
51
+ .slice(0, 16)
52
+ .filter(([, value]) => record(value).status === 'error')
53
+ .map(([name, value]) => `${name.toUpperCase()}: ${record(value).error || 'query failed'}`)
54
+ .join(' · ');
55
+ return failures ? [...limitations, failures] : limitations;
56
+ }
57
+ function normalizeStatus(value) {
58
+ return boundedText(value, 64)
59
+ .toLowerCase()
60
+ .replace(/[\s-]+/gu, '_');
61
+ }
62
+ function normalizeLimitations(value) {
63
+ if (!Array.isArray(value))
64
+ return [];
65
+ const output = [];
66
+ const seen = new Set();
67
+ for (const item of value) {
68
+ const text = boundedText(item, MAX_LIMITATION_LENGTH);
69
+ if (!text || seen.has(text))
70
+ continue;
71
+ output.push(text);
72
+ seen.add(text);
73
+ if (output.length >= MAX_LIMITATIONS)
74
+ break;
75
+ }
76
+ return output;
77
+ }
78
+ function coverageState(input) {
79
+ const status = normalizeStatus(input.status);
80
+ if (status === 'not_found')
81
+ return 'not_found';
82
+ if (SKIPPED_STATES.has(status))
83
+ return 'skipped';
84
+ if (status === 'unsupported')
85
+ return 'unsupported';
86
+ if (UNAVAILABLE_STATES.has(status))
87
+ return 'unavailable';
88
+ if (input.truncated === true || input.complete === false || PARTIAL_STATES.has(status))
89
+ return 'partial';
90
+ if (COMPLETE_STATES.has(status) || (status === '' && input.complete === true))
91
+ return 'complete';
92
+ return 'unknown';
93
+ }
94
+ function statusLabel(state) {
95
+ return {
96
+ complete: 'Complete',
97
+ not_found: 'Not found',
98
+ partial: 'Partial',
99
+ skipped: 'Skipped',
100
+ unavailable: 'Unavailable',
101
+ unknown: 'Unknown',
102
+ unsupported: 'Unsupported',
103
+ }[state];
104
+ }
105
+ export function buildEvidenceCoverageLedger(inputs) {
106
+ const entries = [];
107
+ const seen = new Set();
108
+ for (const input of inputs.slice(0, MAX_ENTRIES)) {
109
+ const id = boundedText(input.id, MAX_ID_LENGTH).toLowerCase().replace(/[^a-z0-9_-]+/gu, '-');
110
+ const label = boundedText(input.label, MAX_LABEL_LENGTH);
111
+ if (!id || !label || seen.has(id))
112
+ continue;
113
+ seen.add(id);
114
+ const state = coverageState(input);
115
+ entries.push({
116
+ id,
117
+ label,
118
+ category: input.category,
119
+ state,
120
+ statusLabel: statusLabel(state),
121
+ truncated: input.truncated === true,
122
+ limitations: normalizeLimitations(input.limitations),
123
+ manualReviewSuggested: state === 'partial' || state === 'unavailable' || state === 'unknown',
124
+ });
125
+ }
126
+ const counts = {
127
+ complete: 0,
128
+ not_found: 0,
129
+ partial: 0,
130
+ skipped: 0,
131
+ unavailable: 0,
132
+ unknown: 0,
133
+ unsupported: 0,
134
+ };
135
+ for (const entry of entries)
136
+ counts[entry.state] += 1;
137
+ return {
138
+ version: 1,
139
+ entries,
140
+ counts,
141
+ completeCount: counts.complete,
142
+ limitedCount: counts.partial + counts.unavailable + counts.unknown,
143
+ };
144
+ }
145
+ export function buildLookupEvidenceCoverageLedger(input) {
146
+ const targetType = boundedText(input.targetType, 20);
147
+ const availability = record(input.availability);
148
+ const diagnostics = record(input.diagnostics);
149
+ const rdapDiagnostic = record(diagnostics.rdap);
150
+ const whoisDiagnostic = record(diagnostics.whois);
151
+ const registrarRdap = record(input.registrarRdap);
152
+ const reverseDns = record(input.reverseDns);
153
+ const observedNetworkContext = record(input.observedNetworkContext);
154
+ const dnsEvidence = record(input.dnsEvidence);
155
+ const httpEvidence = record(input.httpEvidence);
156
+ const httpResponse = record(input.httpResponse);
157
+ const tlsEvidence = record(input.tlsEvidence);
158
+ const pageIdentity = record(input.pageIdentity);
159
+ const pageRoleProfile = record(input.pageRoleProfile);
160
+ const clientBehaviorProfile = record(input.clientBehaviorProfile);
161
+ const technologyProfile = record(input.technologyProfile);
162
+ const securityPosture = record(input.securityPosture);
163
+ const securityTxt = record(input.securityTxt);
164
+ const sslbl = record(input.sslbl);
165
+ const rdapParsed = record(input.rdapParsed);
166
+ const whoisParsed = record(input.whoisParsed);
167
+ const items = [
168
+ {
169
+ id: 'rdap',
170
+ label: targetType === 'domain' ? 'Registry RDAP' : 'RDAP',
171
+ category: 'registry',
172
+ status: rdapDiagnostic.status,
173
+ truncated: rdapParsed.serverTruncated,
174
+ limitations: rdapLimitations(rdapParsed),
175
+ },
176
+ {
177
+ id: 'whois',
178
+ label: 'WHOIS',
179
+ category: 'registry',
180
+ status: whoisDiagnostic.status,
181
+ truncated: Array.isArray(whoisParsed.fieldsTruncated) && whoisParsed.fieldsTruncated.length > 0,
182
+ limitations: stringList(whoisParsed.limitations),
183
+ },
184
+ {
185
+ id: 'availability',
186
+ label: 'Availability decision',
187
+ category: 'analysis',
188
+ status: record(diagnostics.availability).status,
189
+ complete: availability.complete,
190
+ limitations: stringList(availability.limitations),
191
+ },
192
+ ];
193
+ if (registrarRdap.status) {
194
+ items.push({
195
+ id: 'registrar-rdap',
196
+ label: 'Registrar RDAP',
197
+ category: 'registry',
198
+ status: registrarRdap.status,
199
+ limitations: stringList(registrarRdap.limitations),
200
+ });
201
+ }
202
+ if (reverseDns.source === 'reverse_dns' || record(diagnostics.reverseDns).status) {
203
+ items.push({
204
+ id: 'reverse-dns',
205
+ label: 'Reverse DNS',
206
+ category: 'network',
207
+ status: reverseDns.status || record(diagnostics.reverseDns).status,
208
+ complete: reverseDns.complete,
209
+ truncated: reverseDns.truncated,
210
+ limitations: stringList(reverseDns.limitations),
211
+ });
212
+ }
213
+ if (observedNetworkContext.contextVersion === 1) {
214
+ items.push({
215
+ id: 'network-context',
216
+ label: 'Observed network context',
217
+ category: 'network',
218
+ status: observedNetworkContext.status,
219
+ complete: observedNetworkContext.complete,
220
+ limitations: stringList(observedNetworkContext.limitations),
221
+ });
222
+ }
223
+ if (targetType === 'domain') {
224
+ items.push({
225
+ id: 'dns',
226
+ label: 'DNS',
227
+ category: 'network',
228
+ status: dnsEvidence.status,
229
+ complete: dnsEvidence.complete,
230
+ truncated: dnsEvidence.truncated,
231
+ limitations: dnsLimitations(dnsEvidence),
232
+ }, {
233
+ id: 'http',
234
+ label: 'HTTP',
235
+ category: 'web',
236
+ status: httpEvidence.status,
237
+ complete: httpEvidence.complete,
238
+ truncated: httpResponse.bodyTruncated,
239
+ limitations: stringList(httpEvidence.limitations),
240
+ }, {
241
+ id: 'tls',
242
+ label: 'TLS',
243
+ category: 'web',
244
+ status: tlsEvidence.status,
245
+ complete: tlsEvidence.complete,
246
+ truncated: tlsEvidence.chainTruncated,
247
+ limitations: stringList(tlsEvidence.limitations),
248
+ }, {
249
+ id: 'page-identity',
250
+ label: 'Page identity',
251
+ category: 'web',
252
+ status: pageIdentity.status,
253
+ complete: pageIdentity.complete,
254
+ truncated: pageIdentity.truncated,
255
+ limitations: stringList(pageIdentity.limitations),
256
+ }, {
257
+ id: 'technology',
258
+ label: 'Technology indicators',
259
+ category: 'analysis',
260
+ status: technologyProfile.status,
261
+ complete: technologyProfile.complete,
262
+ truncated: technologyProfile.truncated,
263
+ limitations: stringList(technologyProfile.limitations),
264
+ }, {
265
+ id: 'page-role',
266
+ label: 'Page role classification',
267
+ category: 'analysis',
268
+ status: pageRoleProfile.status,
269
+ complete: pageRoleProfile.complete,
270
+ truncated: pageRoleProfile.truncated,
271
+ limitations: stringList(pageRoleProfile.limitations),
272
+ }, {
273
+ id: 'client-behavior',
274
+ label: 'Client-side behaviour indicators',
275
+ category: 'analysis',
276
+ status: clientBehaviorProfile.status,
277
+ complete: clientBehaviorProfile.complete,
278
+ truncated: clientBehaviorProfile.truncated,
279
+ limitations: stringList(clientBehaviorProfile.limitations),
280
+ }, {
281
+ id: 'security-posture',
282
+ label: 'Security posture',
283
+ category: 'analysis',
284
+ status: securityPosture.status,
285
+ complete: securityPosture.complete,
286
+ truncated: securityPosture.truncated,
287
+ limitations: stringList(securityPosture.limitations),
288
+ });
289
+ }
290
+ if (securityTxt.securityTxtVersion === 1) {
291
+ items.push({
292
+ id: 'security-txt',
293
+ label: 'security.txt',
294
+ category: 'web',
295
+ status: securityTxt.state,
296
+ limitations: stringList(securityTxt.limitations),
297
+ });
298
+ }
299
+ if (sslbl.sslblVersion === 1) {
300
+ items.push({
301
+ id: 'sslbl-certificate',
302
+ label: 'SSLBL certificate comparison',
303
+ category: 'external',
304
+ status: sslbl.status,
305
+ complete: sslbl.complete,
306
+ limitations: stringList(sslbl.limitations),
307
+ });
308
+ }
309
+ const providers = Array.isArray(input.threatIntelligenceProviders)
310
+ ? input.threatIntelligenceProviders
311
+ : [];
312
+ for (const [providerIndex, providerValue] of providers.entries()) {
313
+ if (items.length >= MAX_ENTRIES)
314
+ break;
315
+ const provider = record(providerValue);
316
+ const identity = record(provider.provider);
317
+ const fallback = `external-${providerIndex + 1}`;
318
+ const id = boundedText(identity.id || fallback, MAX_ID_LENGTH);
319
+ items.push({
320
+ id: `external-${id}`,
321
+ label: boundedText(identity.label || `External source ${providerIndex + 1}`, MAX_LABEL_LENGTH),
322
+ category: 'external',
323
+ status: provider.state,
324
+ limitations: [boundedText(provider.detail, MAX_LIMITATION_LENGTH)].filter(Boolean),
325
+ });
326
+ }
327
+ return buildEvidenceCoverageLedger(items);
328
+ }