@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.
- package/DISCLOSURE +7 -3
- package/NOTICE +5 -4
- package/README.md +59 -6
- package/bin/whoisleuth.mjs +5 -3
- package/cli/arguments.mjs +256 -9
- package/cli/artifact-structure.mjs +1833 -0
- package/cli/artifact-verify.mjs +242 -49
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +2 -3
- package/cli/bulk-command-runner.mjs +2 -5
- package/cli/case-pack.mjs +819 -34
- package/cli/command-reference.mjs +48 -24
- package/cli/completion.mjs +16 -8
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discovery-scan-command-runner.mjs +4 -5
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +21 -15
- package/cli/domain-control-observations.mjs +25 -2
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +73 -45
- package/cli/export-evidence.mjs +11 -4
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/terminal.mjs +3 -0
- package/cli/input.mjs +57 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-run.mjs +71 -23
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookup-command-runner.mjs +2 -2
- package/cli/lookup-diff.mjs +9 -0
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +28 -8
- package/cli/page-compare.mjs +8 -5
- package/cli/registry-cohort.mjs +385 -61
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +376 -0
- package/cli/risk-calibration.mjs +7 -5
- package/cli/runner.mjs +147 -185
- package/cli/saved-lookup.mjs +26 -3
- package/cli/sharing-review.mjs +12 -6
- package/docs/cli-reference.md +444 -70
- package/docs/cli.md +24 -5
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
- package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +29 -5
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
- package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
- package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
- package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
- package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
- package/frontend/src/lib/analysis/bulk-sort.js +4 -0
- package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +8 -0
- package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
- package/frontend/src/lib/analysis/case-migration-model.js +83 -31
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-model.js +2 -0
- package/frontend/src/lib/analysis/case-record-operations.js +42 -12
- package/frontend/src/lib/analysis/case-report.js +39 -6
- package/frontend/src/lib/analysis/case-response-model.js +66 -7
- package/frontend/src/lib/analysis/case-response-packet.js +15 -5
- package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
- package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
- package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
- package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
- package/frontend/src/lib/analysis/domain-control-records.js +88 -0
- package/frontend/src/lib/analysis/external-findings-import.js +70 -6
- package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
- package/frontend/src/lib/analysis/investigation-guide.js +52 -10
- package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
- package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
- package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +61 -6
- package/frontend/src/lib/analysis/lookup-presentation.js +62 -0
- package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
- package/frontend/src/lib/analysis/utils.js +26 -3
- package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
- package/frontend/src/lib/analysis/visualization-models.js +63 -94
- package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
- package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
- package/frontend/src/lib/analysis/workspace-archive.js +104 -14
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +14 -12
- package/lib/bounded-file.mjs +13 -0
- package/lib/bounded-json-response.mjs +17 -4
- package/lib/bounded-json.mjs +249 -0
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/cryptographic-assurance.mjs +155 -0
- package/lib/ct-search.mjs +87 -47
- package/lib/dns-intelligence.mjs +3 -2
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1053 -0
- package/lib/dnssec-evidence-validation.mjs +10 -3
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-manifest.mjs +17 -129
- package/lib/domain-posture.mjs +7 -4
- package/lib/evidence-export.mjs +751 -78
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/favicon.mjs +8 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +2 -5
- package/lib/interchange-fidelity-registry.mjs +122 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup-response-contract.mjs +691 -3
- package/lib/lookup.mjs +2 -2
- package/lib/observation.mjs +7 -6
- package/lib/outbound-identity.mjs +2 -3
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +21 -0
- package/lib/provider-policy-admission.mjs +9 -4
- package/lib/rdap-normalization.mjs +18 -5
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/risk-calibration-summary.mjs +338 -0
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +59 -10
- package/lib/security-txt.mjs +26 -3
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +784 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/threat-intelligence-runtime.mjs +3 -11
- package/lib/threat-intelligence-types.mjs +18 -1
- package/lib/tls-intelligence.mjs +11 -5
- package/lib/tlsa-evidence.mjs +37 -7
- package/package.json +1 -1
- 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
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
32
|
-
|
|
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
|
-
|
|
54
|
+
continue;
|
|
37
55
|
seenRows.add(id);
|
|
38
56
|
const rowCells = (Array.isArray(row.cells) ? row.cells : []);
|
|
39
|
-
const
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
return {
|
|
86
|
+
});
|
|
87
|
+
rows.push({
|
|
49
88
|
id,
|
|
50
89
|
label,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
.
|
|
59
|
-
|
|
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 =
|
|
63
|
-
const rowHeight =
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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([
|
|
183
|
-
const dayScale = scaleBand().domain([0, 1, 2, 3, 4, 5, 6]).range([
|
|
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:
|
|
204
|
-
height:
|
|
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,
|
|
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
|
|
208
|
+
export function projectProfileListingBars(rawGroups) {
|
|
241
209
|
const candidates = (Array.isArray(rawGroups) ? rawGroups : [])
|
|
242
210
|
.map((group, index) => {
|
|
243
|
-
const
|
|
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
|
-
...
|
|
253
|
-
total
|
|
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,
|
|
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 =
|
|
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 = ['
|
|
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
|
|
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:
|
|
130
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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();
|