@slicedearth/whoisleuth-cli 1.45.0 → 1.47.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +59 -6
  4. package/bin/whoisleuth.mjs +5 -3
  5. package/cli/arguments.mjs +256 -9
  6. package/cli/artifact-structure.mjs +1833 -0
  7. package/cli/artifact-verify.mjs +242 -49
  8. package/cli/bounded-json.mjs +1 -0
  9. package/cli/bulk-checkpoint.mjs +2 -3
  10. package/cli/bulk-command-runner.mjs +2 -5
  11. package/cli/case-pack.mjs +819 -34
  12. package/cli/command-reference.mjs +48 -24
  13. package/cli/completion.mjs +16 -8
  14. package/cli/ct-event-intake.mjs +32 -2
  15. package/cli/discovery-scan-command-runner.mjs +4 -5
  16. package/cli/discovery-workflow.mjs +2 -3
  17. package/cli/doctor.mjs +2 -2
  18. package/cli/domain-control-monitor.mjs +21 -15
  19. package/cli/domain-control-observations.mjs +25 -2
  20. package/cli/evidence-command-runner.mjs +17 -11
  21. package/cli/evidence-signing.mjs +73 -45
  22. package/cli/export-evidence.mjs +11 -4
  23. package/cli/formatters/html.mjs +3 -3
  24. package/cli/formatters/terminal.mjs +3 -0
  25. package/cli/input.mjs +57 -0
  26. package/cli/interchange-report.mjs +226 -0
  27. package/cli/investigation-manifest.mjs +16 -17
  28. package/cli/investigation-run.mjs +71 -23
  29. package/cli/local-mmdb-review.mjs +45 -14
  30. package/cli/lookup-command-runner.mjs +2 -2
  31. package/cli/lookup-diff.mjs +9 -0
  32. package/cli/network-command-runner.mjs +78 -0
  33. package/cli/offline-evidence-review.mjs +28 -8
  34. package/cli/page-compare.mjs +8 -5
  35. package/cli/registry-cohort.mjs +385 -61
  36. package/cli/registry-support.mjs +2 -1
  37. package/cli/retained-artifact-diff.mjs +376 -0
  38. package/cli/risk-calibration.mjs +7 -5
  39. package/cli/runner.mjs +147 -185
  40. package/cli/saved-lookup.mjs +26 -3
  41. package/cli/sharing-review.mjs +12 -6
  42. package/docs/cli-reference.md +444 -70
  43. package/docs/cli.md +24 -5
  44. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  45. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  46. package/frontend/src/lib/analysis/brand-profile-model.js +29 -5
  47. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  48. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  49. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  50. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  51. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  52. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  53. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  54. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  55. package/frontend/src/lib/analysis/case-evidence-model.js +8 -0
  56. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  57. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  58. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  59. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  60. package/frontend/src/lib/analysis/case-record-operations.js +42 -12
  61. package/frontend/src/lib/analysis/case-report.js +39 -6
  62. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  63. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  64. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  65. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  66. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  67. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  68. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  69. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  70. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  71. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  72. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  73. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  74. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  75. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  76. package/frontend/src/lib/analysis/lookup-decision-support.js +61 -6
  77. package/frontend/src/lib/analysis/lookup-presentation.js +62 -0
  78. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  79. package/frontend/src/lib/analysis/utils.js +26 -3
  80. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  81. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  82. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  83. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  84. package/frontend/src/lib/analysis/workspace-archive.js +104 -14
  85. package/lib/application-version.mjs +3 -0
  86. package/lib/availability.mjs +14 -12
  87. package/lib/bounded-file.mjs +13 -0
  88. package/lib/bounded-json-response.mjs +17 -4
  89. package/lib/bounded-json.mjs +249 -0
  90. package/lib/certificate-extension-profile.mjs +2 -1
  91. package/lib/cryptographic-assurance.mjs +155 -0
  92. package/lib/ct-search.mjs +87 -47
  93. package/lib/dns-intelligence.mjs +3 -2
  94. package/lib/dns-resolver-selection.mjs +6 -4
  95. package/lib/dnssec-chain-validation.mjs +1053 -0
  96. package/lib/dnssec-evidence-validation.mjs +10 -3
  97. package/lib/domain-change-packet.mjs +7 -5
  98. package/lib/domain-control-manifest.mjs +17 -129
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/evidence-export.mjs +751 -78
  101. package/lib/evidence-report-markdown.mjs +3 -0
  102. package/lib/evidence-report.mjs +5 -0
  103. package/lib/favicon.mjs +8 -5
  104. package/lib/http-evidence-bounds.mjs +3 -0
  105. package/lib/http-intelligence.mjs +2 -5
  106. package/lib/interchange-fidelity-registry.mjs +122 -0
  107. package/lib/local-geoip-evidence.mjs +2 -1
  108. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  109. package/lib/lookup-response-contract.mjs +691 -3
  110. package/lib/lookup.mjs +2 -2
  111. package/lib/observation.mjs +7 -6
  112. package/lib/outbound-identity.mjs +2 -3
  113. package/lib/perceptual-hash.mjs +12 -4
  114. package/lib/portable-generator.mjs +21 -0
  115. package/lib/provider-policy-admission.mjs +9 -4
  116. package/lib/rdap-normalization.mjs +18 -5
  117. package/lib/rdap-registrar.mjs +5 -1
  118. package/lib/registry-capabilities.mjs +3 -0
  119. package/lib/registry-capability-catalogue.mjs +12 -2
  120. package/lib/registry-comparison.mjs +28 -11
  121. package/lib/risk-calibration-summary.mjs +338 -0
  122. package/lib/rpki-evidence.mjs +9 -8
  123. package/lib/safe-fetch.mjs +59 -10
  124. package/lib/security-txt.mjs +26 -3
  125. package/lib/service-binding-dns.mjs +6 -1
  126. package/lib/smtp-transport-review.mjs +784 -0
  127. package/lib/sslbl-certificates.generated.mjs +136 -134
  128. package/lib/threat-intelligence-runtime.mjs +3 -11
  129. package/lib/threat-intelligence-types.mjs +18 -1
  130. package/lib/tls-intelligence.mjs +11 -5
  131. package/lib/tlsa-evidence.mjs +37 -7
  132. package/package.json +1 -1
  133. package/third-party-notices.txt +95 -5817
@@ -2,7 +2,14 @@ import { scaleBand } from 'd3-scale';
2
2
  import { boundedVisualizationId as boundedId, boundedVisualizationText as boundedText, } from "./visualization-bounds.js";
3
3
  export const MAX_VISUAL_MATRIX_ROWS = 24;
4
4
  export const MAX_VISUAL_MATRIX_COLUMNS = 6;
5
- function normalizedMatrixState(value) {
5
+ // A row is one source-pair lane, so it can publish at most one cell for each
6
+ // allowed column. This keeps output at 24 × 6 = 144 cells and prevents hostile
7
+ // duplicate inputs from creating sub-pixel marks or unbounded cell arrays.
8
+ export const MAX_VISUAL_MATRIX_CELLS = MAX_VISUAL_MATRIX_ROWS * MAX_VISUAL_MATRIX_COLUMNS;
9
+ const MAX_VISUAL_MATRIX_COLUMN_INPUTS = MAX_VISUAL_MATRIX_COLUMNS * 4;
10
+ const MAX_VISUAL_MATRIX_ROW_INPUTS = MAX_VISUAL_MATRIX_ROWS * 4;
11
+ const MAX_VISUAL_MATRIX_CELL_INPUTS_PER_ROW = MAX_VISUAL_MATRIX_COLUMNS * 4;
12
+ function normalizedMatrixTone(value) {
6
13
  const state = boundedText(value, 30).toLowerCase();
7
14
  if (state === 'equal' || state === 'equivalent' || state === 'same')
8
15
  return 'equal';
@@ -22,45 +29,79 @@ function normalizedMatrixState(value) {
22
29
  return 'unknown';
23
30
  }
24
31
  export function projectEvidenceMatrix(rawColumns, rawRows) {
25
- const columns = [...new Set((Array.isArray(rawColumns) ? rawColumns : [])
26
- .map((column) => boundedText(column, 40))
27
- .filter(Boolean))]
28
- .slice(0, MAX_VISUAL_MATRIX_COLUMNS);
32
+ const columnInputs = Array.isArray(rawColumns) ? rawColumns : [];
33
+ const columns = [];
34
+ const seenColumns = new Set();
35
+ for (const rawColumn of columnInputs.slice(0, MAX_VISUAL_MATRIX_COLUMN_INPUTS)) {
36
+ const column = boundedText(rawColumn, 40);
37
+ if (!column || seenColumns.has(column) || columns.length >= MAX_VISUAL_MATRIX_COLUMNS)
38
+ continue;
39
+ seenColumns.add(column);
40
+ columns.push(column);
41
+ }
29
42
  const columnSet = new Set(columns);
30
43
  const seenRows = new Set();
31
- const candidates = (Array.isArray(rawRows) ? rawRows : [])
32
- .map((row, index) => {
44
+ const rowInputs = Array.isArray(rawRows) ? rawRows : [];
45
+ const rows = [];
46
+ let discardedCellInputs = 0;
47
+ const scannedRows = rowInputs.slice(0, MAX_VISUAL_MATRIX_ROW_INPUTS);
48
+ for (const [index, row] of scannedRows.entries()) {
49
+ if (rows.length >= MAX_VISUAL_MATRIX_ROWS)
50
+ break;
33
51
  const id = boundedId(row?.id) || `row-${index}`;
34
52
  const label = boundedText(row?.label, 56);
35
53
  if (!label || seenRows.has(id))
36
- return null;
54
+ continue;
37
55
  seenRows.add(id);
38
56
  const rowCells = (Array.isArray(row.cells) ? row.cells : []);
39
- const byColumn = new Map(rowCells
40
- .map((cell) => {
57
+ const scannedCells = rowCells.slice(0, MAX_VISUAL_MATRIX_CELL_INPUTS_PER_ROW);
58
+ discardedCellInputs += Math.max(0, rowCells.length - scannedCells.length);
59
+ const byColumn = new Map();
60
+ for (const cell of scannedCells) {
41
61
  const column = boundedText(cell?.column, 40);
42
- return [column, {
43
- state: normalizedMatrixState(cell?.state),
44
- detail: boundedText(cell?.detail, 120),
62
+ if (!columnSet.has(column) || byColumn.has(column)) {
63
+ discardedCellInputs += 1;
64
+ continue;
65
+ }
66
+ const exactState = boundedText(cell?.state, 40).toLowerCase() || 'unknown';
67
+ byColumn.set(column, {
68
+ column,
69
+ state: exactState,
70
+ tone: normalizedMatrixTone(cell?.tone ?? exactState),
71
+ detail: boundedText(cell?.detail, 320),
72
+ });
73
+ }
74
+ const cells = columns.flatMap((column) => {
75
+ const value = byColumn.get(column);
76
+ if (value)
77
+ return [value];
78
+ if (row.sparse === true)
79
+ return [];
80
+ return [{
81
+ column,
82
+ state: 'not_collected',
83
+ tone: 'not_collected',
84
+ detail: '',
45
85
  }];
46
- })
47
- .filter(([column]) => columnSet.has(column)));
48
- return {
86
+ });
87
+ rows.push({
49
88
  id,
50
89
  label,
51
- cells: columns.map((column) => ({
52
- column,
53
- state: byColumn.get(column)?.state ?? 'not_collected',
54
- detail: byColumn.get(column)?.detail ?? '',
55
- })),
56
- };
57
- })
58
- .filter((row) => Boolean(row));
59
- const rows = candidates.slice(0, MAX_VISUAL_MATRIX_ROWS);
90
+ context: boundedText(row.context, 90),
91
+ status: boundedText(row.status, 40).toLowerCase() || 'unknown',
92
+ assessment: boundedText(row.assessment, 80),
93
+ cells,
94
+ });
95
+ }
96
+ const discarded = {
97
+ columnInputs: Math.max(0, columnInputs.length - columns.length),
98
+ rowInputs: Math.max(0, rowInputs.length - rows.length),
99
+ cellInputs: discardedCellInputs,
100
+ };
60
101
  const width = 900;
61
102
  const top = 54;
62
- const left = 210;
63
- const rowHeight = 30;
103
+ const left = 250;
104
+ const rowHeight = 40;
64
105
  const x = scaleBand().domain(columns).range([left, 870]).padding(0.12);
65
106
  const y = scaleBand().domain(rows.map((row) => row.id))
66
107
  .range([top, top + Math.max(1, rows.length) * rowHeight])
@@ -83,6 +124,8 @@ export function projectEvidenceMatrix(rawColumns, rawRows) {
83
124
  width: x.bandwidth(),
84
125
  })),
85
126
  })),
86
- truncated: candidates.length > rows.length || rawColumns.length > columns.length,
127
+ discarded,
128
+ projectedCellCount: rows.reduce((sum, row) => sum + row.cells.length, 0),
129
+ truncated: discarded.columnInputs > 0 || discarded.rowInputs > 0 || discarded.cellInputs > 0,
87
130
  };
88
131
  }
@@ -1,21 +1,15 @@
1
1
  import { scaleBand, scaleLinear, scalePoint } from 'd3-scale';
2
2
  import { boundedVisualizationId as boundedId, boundedVisualizationNumber as boundedNumber, boundedVisualizationText as boundedText, validVisualizationDate as validDate, } from "./visualization-bounds.js";
3
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";
4
+ export { MAX_VISUAL_MATRIX_CELLS, MAX_VISUAL_MATRIX_COLUMNS, MAX_VISUAL_MATRIX_ROWS, projectEvidenceMatrix, } from "./visualization-matrix.js";
5
5
  export { MAX_COLLECTION_TIMING_SOURCES, MAX_LIFECYCLE_EVENTS, MAX_TREND_POINTS, projectCollectionTiming, projectLifecycleEvents, projectTrendPoints, } from "./visualization-time-series.js";
6
6
  export const MAX_REDIRECT_NODES = 9;
7
- export const MAX_TRIAGE_PLOT_POINTS = 300;
8
7
  export const WATCHLIST_ACTIVITY_DAYS = 28;
9
8
  export const MAX_SCORE_FACTORS = 16;
10
- export const MAX_COVERAGE_BAR_GROUPS = 18;
9
+ export const MAX_SCORE_FACTOR_LABEL_LENGTH = 160;
10
+ export const MAX_PROFILE_LISTING_BAR_GROUPS = 18;
11
11
  export const MAX_MONITOR_TIMELINE_EVENTS = 12;
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
13
  function redirectLabel(value) {
20
14
  const text = boundedText(value, 320);
21
15
  try {
@@ -75,80 +69,21 @@ export function projectRedirectPath(rawRedirects) {
75
69
  truncated: rawCount > redirects.length,
76
70
  };
77
71
  }
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
72
  function utcDayKey(milliseconds) {
150
73
  return new Date(milliseconds).toISOString().slice(0, 10);
151
74
  }
75
+ function utcWeekLabel(startMilliseconds, endMilliseconds) {
76
+ const start = new Date(startMilliseconds);
77
+ const end = new Date(endMilliseconds);
78
+ const startDay = start.getUTCDate();
79
+ const endDay = end.getUTCDate();
80
+ const startMonth = start.toLocaleDateString('en-AU', { month: 'short', timeZone: 'UTC' });
81
+ const endMonth = end.toLocaleDateString('en-AU', { month: 'short', timeZone: 'UTC' });
82
+ const endYear = end.getUTCFullYear();
83
+ return startMonth === endMonth
84
+ ? `${startDay}–${endDay} ${endMonth} ${endYear}`
85
+ : `${startDay} ${startMonth}–${endDay} ${endMonth} ${endYear}`;
86
+ }
152
87
  export function projectWatchlistActivity(rawEvents) {
153
88
  const valid = (Array.isArray(rawEvents) ? rawEvents : [])
154
89
  .map((event) => {
@@ -162,7 +97,19 @@ export function projectWatchlistActivity(rawEvents) {
162
97
  })
163
98
  .filter((event) => Boolean(event));
164
99
  if (!valid.length) {
165
- return { width: 620, height: 190, days: [], maxChanges: 0, totalChecks: 0, totalChanges: 0 };
100
+ return {
101
+ width: 660,
102
+ height: 235,
103
+ days: [],
104
+ weekdayLabels: [],
105
+ weekLabels: [],
106
+ timeBasis: 'UTC calendar days',
107
+ windowStart: null,
108
+ windowEnd: null,
109
+ maxChanges: 0,
110
+ totalChecks: 0,
111
+ totalChanges: 0,
112
+ };
166
113
  }
167
114
  const latest = Math.max(...valid.map((event) => event.milliseconds));
168
115
  const latestDay = Date.UTC(new Date(latest).getUTCFullYear(), new Date(latest).getUTCMonth(), new Date(latest).getUTCDate());
@@ -179,8 +126,8 @@ export function projectWatchlistActivity(rawEvents) {
179
126
  current.conclusive += event.conclusiveCount;
180
127
  byDay.set(key, current);
181
128
  }
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);
129
+ const weekScale = scaleBand().domain([0, 1, 2, 3]).range([92, 638]).padding(0.12);
130
+ const dayScale = scaleBand().domain([0, 1, 2, 3, 4, 5, 6]).range([42, 190]).padding(0.12);
184
131
  const dayWidth = weekScale.bandwidth();
185
132
  const dayHeight = dayScale.bandwidth();
186
133
  const days = Array.from({ length: WATCHLIST_ACTIVITY_DAYS }, (_, index) => {
@@ -199,10 +146,31 @@ export function projectWatchlistActivity(rawEvents) {
199
146
  ...activity,
200
147
  };
201
148
  });
149
+ const weekdayLabels = Array.from({ length: 7 }, (_, index) => ({
150
+ index,
151
+ label: new Date(start + index * 86_400_000).toLocaleDateString('en-AU', { weekday: 'short', timeZone: 'UTC' }),
152
+ y: (dayScale(index) ?? 0) + dayHeight / 2 + 3,
153
+ }));
154
+ const weekLabels = Array.from({ length: 4 }, (_, index) => {
155
+ const weekStart = start + index * 7 * 86_400_000;
156
+ const weekEnd = weekStart + 6 * 86_400_000;
157
+ return {
158
+ index,
159
+ label: utcWeekLabel(weekStart, weekEnd),
160
+ startDate: utcDayKey(weekStart),
161
+ endDate: utcDayKey(weekEnd),
162
+ x: (weekScale(index) ?? 0) + dayWidth / 2,
163
+ };
164
+ });
202
165
  return {
203
- width: 620,
204
- height: 190,
166
+ width: 660,
167
+ height: 235,
205
168
  days,
169
+ weekdayLabels,
170
+ weekLabels,
171
+ timeBasis: 'UTC calendar days',
172
+ windowStart: utcDayKey(start),
173
+ windowEnd: utcDayKey(latestDay),
206
174
  maxChanges: Math.max(0, ...days.map((day) => day.changes)),
207
175
  totalChecks: windowEvents.length,
208
176
  totalChanges: windowEvents.reduce((sum, event) => sum + event.changeCount, 0),
@@ -212,7 +180,7 @@ export function projectScoreFactors(rawFactors) {
212
180
  const candidates = (Array.isArray(rawFactors) ? rawFactors : [])
213
181
  .map((factor, index) => ({
214
182
  id: `${boundedId(factor?.label) || 'factor'}-${index}`,
215
- label: boundedText(factor?.label, 56) || `Factor ${index + 1}`,
183
+ label: boundedText(factor?.label, MAX_SCORE_FACTOR_LABEL_LENGTH) || `Factor ${index + 1}`,
216
184
  delta: boundedNumber(factor?.delta, -100, 100),
217
185
  }));
218
186
  const factors = candidates.slice(0, MAX_SCORE_FACTORS);
@@ -237,34 +205,35 @@ export function projectScoreFactors(rawFactors) {
237
205
  truncated: candidates.length > factors.length,
238
206
  };
239
207
  }
240
- export function projectCoverageBars(rawGroups) {
208
+ export function projectProfileListingBars(rawGroups) {
241
209
  const candidates = (Array.isArray(rawGroups) ? rawGroups : [])
242
210
  .map((group, index) => {
243
- const values = {
244
- protected: Math.trunc(boundedNumber(group?.protected, 0, 100_000)),
211
+ const outcomes = {
245
212
  registered: Math.trunc(boundedNumber(group?.registered, 0, 100_000)),
246
213
  available: Math.trunc(boundedNumber(group?.available, 0, 100_000)),
247
214
  unknown: Math.trunc(boundedNumber(group?.unknown, 0, 100_000)),
248
215
  };
216
+ const total = Object.values(outcomes).reduce((sum, value) => sum + value, 0);
249
217
  return {
250
218
  id: boundedId(group?.id) || `group-${index}`,
251
219
  label: boundedText(group?.label, 56) || `Group ${index + 1}`,
252
- ...values,
253
- total: Object.values(values).reduce((sum, value) => sum + value, 0),
220
+ ...outcomes,
221
+ total,
222
+ profileListed: Math.min(total, Math.trunc(boundedNumber(group?.profileListed, 0, 100_000))),
254
223
  };
255
224
  })
256
225
  .filter((group) => group.total > 0)
257
226
  .sort((a, b) => b.total - a.total || a.label.localeCompare(b.label));
258
- const groups = candidates.slice(0, MAX_COVERAGE_BAR_GROUPS);
227
+ const groups = candidates.slice(0, MAX_PROFILE_LISTING_BAR_GROUPS);
259
228
  const maximum = Math.max(1, ...groups.map((group) => group.total));
260
229
  const x = scaleLinear().domain([0, maximum]).range([210, 860]).clamp(true);
261
- const rowHeight = 31;
230
+ const rowHeight = 40;
262
231
  return {
263
232
  width: 900,
264
233
  height: Math.max(76, 40 + groups.length * rowHeight),
265
234
  groups: groups.map((group, index) => {
266
235
  let offset = 0;
267
- const segments = ['protected', 'registered', 'available', 'unknown'].map((state) => {
236
+ const segments = ['registered', 'available', 'unknown'].map((state) => {
268
237
  const value = group[state];
269
238
  const start = offset;
270
239
  offset += value;
@@ -116,28 +116,69 @@ export function projectTrendPoints(rawPoints) {
116
116
  .filter((point) => Boolean(point))
117
117
  .sort((a, b) => a.milliseconds - b.milliseconds || a.id.localeCompare(b.id));
118
118
  const points = candidates.slice(-MAX_TREND_POINTS);
119
- const x = scalePoint().domain(points.map((point) => point.id)).range([64, 850]).padding(0.45);
119
+ const firstMilliseconds = points[0]?.milliseconds ?? 0;
120
+ const latestMilliseconds = points.at(-1)?.milliseconds ?? firstMilliseconds;
121
+ const x = scaleLinear()
122
+ .domain(firstMilliseconds === latestMilliseconds
123
+ ? [firstMilliseconds - 1, latestMilliseconds + 1]
124
+ : [firstMilliseconds, latestMilliseconds])
125
+ .range([64, 850])
126
+ .clamp(true);
120
127
  const maximum = Math.max(1, ...points.map((point) => point.total));
121
128
  const y = scaleLinear().domain([0, maximum]).range([190, 24]).nice().clamp(true);
122
129
  const first = points[0] ?? null;
123
130
  const latest = points.at(-1) ?? null;
131
+ const projectedPoints = points.map((point, sequenceIndex) => ({
132
+ ...point,
133
+ sequenceIndex,
134
+ x: points.length > 1 ? x(point.milliseconds) : 450,
135
+ y: y(point.total),
136
+ addedY: y(point.added),
137
+ }));
138
+ // A capped point is a lower bound, so neither adjacent slope is measured.
139
+ // Retain only directly adjacent complete-to-complete segments and leave a
140
+ // visible gap around every capped point.
141
+ const segments = projectedPoints.slice(1).flatMap((to, index) => {
142
+ const from = projectedPoints[index];
143
+ return from && !from.partial && !to.partial ? [{
144
+ fromId: from.id,
145
+ toId: to.id,
146
+ x1: from.x,
147
+ y1: from.y,
148
+ x2: to.x,
149
+ y2: to.y,
150
+ }] : [];
151
+ });
152
+ const partialChecks = points.filter((point) => point.partial).length;
153
+ const hasPartialChecks = partialChecks > 0;
154
+ const summaryMetric = (value, lowerBound) => ({
155
+ value,
156
+ lowerBound,
157
+ label: lowerBound ? `At least ${value}` : String(value),
158
+ });
159
+ const peakTotal = points.reduce((peak, point) => Math.max(peak, point.total), 0);
160
+ const newlyObserved = points.reduce((total, point) => total + point.added, 0);
124
161
  return {
125
162
  width: 900,
126
163
  height: 225,
127
164
  maximum,
165
+ spacing: 'elapsed_time',
166
+ elapsed: {
167
+ firstAt: points[0]?.date ?? null,
168
+ latestAt: points.at(-1)?.date ?? null,
169
+ milliseconds: Math.max(0, latestMilliseconds - firstMilliseconds),
170
+ },
128
171
  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
- })),
172
+ points: projectedPoints,
173
+ segments,
135
174
  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,
175
+ first: summaryMetric(first?.total ?? 0, Boolean(first?.partial)),
176
+ latest: summaryMetric(latest?.total ?? 0, Boolean(latest?.partial)),
177
+ // Any capped total can exceed the displayed observed maximum.
178
+ peak: summaryMetric(peakTotal, hasPartialChecks),
179
+ // Every capped new-count is itself a lower bound, including observed 0.
180
+ newlyObserved: summaryMetric(newlyObserved, hasPartialChecks),
181
+ partialChecks,
141
182
  },
142
183
  truncated: candidates.length > points.length,
143
184
  };
@@ -1,6 +1,7 @@
1
1
  import { normalizeDomain } from "./case-model.js";
2
2
  export const WEBSITE_SNAPSHOT_SCHEMA = 'whoisleuth.website-profile-snapshots';
3
3
  export const WEBSITE_SNAPSHOT_SCHEMA_VERSION = 4;
4
+ export const SUPPORTED_WEBSITE_SNAPSHOT_SCHEMA_VERSIONS = Object.freeze([1, 2, 3, WEBSITE_SNAPSHOT_SCHEMA_VERSION]);
4
5
  export const MAX_WEBSITE_SNAPSHOTS = 60;
5
6
  export const MAX_WEBSITE_SNAPSHOTS_PER_DOMAIN = 12;
6
7
  export const MAX_WEBSITE_SNAPSHOT_STORE_BYTES = 512 * 1024;
@@ -188,6 +189,11 @@ export function normalizeWebsiteSnapshotStore(raw) {
188
189
  && Number(value?.version) > WEBSITE_SNAPSHOT_SCHEMA_VERSION) {
189
190
  throw new Error('This website-snapshot collection uses a newer schema and cannot be read safely.');
190
191
  }
192
+ if (value?.schema === WEBSITE_SNAPSHOT_SCHEMA
193
+ && Number.isSafeInteger(value.version)
194
+ && !SUPPORTED_WEBSITE_SNAPSHOT_SCHEMA_VERSIONS.includes(Number(value.version))) {
195
+ throw new Error('This website-snapshot collection uses an unsupported schema and cannot be read safely.');
196
+ }
191
197
  const sourceValues = Array.isArray(raw) ? raw : Array.isArray(value?.snapshots) ? value.snapshots : [];
192
198
  const snapshots = values(sourceValues, MAX_WEBSITE_SNAPSHOTS * 2, normalizeWebsiteProfileSnapshot);
193
199
  const perDomain = new Map();