@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
@@ -0,0 +1,369 @@
1
+ import { scaleBand, scaleLinear, scalePoint } from 'd3-scale';
2
+ import { boundedVisualizationId as boundedId, boundedVisualizationNumber as boundedNumber, boundedVisualizationText as boundedText, validVisualizationDate as validDate, } from "./visualization-bounds.js";
3
+ export { FORCE_GRAPH_LINK_KINDS, MAX_FORCE_GRAPH_LINKS, MAX_FORCE_GRAPH_NODES, projectBoundedForceGraph, } from "./visualization-force-graph.js";
4
+ export { MAX_VISUAL_MATRIX_COLUMNS, MAX_VISUAL_MATRIX_ROWS, projectEvidenceMatrix, } from "./visualization-matrix.js";
5
+ export { MAX_COLLECTION_TIMING_SOURCES, MAX_LIFECYCLE_EVENTS, MAX_TREND_POINTS, projectCollectionTiming, projectLifecycleEvents, projectTrendPoints, } from "./visualization-time-series.js";
6
+ export const MAX_REDIRECT_NODES = 9;
7
+ export const MAX_TRIAGE_PLOT_POINTS = 300;
8
+ export const WATCHLIST_ACTIVITY_DAYS = 28;
9
+ export const MAX_SCORE_FACTORS = 16;
10
+ export const MAX_COVERAGE_BAR_GROUPS = 18;
11
+ export const MAX_MONITOR_TIMELINE_EVENTS = 12;
12
+ export const MAX_MONITOR_TIMELINE_LANES = 6;
13
+ function boundedScore(value) {
14
+ if (value === null || value === undefined || value === '')
15
+ return null;
16
+ const score = Number(value);
17
+ return Number.isFinite(score) ? Math.max(0, Math.min(100, score)) : null;
18
+ }
19
+ function redirectLabel(value) {
20
+ const text = boundedText(value, 320);
21
+ try {
22
+ const parsed = new URL(text);
23
+ const path = parsed.pathname === '/' ? '' : parsed.pathname;
24
+ return `${parsed.hostname}${path}`.slice(0, 72);
25
+ }
26
+ catch {
27
+ return text.slice(0, 72);
28
+ }
29
+ }
30
+ export function projectRedirectPath(rawRedirects) {
31
+ const redirects = (Array.isArray(rawRedirects) ? rawRedirects : [])
32
+ .slice(0, MAX_REDIRECT_NODES - 1)
33
+ .map((redirect) => ({
34
+ status: boundedText(redirect?.status, 12) || '—',
35
+ from: redirectLabel(redirect?.from),
36
+ to: redirectLabel(redirect?.to),
37
+ queryOmitted: Boolean(redirect?.queryOmitted),
38
+ }))
39
+ .filter((redirect) => redirect.from && redirect.to);
40
+ const rawCount = Array.isArray(rawRedirects) ? rawRedirects.length : 0;
41
+ if (!redirects.length) {
42
+ return { width: 900, height: 150, nodes: [], edges: [], truncated: rawCount > 0 };
43
+ }
44
+ const firstRedirect = redirects[0];
45
+ if (!firstRedirect) {
46
+ return { width: 900, height: 150, nodes: [], edges: [], truncated: rawCount > 0 };
47
+ }
48
+ const nodes = [
49
+ { id: 'redirect-start', label: firstRedirect.from, status: 'start', queryOmitted: false },
50
+ ...redirects.map((redirect, index) => ({
51
+ id: `redirect-${index + 1}`,
52
+ label: redirect.to,
53
+ status: redirect.status,
54
+ queryOmitted: redirect.queryOmitted,
55
+ })),
56
+ ];
57
+ const x = scalePoint().domain(nodes.map((node) => node.id)).range([62, 838]).padding(0.35);
58
+ const projected = nodes.map((node) => ({ ...node, x: x(node.id) ?? 450, y: 70 }));
59
+ const edges = redirects.flatMap((redirect, index) => {
60
+ const from = projected[index];
61
+ const to = projected[index + 1];
62
+ return from && to ? [{
63
+ id: `redirect-edge-${index}`,
64
+ fromX: from.x,
65
+ toX: to.x,
66
+ y: 70,
67
+ status: redirect.status,
68
+ }] : [];
69
+ });
70
+ return {
71
+ width: 900,
72
+ height: 150,
73
+ nodes: projected,
74
+ edges,
75
+ truncated: rawCount > redirects.length,
76
+ };
77
+ }
78
+ function deterministicSample(items, limit) {
79
+ const boundedLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 0;
80
+ if (boundedLimit === 0)
81
+ return [];
82
+ if (items.length <= boundedLimit)
83
+ return items;
84
+ if (boundedLimit === 1)
85
+ return items.slice(0, 1);
86
+ return Array.from({ length: boundedLimit }, (_, index) => {
87
+ const sourceIndex = Math.round(index * (items.length - 1) / (boundedLimit - 1));
88
+ return items[sourceIndex];
89
+ }).filter((item) => item !== undefined);
90
+ }
91
+ export function projectTriagePoints(rawPoints) {
92
+ const eligible = (Array.isArray(rawPoints) ? rawPoints : [])
93
+ .map((point) => ({
94
+ domain: boundedText(point?.domain, 253).toLowerCase(),
95
+ risk: boundedScore(point?.risk),
96
+ opportunity: boundedScore(point?.opportunity),
97
+ availability: boundedText(point?.availability, 30).toLowerCase() || 'unknown',
98
+ trusted: Boolean(point?.trusted),
99
+ }))
100
+ .filter((point) => point.domain && point.risk !== null && point.opportunity !== null)
101
+ .sort((a, b) => a.domain.localeCompare(b.domain));
102
+ const sampled = deterministicSample(eligible, MAX_TRIAGE_PLOT_POINTS);
103
+ const quadrants = eligible.reduce((counts, point) => {
104
+ if (point.risk >= 50) {
105
+ if (point.opportunity >= 50)
106
+ counts.priorityReview += 1;
107
+ else
108
+ counts.riskLedReview += 1;
109
+ }
110
+ else if (point.opportunity >= 50) {
111
+ counts.availableReview += 1;
112
+ }
113
+ else {
114
+ counts.lowerScores += 1;
115
+ }
116
+ return counts;
117
+ }, {
118
+ availableReview: 0,
119
+ priorityReview: 0,
120
+ lowerScores: 0,
121
+ riskLedReview: 0,
122
+ });
123
+ const x = scaleLinear().domain([0, 100]).range([58, 842]).clamp(true);
124
+ const y = scaleLinear().domain([0, 100]).range([308, 28]).clamp(true);
125
+ const points = sampled.map((point) => ({
126
+ ...point,
127
+ risk: point.risk,
128
+ opportunity: point.opportunity,
129
+ x: x(point.risk),
130
+ y: y(point.opportunity),
131
+ tone: point.trusted
132
+ ? 'trusted'
133
+ : point.availability === 'available'
134
+ ? 'available'
135
+ : point.availability === 'error'
136
+ ? 'error'
137
+ : 'registered',
138
+ }));
139
+ return {
140
+ width: 900,
141
+ height: 360,
142
+ points,
143
+ eligibleCount: eligible.length,
144
+ omittedCount: Math.max(0, (Array.isArray(rawPoints) ? rawPoints.length : 0) - eligible.length),
145
+ sampled: eligible.length > sampled.length,
146
+ quadrants,
147
+ };
148
+ }
149
+ function utcDayKey(milliseconds) {
150
+ return new Date(milliseconds).toISOString().slice(0, 10);
151
+ }
152
+ export function projectWatchlistActivity(rawEvents) {
153
+ const valid = (Array.isArray(rawEvents) ? rawEvents : [])
154
+ .map((event) => {
155
+ const date = validDate(event?.checkedAt);
156
+ if (!date)
157
+ return null;
158
+ const changeCount = Math.max(0, Math.min(10_000, Math.trunc(Number(event.changeCount) || 0)));
159
+ const resultCount = Math.max(0, Math.min(10_000, Math.trunc(Number(event.resultCount) || 0)));
160
+ const conclusiveCount = Math.max(0, Math.min(resultCount, Math.trunc(Number(event.conclusiveCount) || 0)));
161
+ return { ...date, changeCount, resultCount, conclusiveCount };
162
+ })
163
+ .filter((event) => Boolean(event));
164
+ if (!valid.length) {
165
+ return { width: 620, height: 190, days: [], maxChanges: 0, totalChecks: 0, totalChanges: 0 };
166
+ }
167
+ const latest = Math.max(...valid.map((event) => event.milliseconds));
168
+ const latestDay = Date.UTC(new Date(latest).getUTCFullYear(), new Date(latest).getUTCMonth(), new Date(latest).getUTCDate());
169
+ const start = latestDay - (WATCHLIST_ACTIVITY_DAYS - 1) * 86_400_000;
170
+ const windowEnd = latestDay + 86_400_000;
171
+ const windowEvents = valid.filter((event) => event.milliseconds >= start && event.milliseconds < windowEnd);
172
+ const byDay = new Map();
173
+ for (const event of windowEvents) {
174
+ const key = utcDayKey(event.milliseconds);
175
+ const current = byDay.get(key) ?? { checks: 0, changes: 0, results: 0, conclusive: 0 };
176
+ current.checks += 1;
177
+ current.changes += event.changeCount;
178
+ current.results += event.resultCount;
179
+ current.conclusive += event.conclusiveCount;
180
+ byDay.set(key, current);
181
+ }
182
+ const weekScale = scaleBand().domain([0, 1, 2, 3]).range([72, 580]).padding(0.12);
183
+ const dayScale = scaleBand().domain([0, 1, 2, 3, 4, 5, 6]).range([26, 165]).padding(0.12);
184
+ const dayWidth = weekScale.bandwidth();
185
+ const dayHeight = dayScale.bandwidth();
186
+ const days = Array.from({ length: WATCHLIST_ACTIVITY_DAYS }, (_, index) => {
187
+ const milliseconds = start + index * 86_400_000;
188
+ const key = utcDayKey(milliseconds);
189
+ const activity = byDay.get(key) ?? { checks: 0, changes: 0, results: 0, conclusive: 0 };
190
+ return {
191
+ date: key,
192
+ label: new Date(milliseconds).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', timeZone: 'UTC' }),
193
+ week: Math.floor(index / 7),
194
+ weekday: index % 7,
195
+ x: weekScale(Math.floor(index / 7)) ?? 0,
196
+ y: dayScale(index % 7) ?? 0,
197
+ width: dayWidth,
198
+ height: dayHeight,
199
+ ...activity,
200
+ };
201
+ });
202
+ return {
203
+ width: 620,
204
+ height: 190,
205
+ days,
206
+ maxChanges: Math.max(0, ...days.map((day) => day.changes)),
207
+ totalChecks: windowEvents.length,
208
+ totalChanges: windowEvents.reduce((sum, event) => sum + event.changeCount, 0),
209
+ };
210
+ }
211
+ export function projectScoreFactors(rawFactors) {
212
+ const candidates = (Array.isArray(rawFactors) ? rawFactors : [])
213
+ .map((factor, index) => ({
214
+ id: `${boundedId(factor?.label) || 'factor'}-${index}`,
215
+ label: boundedText(factor?.label, 56) || `Factor ${index + 1}`,
216
+ delta: boundedNumber(factor?.delta, -100, 100),
217
+ }));
218
+ const factors = candidates.slice(0, MAX_SCORE_FACTORS);
219
+ const maximum = Math.max(1, ...factors.map((factor) => Math.abs(factor.delta)));
220
+ const x = scaleLinear().domain([-maximum, maximum]).range([220, 860]).clamp(true);
221
+ const zeroX = x(0);
222
+ const rowHeight = 28;
223
+ return {
224
+ width: 900,
225
+ height: Math.max(74, 42 + factors.length * rowHeight),
226
+ zeroX,
227
+ maximum,
228
+ factors: factors.map((factor, index) => {
229
+ const valueX = x(factor.delta);
230
+ return {
231
+ ...factor,
232
+ x: Math.min(zeroX, valueX),
233
+ width: factor.delta === 0 ? 0 : Math.max(2, Math.abs(valueX - zeroX)),
234
+ y: 24 + index * rowHeight,
235
+ };
236
+ }),
237
+ truncated: candidates.length > factors.length,
238
+ };
239
+ }
240
+ export function projectCoverageBars(rawGroups) {
241
+ const candidates = (Array.isArray(rawGroups) ? rawGroups : [])
242
+ .map((group, index) => {
243
+ const values = {
244
+ protected: Math.trunc(boundedNumber(group?.protected, 0, 100_000)),
245
+ registered: Math.trunc(boundedNumber(group?.registered, 0, 100_000)),
246
+ available: Math.trunc(boundedNumber(group?.available, 0, 100_000)),
247
+ unknown: Math.trunc(boundedNumber(group?.unknown, 0, 100_000)),
248
+ };
249
+ return {
250
+ id: boundedId(group?.id) || `group-${index}`,
251
+ label: boundedText(group?.label, 56) || `Group ${index + 1}`,
252
+ ...values,
253
+ total: Object.values(values).reduce((sum, value) => sum + value, 0),
254
+ };
255
+ })
256
+ .filter((group) => group.total > 0)
257
+ .sort((a, b) => b.total - a.total || a.label.localeCompare(b.label));
258
+ const groups = candidates.slice(0, MAX_COVERAGE_BAR_GROUPS);
259
+ const maximum = Math.max(1, ...groups.map((group) => group.total));
260
+ const x = scaleLinear().domain([0, maximum]).range([210, 860]).clamp(true);
261
+ const rowHeight = 31;
262
+ return {
263
+ width: 900,
264
+ height: Math.max(76, 40 + groups.length * rowHeight),
265
+ groups: groups.map((group, index) => {
266
+ let offset = 0;
267
+ const segments = ['protected', 'registered', 'available', 'unknown'].map((state) => {
268
+ const value = group[state];
269
+ const start = offset;
270
+ offset += value;
271
+ return {
272
+ state,
273
+ value,
274
+ x: x(start),
275
+ width: Math.max(value ? 1 : 0, x(offset) - x(start)),
276
+ };
277
+ });
278
+ return { ...group, y: 20 + index * rowHeight, segments };
279
+ }),
280
+ truncated: candidates.length > groups.length,
281
+ };
282
+ }
283
+ export function projectMonitorTimeline(rawEvents) {
284
+ const candidates = (Array.isArray(rawEvents) ? rawEvents : [])
285
+ .map((event, index) => {
286
+ const date = validDate(event?.checkedAt);
287
+ const id = boundedId(event?.id) || `event-${index}`;
288
+ if (!date)
289
+ return null;
290
+ return {
291
+ id,
292
+ checkedAt: date.text,
293
+ milliseconds: date.milliseconds,
294
+ mode: boundedText(event?.mode, 20).toLowerCase() || 'saved',
295
+ groups: (Array.isArray(event.groups) ? event.groups : [])
296
+ .map((group) => ({
297
+ key: boundedId(group?.key),
298
+ label: boundedText(group?.label, 40),
299
+ changeCount: Math.trunc(boundedNumber(group?.changeCount, 0, 10_000)),
300
+ }))
301
+ .filter((group) => group.key && group.label),
302
+ };
303
+ })
304
+ .filter((event) => Boolean(event))
305
+ .sort((a, b) => a.milliseconds - b.milliseconds || a.id.localeCompare(b.id));
306
+ const events = candidates.slice(-MAX_MONITOR_TIMELINE_EVENTS);
307
+ const laneMap = new Map();
308
+ for (const event of events) {
309
+ for (const group of event.groups) {
310
+ if (!laneMap.has(group.key) && laneMap.size < MAX_MONITOR_TIMELINE_LANES) {
311
+ laneMap.set(group.key, group.label);
312
+ }
313
+ }
314
+ }
315
+ const lanes = [...laneMap].map(([key, label]) => ({ key, label }));
316
+ const x = scaleBand().domain(events.map((event) => event.id)).range([170, 865]).padding(0.18);
317
+ const y = scaleBand().domain(lanes.map((lane) => lane.key)).range([42, 42 + lanes.length * 40]).padding(0.18);
318
+ const maxChanges = Math.max(1, ...events.flatMap((event) => event.groups.map((group) => group.changeCount)));
319
+ return {
320
+ width: 900,
321
+ height: Math.max(110, 64 + lanes.length * 40),
322
+ maxChanges,
323
+ events: events.map((event) => ({
324
+ ...event,
325
+ x: x(event.id) ?? 170,
326
+ width: x.bandwidth(),
327
+ cells: lanes.map((lane) => ({
328
+ key: lane.key,
329
+ label: lane.label,
330
+ count: event.groups.find((group) => group.key === lane.key)?.changeCount ?? 0,
331
+ y: y(lane.key) ?? 42,
332
+ height: y.bandwidth(),
333
+ })),
334
+ })),
335
+ lanes: lanes.map((lane) => ({ ...lane, y: y(lane.key) ?? 42, height: y.bandwidth() })),
336
+ truncated: candidates.length > events.length
337
+ || events.some((event) => event.groups.some((group) => !laneMap.has(group.key))),
338
+ };
339
+ }
340
+ export function projectCertificateValidity(input) {
341
+ const validFrom = validDate(input.validFrom);
342
+ const validTo = validDate(input.validTo);
343
+ const observed = validDate(input.observedAt);
344
+ if (!validFrom || !validTo || validTo.milliseconds <= validFrom.milliseconds) {
345
+ return { available: false, width: 900, height: 110 };
346
+ }
347
+ const observedMilliseconds = observed?.milliseconds ?? validFrom.milliseconds;
348
+ const span = validTo.milliseconds - validFrom.milliseconds;
349
+ const padding = Math.max(86_400_000, span * 0.08);
350
+ const x = scaleLinear()
351
+ .domain([validFrom.milliseconds - padding, validTo.milliseconds + padding])
352
+ .range([72, 828])
353
+ .clamp(true);
354
+ return {
355
+ available: true,
356
+ width: 900,
357
+ height: 110,
358
+ validFrom: validFrom.text,
359
+ validTo: validTo.text,
360
+ observedAt: observed?.text ?? '',
361
+ hasObservation: Boolean(observed),
362
+ fromX: x(validFrom.milliseconds),
363
+ toX: x(validTo.milliseconds),
364
+ observedX: x(observedMilliseconds),
365
+ observedWithinValidity: observedMilliseconds >= validFrom.milliseconds
366
+ && observedMilliseconds <= validTo.milliseconds,
367
+ elapsedPercent: Math.max(0, Math.min(100, ((observedMilliseconds - validFrom.milliseconds) / span) * 100)),
368
+ };
369
+ }
@@ -0,0 +1,144 @@
1
+ import { scaleLinear, scalePoint } from 'd3-scale';
2
+ import { boundedVisualizationId as boundedId, boundedVisualizationNumber as boundedNumber, boundedVisualizationText as boundedText, validVisualizationDate as validDate, } from "./visualization-bounds.js";
3
+ export const MAX_LIFECYCLE_EVENTS = 8;
4
+ export const MAX_COLLECTION_TIMING_SOURCES = 16;
5
+ export const MAX_TREND_POINTS = 24;
6
+ export function projectLifecycleEvents(rawEvents) {
7
+ const seen = new Set();
8
+ const candidates = (Array.isArray(rawEvents) ? rawEvents : [])
9
+ .map((event) => {
10
+ const id = boundedId(event?.id);
11
+ const label = boundedText(event?.label, 40);
12
+ const date = validDate(event?.date);
13
+ if (!id || !label || !date)
14
+ return null;
15
+ const kind = event.kind === 'certificate' || event.kind === 'observation'
16
+ ? event.kind
17
+ : 'registry';
18
+ return {
19
+ id,
20
+ label,
21
+ date: date.text,
22
+ milliseconds: date.milliseconds,
23
+ detail: boundedText(event.detail, 100),
24
+ kind,
25
+ };
26
+ })
27
+ .filter((event) => Boolean(event))
28
+ .sort((a, b) => a.milliseconds - b.milliseconds || a.id.localeCompare(b.id))
29
+ .filter((event) => {
30
+ if (seen.has(event.id))
31
+ return false;
32
+ seen.add(event.id);
33
+ return true;
34
+ });
35
+ const accepted = candidates.slice(0, MAX_LIFECYCLE_EVENTS);
36
+ const x = scalePoint()
37
+ .domain(accepted.map((event) => event.id))
38
+ .range([70, 830])
39
+ .padding(accepted.length > 1 ? 0.35 : 0.5);
40
+ const laneY = [48, 158, 27];
41
+ const events = accepted.map((event, index) => ({
42
+ ...event,
43
+ x: x(event.id) ?? 450,
44
+ labelY: laneY[index % laneY.length] ?? 102,
45
+ anchor: index === 0 ? 'start' : index === accepted.length - 1 ? 'end' : 'middle',
46
+ }));
47
+ return {
48
+ width: 900,
49
+ height: 205,
50
+ axisY: 102,
51
+ events,
52
+ truncated: candidates.length > accepted.length,
53
+ };
54
+ }
55
+ export function projectCollectionTiming(rawSources, rawTotalMs) {
56
+ const candidates = (Array.isArray(rawSources) ? rawSources : [])
57
+ .map((source, index) => {
58
+ const id = boundedId(source?.source) || `source-${index}`;
59
+ const durationMs = boundedNumber(source?.durationMs, 0, 300_000);
60
+ const completedAfterMs = boundedNumber(source?.completedAfterMs, 0, 300_000);
61
+ return {
62
+ id,
63
+ label: boundedText(source?.source, 48) || `Source ${index + 1}`,
64
+ outcome: boundedText(source?.outcome, 24).toLowerCase() || 'unknown',
65
+ durationMs,
66
+ completedAfterMs,
67
+ startedAfterMs: Math.max(0, completedAfterMs - durationMs),
68
+ };
69
+ })
70
+ .sort((a, b) => a.completedAfterMs - b.completedAfterMs || a.id.localeCompare(b.id));
71
+ const sources = candidates.slice(0, MAX_COLLECTION_TIMING_SOURCES);
72
+ const longestSource = [...sources]
73
+ .sort((a, b) => b.durationMs - a.durationMs
74
+ || b.completedAfterMs - a.completedAfterMs
75
+ || a.id.localeCompare(b.id))[0] ?? null;
76
+ const lastSettledSource = sources.at(-1) ?? null;
77
+ const requestErrorCount = sources.filter((source) => source.outcome === 'rejected').length;
78
+ const totalMs = Math.max(1, boundedNumber(rawTotalMs, 0, 300_000), ...sources.map((source) => source.completedAfterMs));
79
+ const x = scaleLinear().domain([0, totalMs]).range([190, 860]).clamp(true);
80
+ const rowHeight = 30;
81
+ return {
82
+ width: 900,
83
+ height: Math.max(92, 50 + sources.length * rowHeight),
84
+ totalMs,
85
+ longestSource,
86
+ lastSettledSource,
87
+ requestErrorCount,
88
+ ticks: x.ticks(5).map((value) => ({ value, x: x(value) })),
89
+ sources: sources.map((source, index) => ({
90
+ ...source,
91
+ x: x(source.startedAfterMs),
92
+ width: Math.max(3, x(source.completedAfterMs) - x(source.startedAfterMs)),
93
+ y: 30 + index * rowHeight,
94
+ })),
95
+ truncated: candidates.length > sources.length,
96
+ };
97
+ }
98
+ export function projectTrendPoints(rawPoints) {
99
+ const seen = new Set();
100
+ const candidates = (Array.isArray(rawPoints) ? rawPoints : [])
101
+ .map((point, index) => {
102
+ const date = validDate(point?.date);
103
+ const id = boundedId(point?.id) || `point-${index}`;
104
+ if (!date || seen.has(id))
105
+ return null;
106
+ seen.add(id);
107
+ return {
108
+ id,
109
+ date: date.text,
110
+ milliseconds: date.milliseconds,
111
+ total: Math.trunc(boundedNumber(point?.total, 0, 100_000)),
112
+ added: Math.trunc(boundedNumber(point?.added, 0, 100_000)),
113
+ partial: Boolean(point?.partial),
114
+ };
115
+ })
116
+ .filter((point) => Boolean(point))
117
+ .sort((a, b) => a.milliseconds - b.milliseconds || a.id.localeCompare(b.id));
118
+ const points = candidates.slice(-MAX_TREND_POINTS);
119
+ const x = scalePoint().domain(points.map((point) => point.id)).range([64, 850]).padding(0.45);
120
+ const maximum = Math.max(1, ...points.map((point) => point.total));
121
+ const y = scaleLinear().domain([0, maximum]).range([190, 24]).nice().clamp(true);
122
+ const first = points[0] ?? null;
123
+ const latest = points.at(-1) ?? null;
124
+ return {
125
+ width: 900,
126
+ height: 225,
127
+ maximum,
128
+ ticks: y.ticks(4).map((value) => ({ value, y: y(value) })),
129
+ points: points.map((point) => ({
130
+ ...point,
131
+ x: x(point.id) ?? 450,
132
+ y: y(point.total),
133
+ addedY: y(point.added),
134
+ })),
135
+ summary: {
136
+ firstTotal: first?.total ?? 0,
137
+ latestTotal: latest?.total ?? 0,
138
+ peakTotal: points.reduce((peak, point) => Math.max(peak, point.total), 0),
139
+ newlyObserved: points.reduce((total, point) => total + point.added, 0),
140
+ partialChecks: points.filter((point) => point.partial).length,
141
+ },
142
+ truncated: candidates.length > points.length,
143
+ };
144
+ }
@@ -137,7 +137,7 @@ function validateEnvelope(raw) {
137
137
  throw new Error('The encrypted workspace archive declares an unsupported cipher contract.');
138
138
  }
139
139
  const salt = decodeBase64url(kdf.salt, SALT_BYTES, 'salt');
140
- const iv = decodeBase64url(cipher.iv, IV_BYTES, 'initialization vector');
140
+ const iv = decodeBase64url(cipher.iv, IV_BYTES, 'initialisation vector');
141
141
  if (typeof value.ciphertext !== 'string' || value.ciphertext.length > MAX_CIPHERTEXT_BASE64URL_CHARACTERS) {
142
142
  throw new Error('The encrypted workspace archive ciphertext exceeds its byte limit.');
143
143
  }
@@ -48,7 +48,7 @@ function record(value) {
48
48
  function byteLength(value) {
49
49
  return new TextEncoder().encode(value).byteLength;
50
50
  }
51
- function serialize(value, message = 'The workspace archive contains data that cannot be serialized.') {
51
+ function serialize(value, message = 'The workspace archive contains data that cannot be serialised.') {
52
52
  try {
53
53
  const serialized = JSON.stringify(value);
54
54
  if (typeof serialized !== 'string')
@@ -340,6 +340,7 @@ export async function readWorkspaceArchive(raw, options = {}) {
340
340
  }
341
341
  throw new Error(`Expected workspace archive schema 1, 2, 3, 4, or ${WORKSPACE_ARCHIVE_VERSION}.`);
342
342
  }
343
+ const sourceVersion = value.version;
343
344
  const { bytes } = ensureArchiveBudget(value);
344
345
  const manifest = record(value.manifest);
345
346
  const sectionValues = record(value.sections);
@@ -370,7 +371,7 @@ export async function readWorkspaceArchive(raw, options = {}) {
370
371
  let reason = '';
371
372
  if (!definition) {
372
373
  status = 'unsupported';
373
- reason = 'This app does not recognize the archive section.';
374
+ reason = 'This app does not recognise the archive section.';
374
375
  }
375
376
  else if (entry.version !== definition.version || entry.schema !== definition.schema) {
376
377
  status = 'unsupported';
@@ -394,6 +395,7 @@ export async function readWorkspaceArchive(raw, options = {}) {
394
395
  return {
395
396
  schema: WORKSPACE_ARCHIVE_SCHEMA,
396
397
  version: WORKSPACE_ARCHIVE_VERSION,
398
+ sourceVersion,
397
399
  generatedAt: timestamp(value.generatedAt),
398
400
  bytes,
399
401
  sections,
@@ -16,4 +16,4 @@ function abusechAuthKey(env = process.env) {
16
16
  return normalizeAbusechAuthKey(source.ABUSECH_AUTH_KEY)
17
17
  || normalizeAbusechAuthKey(source.URLHAUS_AUTH_KEY);
18
18
  }
19
- export { MAX_ABUSECH_AUTH_KEY_LENGTH, normalizeAbusechAuthKey, abusechAuthKey, };
19
+ export { abusechAuthKey, };
@@ -0,0 +1,40 @@
1
+ // Deliberately small interoperability projection over reviewed MISP
2
+ // taxonomies. WHOISleuth retains its own bounded workflow vocabulary and
3
+ // derives machine tags only when an analyst-owned disposition and reason make
4
+ // the mapping unambiguous. These tags never alter collection or scoring.
5
+ const ANALYST_REVIEW_REASONS = Object.freeze([
6
+ Object.freeze({ value: '', label: 'Not recorded' }),
7
+ Object.freeze({ value: 'authorized_or_owned', label: 'Authorised or owned domain' }),
8
+ Object.freeze({ value: 'shared_infrastructure', label: 'Shared infrastructure explained the signal' }),
9
+ Object.freeze({ value: 'generic_platform_or_template', label: 'Generic platform or template' }),
10
+ Object.freeze({ value: 'parked_or_reseller', label: 'Parking or reseller page' }),
11
+ Object.freeze({ value: 'insufficient_evidence', label: 'Insufficient evidence' }),
12
+ Object.freeze({ value: 'legitimate_third_party', label: 'Legitimate third party' }),
13
+ Object.freeze({ value: 'confirmed_credential_abuse', label: 'Confirmed credential abuse' }),
14
+ Object.freeze({ value: 'confirmed_malware', label: 'Confirmed malware' }),
15
+ Object.freeze({ value: 'other_reviewed', label: 'Other reviewed reason' }),
16
+ ]);
17
+ const ANALYST_REVIEW_REASON_VALUES = new Set(ANALYST_REVIEW_REASONS.map(({ value }) => value).filter(Boolean));
18
+ const MISP_TAXONOMY_REFERENCES = Object.freeze([
19
+ Object.freeze({ namespace: 'domain-abuse', version: 2, sourceSha256: '5fcfb44742daac97061726faaaadd2ce6b7eac55306acaae3d30c3882d18f991', sourceUrl: 'https://github.com/MISP/misp-taxonomies/blob/main/domain-abuse/machinetag.json' }),
20
+ Object.freeze({ namespace: 'estimative-language', version: 5, sourceSha256: '1010927d396f66f7cc58a229ebb8af6ad2cdc0250ad5f8ce3b1bd9aacf5ab4da', sourceUrl: 'https://github.com/MISP/misp-taxonomies/blob/main/estimative-language/machinetag.json' }),
21
+ Object.freeze({ namespace: 'event-assessment', version: 2, sourceSha256: '39291bdd50e1b2975a93e33e396f42b398178015580a84402bc3d8d549dd721e', sourceUrl: 'https://github.com/MISP/misp-taxonomies/blob/main/event-assessment/machinetag.json' }),
22
+ Object.freeze({ namespace: 'false-positive', version: 7, sourceSha256: 'ffc8d35ae36eb1d39f33611d1090793021f2bfe3b4b31ae41631e22cf544a2fb', sourceUrl: 'https://github.com/MISP/misp-taxonomies/blob/main/false-positive/machinetag.json' }),
23
+ Object.freeze({ namespace: 'phishing', version: 5, sourceSha256: '8628ebe4d5935216a7b006a4d975669c4ae1a554ef8ed78ffaadd62f763d7263', sourceUrl: 'https://github.com/MISP/misp-taxonomies/blob/main/phishing/machinetag.json' }),
24
+ ]);
25
+ function analystInteroperabilityTags(disposition, reviewReasonCode) {
26
+ const tags = new Set();
27
+ if (disposition === 'false_positive')
28
+ tags.add('false-positive:confirmed="true"');
29
+ if ((disposition === 'false_positive' || disposition === 'expected')
30
+ && ['authorized_or_owned', 'shared_infrastructure', 'generic_platform_or_template', 'parked_or_reseller', 'legitimate_third_party'].includes(String(reviewReasonCode)))
31
+ tags.add('false-positive:risk="high"');
32
+ if (reviewReasonCode === 'insufficient_evidence') {
33
+ tags.add('estimative-language:confidence-in-analytic-judgment="low"');
34
+ }
35
+ if (disposition === 'confirmed_abuse' && reviewReasonCode === 'confirmed_credential_abuse') {
36
+ tags.add('phishing:techniques="fake-website"');
37
+ }
38
+ return [...tags].sort();
39
+ }
40
+ export { ANALYST_REVIEW_REASONS, ANALYST_REVIEW_REASON_VALUES, MISP_TAXONOMY_REFERENCES, analystInteroperabilityTags, };
@@ -237,7 +237,7 @@ async function fetchHomepage(domain, { fetcher = safeFetchDetailed } = {}) {
237
237
  technologyHeaders: {
238
238
  'cf-ray': res.headers.get('cf-ray') || '',
239
239
  'x-drupal-cache': res.headers.get('x-drupal-cache') || '',
240
- 'x-fastly-request-id': res.headers.get('x-fastly-request-id') || '',
240
+ 'x-served-by': res.headers.get('x-served-by') || '',
241
241
  'x-powered-by': res.headers.get('x-powered-by') || '',
242
242
  'x-shopify-stage': res.headers.get('x-shopify-stage') || '',
243
243
  'x-sorting-hat-podid': res.headers.get('x-sorting-hat-podid') || '',
@@ -309,7 +309,7 @@ function registryPolicyDetail(domain, fast) {
309
309
  const whoisAdmission = fast ? null : registryServiceAdmissionFor(domain, 'whois');
310
310
  if (whoisAdmission && !whoisAdmission.allowed) {
311
311
  details.push(whoisAdmission.state === 'permission_required'
312
- ? 'WHOIS was not queried because registry permission or source authorization is required.'
312
+ ? 'WHOIS was not queried because registry permission or source authorisation is required.'
313
313
  : 'WHOIS was not queried because IANA publishes no domain WHOIS service for this suffix.');
314
314
  }
315
315
  return details.length ? ` ${details.join(' ')}` : '';
@@ -1,9 +1,16 @@
1
1
  // Shared strict primitives for internal evidence contracts. These helpers do
2
2
  // not infer missing values and keep every string, object, enum, and counter
3
3
  // bounded before a provider or connector result reaches its public model.
4
+ import { domainToASCII } from 'node:url';
4
5
  export function isRecord(value) {
5
6
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
6
7
  }
8
+ export function recordOrNull(value) {
9
+ return isRecord(value) ? value : null;
10
+ }
11
+ export function recordOrEmpty(value) {
12
+ return isRecord(value) ? value : {};
13
+ }
7
14
  export function boundedString(value, maxLength) {
8
15
  if (typeof value !== 'string' || /[\u0000-\u001f\u007f]/u.test(value))
9
16
  return null;
@@ -59,3 +66,29 @@ export function boundedInteger(value, minimum, maximum, label) {
59
66
  }
60
67
  return value;
61
68
  }
69
+ export function requireRecord(value, label) {
70
+ if (!isRecord(value))
71
+ throw new TypeError(`${label} must be an object.`);
72
+ return value;
73
+ }
74
+ export function requireBoundedString(value, label, maxLength) {
75
+ const normalised = strictBoundedString(value, maxLength);
76
+ if (!normalised) {
77
+ throw new TypeError(`${label} must contain from 1 to ${maxLength} characters without control characters.`);
78
+ }
79
+ return normalised;
80
+ }
81
+ export function requireIsoTimestamp(value, label) {
82
+ const normalised = isoTimestamp(value);
83
+ if (!normalised)
84
+ throw new TypeError(`${label} must be a valid timestamp.`);
85
+ return normalised;
86
+ }
87
+ export function requireDomainName(value, label, noun = 'domain name') {
88
+ const supplied = requireBoundedString(value, label, 253).toLowerCase().replace(/\.$/u, '');
89
+ const ascii = domainToASCII(supplied);
90
+ if (!ascii || !ascii.includes('.') || ascii.length > 253 || ascii.split('.').some((part) => (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(part)))) {
91
+ throw new TypeError(`${label} must be a valid ${noun}.`);
92
+ }
93
+ return ascii;
94
+ }