@slicedearth/whoisleuth-cli 1.34.0 → 1.39.1

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 (131) hide show
  1. package/README.md +19 -2
  2. package/bin/whoisleuth.mjs +41 -24
  3. package/cli/arguments.mjs +326 -3
  4. package/cli/artifact-verify.mjs +60 -14
  5. package/cli/bulk.mjs +1 -2
  6. package/cli/compare.mjs +1 -1
  7. package/cli/completion.mjs +36 -2
  8. package/cli/doctor.mjs +11 -5
  9. package/cli/evidence-command-arguments.mjs +1 -1
  10. package/cli/evidence-command-runner.mjs +2 -2
  11. package/cli/evidence-signing.mjs +4 -4
  12. package/cli/export-evidence.mjs +9 -2
  13. package/cli/formatters/html.mjs +1 -1
  14. package/cli/formatters/json.mjs +13 -1
  15. package/cli/formatters/terminal.mjs +14 -4
  16. package/cli/investigation-plan.mjs +112 -0
  17. package/cli/local-mmdb-review.mjs +76 -0
  18. package/cli/lookalike-calibration.mjs +121 -0
  19. package/cli/lookup-command-runner.mjs +4 -1
  20. package/cli/lookup-plan.mjs +3 -3
  21. package/cli/lookup-reconcile.mjs +141 -0
  22. package/cli/lookup-timeline.mjs +1 -1
  23. package/cli/mail-review.mjs +48 -3
  24. package/cli/offline-evidence-review.mjs +159 -0
  25. package/cli/registry-doctor.mjs +125 -0
  26. package/cli/registry-support.mjs +1 -4
  27. package/cli/risk-calibration.mjs +117 -9
  28. package/cli/runner.mjs +342 -15
  29. package/cli/saved-lookup.mjs +2 -2
  30. package/cli/sharing-review.mjs +202 -0
  31. package/cli/source-reliability.mjs +1 -1
  32. package/docs/cli-reference.md +1110 -0
  33. package/docs/cli.md +224 -847
  34. package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
  35. package/frontend/src/lib/analysis/acquisition-due-diligence.js +6 -6
  36. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
  37. package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
  38. package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
  39. package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
  40. package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
  41. package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
  42. package/frontend/src/lib/analysis/case-migration-model.js +6 -1
  43. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  44. package/frontend/src/lib/analysis/case-record-core.js +5 -1
  45. package/frontend/src/lib/analysis/case-record-operations.js +6 -1
  46. package/frontend/src/lib/analysis/case-response-packet.js +7 -7
  47. package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
  48. package/frontend/src/lib/analysis/http-summary.js +2 -1
  49. package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
  50. package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
  51. package/frontend/src/lib/analysis/lookup-decision-support.js +480 -0
  52. package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
  53. package/frontend/src/lib/analysis/lookup-presentation.js +74 -0
  54. package/frontend/src/lib/analysis/lookup-response.js +2 -0
  55. package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
  56. package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
  57. package/frontend/src/lib/analysis/page-similarity.js +1 -1
  58. package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
  59. package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
  60. package/frontend/src/lib/analysis/scoring.js +8 -88
  61. package/frontend/src/lib/analysis/shortlist-model.js +8 -4
  62. package/frontend/src/lib/analysis/utils.js +7 -40
  63. package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
  64. package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
  65. package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
  66. package/frontend/src/lib/analysis/visualization-models.js +370 -0
  67. package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
  68. package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
  69. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
  70. package/frontend/src/lib/analysis/workspace-archive.js +2 -2
  71. package/lib/analyst-taxonomy.mjs +40 -0
  72. package/lib/availability.mjs +30 -10
  73. package/lib/browser-library-profile.mjs +15 -2
  74. package/lib/bulk-limits.mjs +13 -0
  75. package/lib/certificate-extension-profile.mjs +71 -0
  76. package/lib/credential-surface-profile.mjs +1 -1
  77. package/lib/ct-response-bounds.mjs +9 -0
  78. package/lib/ct-search.mjs +66 -12
  79. package/lib/dns-delegation-health.mjs +56 -5
  80. package/lib/dnssec-evidence-validation.mjs +235 -0
  81. package/lib/domain-assurance.mjs +368 -0
  82. package/lib/domain-control-manifest.mjs +382 -0
  83. package/lib/domain-posture.mjs +11 -10
  84. package/lib/encrypted-dns-contract.mjs +81 -0
  85. package/lib/evidence-export.mjs +17 -3
  86. package/lib/evidence-report-markdown.mjs +1 -1
  87. package/lib/evidence-report.mjs +4 -1
  88. package/lib/favicon.mjs +3 -8
  89. package/lib/generated/cisa-kev-catalog.mjs +1668 -0
  90. package/lib/html-signals.mjs +4 -0
  91. package/lib/http-evidence-bounds.mjs +4 -0
  92. package/lib/http-intelligence.mjs +2 -1
  93. package/lib/ip-prefix.mjs +87 -0
  94. package/lib/local-geoip-evidence.mjs +89 -0
  95. package/lib/lookup-response-contract.mjs +346 -0
  96. package/lib/observed-network-context.mjs +1 -1
  97. package/lib/opportunity-scoring.mjs +107 -0
  98. package/lib/outbound-identity.mjs +9 -0
  99. package/lib/outbound-request-bounds.mjs +9 -0
  100. package/lib/page-fingerprints.mjs +1 -1
  101. package/lib/perceptual-hash-comparison.mjs +34 -0
  102. package/lib/perceptual-hash.mjs +3 -31
  103. package/lib/project-metadata.mjs +8 -0
  104. package/lib/provider-policy-admission.mjs +49 -0
  105. package/lib/rdap-capabilities.mjs +6 -6
  106. package/lib/rdap-search-workbench.mjs +247 -0
  107. package/lib/rdap.mjs +7 -2
  108. package/lib/registry-access.mjs +1 -1
  109. package/lib/registry-capabilities.mjs +31 -1
  110. package/lib/registry-capability-catalogue.mjs +15 -14
  111. package/lib/registry-insights.mjs +6 -6
  112. package/lib/response-policy.mjs +81 -6
  113. package/lib/risk-scoring.mjs +206 -83
  114. package/lib/rpki-evidence.mjs +105 -0
  115. package/lib/safe-fetch.mjs +2 -1
  116. package/lib/scoring-evidence-quality.mjs +107 -0
  117. package/lib/security-txt.mjs +3 -3
  118. package/lib/semantic-version.mjs +54 -0
  119. package/lib/static-html-analysis.mjs +27 -0
  120. package/lib/threat-intelligence-manifests.mjs +1 -1
  121. package/lib/threatfox-intelligence.mjs +11 -6
  122. package/lib/tls-intelligence.mjs +13 -3
  123. package/lib/tlsa-evidence.mjs +194 -0
  124. package/lib/urlhaus-intelligence.mjs +11 -6
  125. package/lib/urlscan-intelligence.mjs +10 -5
  126. package/lib/website-security-posture.mjs +16 -9
  127. package/lib/website-technology.mjs +1 -1
  128. package/lib/whois-chain.mjs +14 -152
  129. package/lib/whois.mjs +2 -2
  130. package/package.json +6 -2
  131. package/third-party-notices.txt +911 -19
@@ -0,0 +1,370 @@
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
+ .filter((factor) => factor.delta !== 0);
219
+ const factors = candidates.slice(0, MAX_SCORE_FACTORS);
220
+ const maximum = Math.max(1, ...factors.map((factor) => Math.abs(factor.delta)));
221
+ const x = scaleLinear().domain([-maximum, maximum]).range([220, 860]).clamp(true);
222
+ const zeroX = x(0);
223
+ const rowHeight = 28;
224
+ return {
225
+ width: 900,
226
+ height: Math.max(74, 42 + factors.length * rowHeight),
227
+ zeroX,
228
+ maximum,
229
+ factors: factors.map((factor, index) => {
230
+ const valueX = x(factor.delta);
231
+ return {
232
+ ...factor,
233
+ x: Math.min(zeroX, valueX),
234
+ width: Math.max(2, Math.abs(valueX - zeroX)),
235
+ y: 24 + index * rowHeight,
236
+ };
237
+ }),
238
+ truncated: candidates.length > factors.length,
239
+ };
240
+ }
241
+ export function projectCoverageBars(rawGroups) {
242
+ const candidates = (Array.isArray(rawGroups) ? rawGroups : [])
243
+ .map((group, index) => {
244
+ const values = {
245
+ protected: Math.trunc(boundedNumber(group?.protected, 0, 100_000)),
246
+ registered: Math.trunc(boundedNumber(group?.registered, 0, 100_000)),
247
+ available: Math.trunc(boundedNumber(group?.available, 0, 100_000)),
248
+ unknown: Math.trunc(boundedNumber(group?.unknown, 0, 100_000)),
249
+ };
250
+ return {
251
+ id: boundedId(group?.id) || `group-${index}`,
252
+ label: boundedText(group?.label, 56) || `Group ${index + 1}`,
253
+ ...values,
254
+ total: Object.values(values).reduce((sum, value) => sum + value, 0),
255
+ };
256
+ })
257
+ .filter((group) => group.total > 0)
258
+ .sort((a, b) => b.total - a.total || a.label.localeCompare(b.label));
259
+ const groups = candidates.slice(0, MAX_COVERAGE_BAR_GROUPS);
260
+ const maximum = Math.max(1, ...groups.map((group) => group.total));
261
+ const x = scaleLinear().domain([0, maximum]).range([210, 860]).clamp(true);
262
+ const rowHeight = 31;
263
+ return {
264
+ width: 900,
265
+ height: Math.max(76, 40 + groups.length * rowHeight),
266
+ groups: groups.map((group, index) => {
267
+ let offset = 0;
268
+ const segments = ['protected', 'registered', 'available', 'unknown'].map((state) => {
269
+ const value = group[state];
270
+ const start = offset;
271
+ offset += value;
272
+ return {
273
+ state,
274
+ value,
275
+ x: x(start),
276
+ width: Math.max(value ? 1 : 0, x(offset) - x(start)),
277
+ };
278
+ });
279
+ return { ...group, y: 20 + index * rowHeight, segments };
280
+ }),
281
+ truncated: candidates.length > groups.length,
282
+ };
283
+ }
284
+ export function projectMonitorTimeline(rawEvents) {
285
+ const candidates = (Array.isArray(rawEvents) ? rawEvents : [])
286
+ .map((event, index) => {
287
+ const date = validDate(event?.checkedAt);
288
+ const id = boundedId(event?.id) || `event-${index}`;
289
+ if (!date)
290
+ return null;
291
+ return {
292
+ id,
293
+ checkedAt: date.text,
294
+ milliseconds: date.milliseconds,
295
+ mode: boundedText(event?.mode, 20).toLowerCase() || 'saved',
296
+ groups: (Array.isArray(event.groups) ? event.groups : [])
297
+ .map((group) => ({
298
+ key: boundedId(group?.key),
299
+ label: boundedText(group?.label, 40),
300
+ changeCount: Math.trunc(boundedNumber(group?.changeCount, 0, 10_000)),
301
+ }))
302
+ .filter((group) => group.key && group.label),
303
+ };
304
+ })
305
+ .filter((event) => Boolean(event))
306
+ .sort((a, b) => a.milliseconds - b.milliseconds || a.id.localeCompare(b.id));
307
+ const events = candidates.slice(-MAX_MONITOR_TIMELINE_EVENTS);
308
+ const laneMap = new Map();
309
+ for (const event of events) {
310
+ for (const group of event.groups) {
311
+ if (!laneMap.has(group.key) && laneMap.size < MAX_MONITOR_TIMELINE_LANES) {
312
+ laneMap.set(group.key, group.label);
313
+ }
314
+ }
315
+ }
316
+ const lanes = [...laneMap].map(([key, label]) => ({ key, label }));
317
+ const x = scaleBand().domain(events.map((event) => event.id)).range([170, 865]).padding(0.18);
318
+ const y = scaleBand().domain(lanes.map((lane) => lane.key)).range([42, 42 + lanes.length * 40]).padding(0.18);
319
+ const maxChanges = Math.max(1, ...events.flatMap((event) => event.groups.map((group) => group.changeCount)));
320
+ return {
321
+ width: 900,
322
+ height: Math.max(110, 64 + lanes.length * 40),
323
+ maxChanges,
324
+ events: events.map((event) => ({
325
+ ...event,
326
+ x: x(event.id) ?? 170,
327
+ width: x.bandwidth(),
328
+ cells: lanes.map((lane) => ({
329
+ key: lane.key,
330
+ label: lane.label,
331
+ count: event.groups.find((group) => group.key === lane.key)?.changeCount ?? 0,
332
+ y: y(lane.key) ?? 42,
333
+ height: y.bandwidth(),
334
+ })),
335
+ })),
336
+ lanes: lanes.map((lane) => ({ ...lane, y: y(lane.key) ?? 42, height: y.bandwidth() })),
337
+ truncated: candidates.length > events.length
338
+ || events.some((event) => event.groups.some((group) => !laneMap.has(group.key))),
339
+ };
340
+ }
341
+ export function projectCertificateValidity(input) {
342
+ const validFrom = validDate(input.validFrom);
343
+ const validTo = validDate(input.validTo);
344
+ const observed = validDate(input.observedAt);
345
+ if (!validFrom || !validTo || validTo.milliseconds <= validFrom.milliseconds) {
346
+ return { available: false, width: 900, height: 110 };
347
+ }
348
+ const observedMilliseconds = observed?.milliseconds ?? validFrom.milliseconds;
349
+ const span = validTo.milliseconds - validFrom.milliseconds;
350
+ const padding = Math.max(86_400_000, span * 0.08);
351
+ const x = scaleLinear()
352
+ .domain([validFrom.milliseconds - padding, validTo.milliseconds + padding])
353
+ .range([72, 828])
354
+ .clamp(true);
355
+ return {
356
+ available: true,
357
+ width: 900,
358
+ height: 110,
359
+ validFrom: validFrom.text,
360
+ validTo: validTo.text,
361
+ observedAt: observed?.text ?? '',
362
+ hasObservation: Boolean(observed),
363
+ fromX: x(validFrom.milliseconds),
364
+ toX: x(validTo.milliseconds),
365
+ observedX: x(observedMilliseconds),
366
+ observedWithinValidity: observedMilliseconds >= validFrom.milliseconds
367
+ && observedMilliseconds <= validTo.milliseconds,
368
+ elapsedPercent: Math.max(0, Math.min(100, ((observedMilliseconds - validFrom.milliseconds) / span) * 100)),
369
+ };
370
+ }
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  import { normalizeDomain } from "./case-model.js";
2
2
  export const WEBSITE_SNAPSHOT_SCHEMA = 'whoisleuth.website-profile-snapshots';
3
- export const WEBSITE_SNAPSHOT_SCHEMA_VERSION = 3;
3
+ export const WEBSITE_SNAPSHOT_SCHEMA_VERSION = 4;
4
4
  export const MAX_WEBSITE_SNAPSHOTS = 60;
5
5
  export const MAX_WEBSITE_SNAPSHOTS_PER_DOMAIN = 12;
6
6
  export const MAX_WEBSITE_SNAPSHOT_STORE_BYTES = 512 * 1024;
@@ -65,6 +65,23 @@ function source(value) {
65
65
  const state = text(item?.state, 40);
66
66
  return sourceName && state ? { source: sourceName, state } : null;
67
67
  }
68
+ function dependency(value) {
69
+ const item = record(value);
70
+ const recordType = text(item?.recordType, 10);
71
+ const target = normalizeDomain(item?.target);
72
+ const state = text(item?.state, 40);
73
+ if (!target
74
+ || !['CNAME', 'HTTPS', 'NS', 'MX', 'HTTP'].includes(recordType)
75
+ || !['candidate', 'unresolved', 'active', 'unsupported', 'false_positive'].includes(state))
76
+ return null;
77
+ return {
78
+ recordType: recordType,
79
+ target,
80
+ state: state,
81
+ qualification: text(item?.qualification, 80) || 'inconclusive',
82
+ serviceFamily: nullableText(item?.serviceFamily, 120),
83
+ };
84
+ }
68
85
  function nullableBoolean(value) {
69
86
  return typeof value === 'boolean' ? value : null;
70
87
  }
@@ -161,6 +178,7 @@ export function normalizeWebsiteProfileSnapshot(raw) {
161
178
  identity: identity(value?.identity),
162
179
  identityValues: identityValues(value?.identityValues),
163
180
  sources: values(value?.sources, 16, source),
181
+ dependencies: values(value?.dependencies, 20, dependency),
164
182
  certificate: certificate(value?.certificate),
165
183
  };
166
184
  }
@@ -242,13 +260,49 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
242
260
  const before = normalizeWebsiteProfileSnapshot(beforeRaw);
243
261
  const after = normalizeWebsiteProfileSnapshot(afterRaw);
244
262
  if (!before || !after || before.domain !== after.domain) {
245
- return { compatible: false, changes: [{ field: 'snapshot', state: 'incomparable', before: before?.domain ?? null, after: after?.domain ?? null }] };
263
+ return {
264
+ compatible: false,
265
+ changes: [{ field: 'snapshot', state: 'incomparable', before: before?.domain ?? null, after: after?.domain ?? null }],
266
+ dependencyTransitions: [],
267
+ };
246
268
  }
247
269
  const changes = [
248
270
  ...compareMap('technology', new Map(before.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}`])), new Map(after.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}`]))),
249
271
  ...compareMap('posture', new Map(before.posture.map((item) => [item.id, item.state])), new Map(after.posture.map((item) => [item.id, item.state]))),
250
272
  ...compareMap('source', new Map(before.sources.map((item) => [item.source, item.state])), new Map(after.sources.map((item) => [item.source, item.state]))),
273
+ ...compareMap('dependency', new Map(before.dependencies.map((item) => [`${item.recordType}:${item.target}`, `${item.state}|${item.qualification}`])), new Map(after.dependencies.map((item) => [`${item.recordType}:${item.target}`, `${item.state}|${item.qualification}`]))),
251
274
  ];
275
+ const dependencyTransitions = [];
276
+ const beforeDependencies = new Map(before.dependencies.map((item) => [`${item.recordType}:${item.target}`, item]));
277
+ const afterDependencies = new Map(after.dependencies.map((item) => [`${item.recordType}:${item.target}`, item]));
278
+ for (const key of [...new Set([...beforeDependencies.keys(), ...afterDependencies.keys()])].sort()) {
279
+ const left = beforeDependencies.get(key);
280
+ const right = afterDependencies.get(key);
281
+ if (left?.state === 'active' && right?.qualification === 'known_deprovision_pattern') {
282
+ dependencyTransitions.push({
283
+ target: right.target,
284
+ recordType: right.recordType,
285
+ state: 'active_to_deprovision_cue',
286
+ detail: 'An earlier active navigation target now matches a reviewed passive deprovision-page cue. Verify the service account and DNS manually; this does not establish claimability.',
287
+ });
288
+ }
289
+ else if (left?.state === 'active' && right?.state === 'unresolved') {
290
+ dependencyTransitions.push({
291
+ target: right.target,
292
+ recordType: right.recordType,
293
+ state: 'active_to_unresolved',
294
+ detail: 'An earlier active dependency is unresolved in the later retained observation. Resolver or collection failure remains possible.',
295
+ });
296
+ }
297
+ else if (!left && right) {
298
+ dependencyTransitions.push({ target: right.target, recordType: right.recordType, state: 'added', detail: 'This dependency first appears in the later retained snapshot.' });
299
+ }
300
+ else if (left && !right && after.complete) {
301
+ dependencyTransitions.push({ target: left.target, recordType: left.recordType, state: 'removed', detail: 'This dependency is not represented in the later complete snapshot. It may have been intentionally removed or replaced.' });
302
+ }
303
+ if (dependencyTransitions.length >= 20)
304
+ break;
305
+ }
252
306
  for (const key of Object.keys(before.identity)) {
253
307
  const left = before.identity[key];
254
308
  const right = after.identity[key];
@@ -298,5 +352,5 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
298
352
  if (before.complete !== after.complete || before.truncated !== after.truncated) {
299
353
  changes.push({ field: 'completeness', state: 'incomparable', before: `${before.complete}/${before.truncated}`, after: `${after.complete}/${after.truncated}` });
300
354
  }
301
- return { compatible: true, changes };
355
+ return { compatible: true, changes, dependencyTransitions };
302
356
  }
@@ -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')
@@ -370,7 +370,7 @@ export async function readWorkspaceArchive(raw, options = {}) {
370
370
  let reason = '';
371
371
  if (!definition) {
372
372
  status = 'unsupported';
373
- reason = 'This app does not recognize the archive section.';
373
+ reason = 'This app does not recognise the archive section.';
374
374
  }
375
375
  else if (entry.version !== definition.version || entry.schema !== definition.schema) {
376
376
  status = 'unsupported';