@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.
- package/README.md +19 -2
- package/bin/whoisleuth.mjs +41 -24
- package/cli/arguments.mjs +326 -3
- package/cli/artifact-verify.mjs +60 -14
- package/cli/bulk.mjs +1 -2
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +36 -2
- package/cli/doctor.mjs +11 -5
- package/cli/evidence-command-arguments.mjs +1 -1
- package/cli/evidence-command-runner.mjs +2 -2
- package/cli/evidence-signing.mjs +4 -4
- package/cli/export-evidence.mjs +9 -2
- package/cli/formatters/html.mjs +1 -1
- package/cli/formatters/json.mjs +13 -1
- package/cli/formatters/terminal.mjs +14 -4
- package/cli/investigation-plan.mjs +112 -0
- package/cli/local-mmdb-review.mjs +76 -0
- package/cli/lookalike-calibration.mjs +121 -0
- package/cli/lookup-command-runner.mjs +4 -1
- package/cli/lookup-plan.mjs +3 -3
- package/cli/lookup-reconcile.mjs +141 -0
- package/cli/lookup-timeline.mjs +1 -1
- package/cli/mail-review.mjs +48 -3
- package/cli/offline-evidence-review.mjs +159 -0
- package/cli/registry-doctor.mjs +125 -0
- package/cli/registry-support.mjs +1 -4
- package/cli/risk-calibration.mjs +117 -9
- package/cli/runner.mjs +342 -15
- package/cli/saved-lookup.mjs +2 -2
- package/cli/sharing-review.mjs +202 -0
- package/cli/source-reliability.mjs +1 -1
- package/docs/cli-reference.md +1110 -0
- package/docs/cli.md +224 -847
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +6 -6
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
- package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
- package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
- package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
- package/frontend/src/lib/analysis/case-migration-model.js +6 -1
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-core.js +5 -1
- package/frontend/src/lib/analysis/case-record-operations.js +6 -1
- package/frontend/src/lib/analysis/case-response-packet.js +7 -7
- package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
- package/frontend/src/lib/analysis/http-summary.js +2 -1
- package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
- package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +480 -0
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +74 -0
- package/frontend/src/lib/analysis/lookup-response.js +2 -0
- package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
- package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
- package/frontend/src/lib/analysis/page-similarity.js +1 -1
- package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
- package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
- package/frontend/src/lib/analysis/scoring.js +8 -88
- package/frontend/src/lib/analysis/shortlist-model.js +8 -4
- package/frontend/src/lib/analysis/utils.js +7 -40
- package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
- package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
- package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
- package/frontend/src/lib/analysis/visualization-models.js +370 -0
- package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
- package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive.js +2 -2
- package/lib/analyst-taxonomy.mjs +40 -0
- package/lib/availability.mjs +30 -10
- package/lib/browser-library-profile.mjs +15 -2
- package/lib/bulk-limits.mjs +13 -0
- package/lib/certificate-extension-profile.mjs +71 -0
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/ct-response-bounds.mjs +9 -0
- package/lib/ct-search.mjs +66 -12
- package/lib/dns-delegation-health.mjs +56 -5
- package/lib/dnssec-evidence-validation.mjs +235 -0
- package/lib/domain-assurance.mjs +368 -0
- package/lib/domain-control-manifest.mjs +382 -0
- package/lib/domain-posture.mjs +11 -10
- package/lib/encrypted-dns-contract.mjs +81 -0
- package/lib/evidence-export.mjs +17 -3
- package/lib/evidence-report-markdown.mjs +1 -1
- package/lib/evidence-report.mjs +4 -1
- package/lib/favicon.mjs +3 -8
- package/lib/generated/cisa-kev-catalog.mjs +1668 -0
- package/lib/html-signals.mjs +4 -0
- package/lib/http-evidence-bounds.mjs +4 -0
- package/lib/http-intelligence.mjs +2 -1
- package/lib/ip-prefix.mjs +87 -0
- package/lib/local-geoip-evidence.mjs +89 -0
- package/lib/lookup-response-contract.mjs +346 -0
- package/lib/observed-network-context.mjs +1 -1
- package/lib/opportunity-scoring.mjs +107 -0
- package/lib/outbound-identity.mjs +9 -0
- package/lib/outbound-request-bounds.mjs +9 -0
- package/lib/page-fingerprints.mjs +1 -1
- package/lib/perceptual-hash-comparison.mjs +34 -0
- package/lib/perceptual-hash.mjs +3 -31
- package/lib/project-metadata.mjs +8 -0
- package/lib/provider-policy-admission.mjs +49 -0
- package/lib/rdap-capabilities.mjs +6 -6
- package/lib/rdap-search-workbench.mjs +247 -0
- package/lib/rdap.mjs +7 -2
- package/lib/registry-access.mjs +1 -1
- package/lib/registry-capabilities.mjs +31 -1
- package/lib/registry-capability-catalogue.mjs +15 -14
- package/lib/registry-insights.mjs +6 -6
- package/lib/response-policy.mjs +81 -6
- package/lib/risk-scoring.mjs +206 -83
- package/lib/rpki-evidence.mjs +105 -0
- package/lib/safe-fetch.mjs +2 -1
- package/lib/scoring-evidence-quality.mjs +107 -0
- package/lib/security-txt.mjs +3 -3
- package/lib/semantic-version.mjs +54 -0
- package/lib/static-html-analysis.mjs +27 -0
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threatfox-intelligence.mjs +11 -6
- package/lib/tls-intelligence.mjs +13 -3
- package/lib/tlsa-evidence.mjs +194 -0
- package/lib/urlhaus-intelligence.mjs +11 -6
- package/lib/urlscan-intelligence.mjs +10 -5
- package/lib/website-security-posture.mjs +16 -9
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-chain.mjs +14 -152
- package/lib/whois.mjs +2 -2
- package/package.json +6 -2
- 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
|
+
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 {
|
|
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, '
|
|
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
|
|
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
|
|
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';
|