@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
@@ -0,0 +1,338 @@
1
+ import { ANALYST_REVIEW_REASON_VALUES } from "./analyst-taxonomy.mjs";
2
+ import { scanBoundedJson } from "./bounded-json.mjs";
3
+ import { RISK_REVIEW_THRESHOLD } from "./risk-scoring.mjs";
4
+ export const RISK_CALIBRATION_SUMMARY_SCHEMA = 'whoisleuth.cli.risk-calibration';
5
+ export const RISK_CALIBRATION_SUMMARY_VERSION = 3;
6
+ export const RISK_CALIBRATION_SUMMARY_MAX_BYTES = 512 * 1024;
7
+ export const RISK_CALIBRATION_SUMMARY_MAX_RECORDS = 500;
8
+ export const RISK_CALIBRATION_SUMMARY_MIN_STRATUM_SAMPLE = 20;
9
+ export const RISK_CALIBRATION_SUMMARY_THRESHOLDS = Object.freeze([...new Set([40, 50, 60, RISK_REVIEW_THRESHOLD, 80, 90])].sort((left, right) => left - right));
10
+ export const RISK_CALIBRATION_SUMMARY_INTERPRETATION = 'This offline replay compares heuristic Risk scores with analyst dispositions. It does not prove maliciousness or safety.';
11
+ const ROOT_KEYS = new Set([
12
+ 'schema', 'version', 'mode', 'generatedAt', 'dataset', 'riskModelVersion',
13
+ 'currentReviewThreshold', 'summary', 'thresholds', 'strata',
14
+ 'modelComparison', 'privacy', 'interpretation',
15
+ ]);
16
+ const DATASET_KEYS = new Set(['schema', 'version', 'recordCount']);
17
+ const SUMMARY_KEYS = new Set(['total', 'positive', 'negative', 'excluded', 'scoreBands']);
18
+ const SCORE_BAND_KEYS = new Set(['not_scored', '0_39', '40_69', '70_100']);
19
+ const METRIC_KEYS = new Set([
20
+ 'threshold', 'truePositive', 'falsePositive', 'trueNegative', 'falseNegative',
21
+ 'precision', 'recall', 'specificity', 'falsePositiveRate', 'f1',
22
+ 'balancedAccuracy', 'confidence95',
23
+ ]);
24
+ const CONFIDENCE_KEYS = new Set(['precision', 'recall', 'specificity']);
25
+ const INTERVAL_KEYS = new Set(['lower', 'upper']);
26
+ const STRATUM_KEYS = new Set(['dimension', 'value', 'sampleCount', 'insufficientSample', 'metrics']);
27
+ const MODEL_COMPARISON_KEYS = new Set([
28
+ 'available', 'previousModelVersion', 'currentModelVersion', 'scoresChanged',
29
+ 'bandsChanged', 'thresholdClassificationsChanged',
30
+ ]);
31
+ const PRIVACY_KEYS = new Set(['targetsRetained', 'identifiersRetained', 'rawEvidenceRetained']);
32
+ const INTERPRETATION_KEYS = new Set(['authority', 'statement', 'automaticTuning', 'networkRequests', 'persisted']);
33
+ const REVIEW_REASON_VALUES = new Set([...ANALYST_REVIEW_REASON_VALUES, 'not_recorded']);
34
+ const SCAN_DEPTH_VALUES = new Set(['deep', 'fast', 'unknown']);
35
+ const CONTROL_RE = /[\x00-\x1f\x7f]/u;
36
+ function object(value, label) {
37
+ if (!value || typeof value !== 'object' || Array.isArray(value))
38
+ throw new Error(`${label} must be an object.`);
39
+ return value;
40
+ }
41
+ function exactKeys(value, expected, label) {
42
+ const keys = Object.keys(value);
43
+ if (keys.length !== expected.size || keys.some((key) => !expected.has(key))) {
44
+ throw new Error(`${label} contains unsupported or missing fields.`);
45
+ }
46
+ }
47
+ function boundedCount(value, label, maximum = RISK_CALIBRATION_SUMMARY_MAX_RECORDS) {
48
+ if (!Number.isSafeInteger(value) || Number(value) < 0 || Number(value) > maximum) {
49
+ throw new Error(`${label} must be a bounded non-negative integer.`);
50
+ }
51
+ return Number(value);
52
+ }
53
+ function modelVersion(value, label) {
54
+ if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > 100) {
55
+ throw new Error(`${label} must be a supported positive model version.`);
56
+ }
57
+ return Number(value);
58
+ }
59
+ function canonicalTimestamp(value, label) {
60
+ if (typeof value !== 'string' || value.length > 64 || !Number.isFinite(Date.parse(value))) {
61
+ throw new Error(`${label} must be a valid date and time.`);
62
+ }
63
+ const canonical = new Date(value).toISOString();
64
+ if (canonical !== value)
65
+ throw new Error(`${label} must use canonical UTC ISO-8601 form.`);
66
+ return canonical;
67
+ }
68
+ function ratio(numerator, denominator) {
69
+ return denominator ? Number((numerator / denominator).toFixed(4)) : null;
70
+ }
71
+ function wilson95(successes, total) {
72
+ if (!total)
73
+ return null;
74
+ const z = 1.96;
75
+ const proportion = successes / total;
76
+ const denominator = 1 + (z * z) / total;
77
+ const center = (proportion + (z * z) / (2 * total)) / denominator;
78
+ const margin = (z * Math.sqrt((proportion * (1 - proportion) + (z * z) / (4 * total)) / total)) / denominator;
79
+ return Object.freeze({
80
+ lower: Number(Math.max(0, center - margin).toFixed(4)),
81
+ upper: Number(Math.min(1, center + margin).toFixed(4)),
82
+ });
83
+ }
84
+ function exactRatio(value, expected, label) {
85
+ if (value !== expected)
86
+ throw new Error(`${label} is inconsistent with the retained confusion counts.`);
87
+ return expected;
88
+ }
89
+ function interval(value, expected, label) {
90
+ if (expected === null) {
91
+ if (value !== null)
92
+ throw new Error(`${label} must be null when its denominator is zero.`);
93
+ return null;
94
+ }
95
+ const candidate = object(value, label);
96
+ exactKeys(candidate, INTERVAL_KEYS, label);
97
+ if (candidate.lower !== expected.lower || candidate.upper !== expected.upper) {
98
+ throw new Error(`${label} is inconsistent with the retained confusion counts.`);
99
+ }
100
+ return Object.freeze({ lower: expected.lower, upper: expected.upper });
101
+ }
102
+ function thresholdMetrics(value, label, expectedThreshold, expectedTotal, expectedPositive = null, expectedNegative = null) {
103
+ const metric = object(value, label);
104
+ exactKeys(metric, METRIC_KEYS, label);
105
+ if (metric.threshold !== expectedThreshold)
106
+ throw new Error(`${label} has an unexpected threshold.`);
107
+ const truePositive = boundedCount(metric.truePositive, `${label} true-positive count`, expectedTotal);
108
+ const falseNegative = boundedCount(metric.falseNegative, `${label} false-negative count`, expectedTotal);
109
+ const trueNegative = boundedCount(metric.trueNegative, `${label} true-negative count`, expectedTotal);
110
+ const falsePositive = boundedCount(metric.falsePositive, `${label} false-positive count`, expectedTotal);
111
+ if (truePositive + falsePositive + trueNegative + falseNegative !== expectedTotal
112
+ || (expectedPositive !== null && truePositive + falseNegative !== expectedPositive)
113
+ || (expectedNegative !== null && trueNegative + falsePositive !== expectedNegative)) {
114
+ throw new Error(`${label} has inconsistent class counts.`);
115
+ }
116
+ const precision = ratio(truePositive, truePositive + falsePositive);
117
+ const recall = ratio(truePositive, truePositive + falseNegative);
118
+ const specificity = ratio(trueNegative, trueNegative + falsePositive);
119
+ const falsePositiveRate = ratio(falsePositive, falsePositive + trueNegative);
120
+ const f1 = precision === null || recall === null || precision + recall === 0
121
+ ? null
122
+ : Number(((2 * precision * recall) / (precision + recall)).toFixed(4));
123
+ const balancedAccuracy = recall === null || specificity === null
124
+ ? null
125
+ : Number(((recall + specificity) / 2).toFixed(4));
126
+ const confidence = object(metric.confidence95, `${label} confidence intervals`);
127
+ exactKeys(confidence, CONFIDENCE_KEYS, `${label} confidence intervals`);
128
+ return Object.freeze({
129
+ threshold: expectedThreshold,
130
+ truePositive,
131
+ falsePositive,
132
+ trueNegative,
133
+ falseNegative,
134
+ precision: exactRatio(metric.precision, precision, `${label} precision`),
135
+ recall: exactRatio(metric.recall, recall, `${label} recall`),
136
+ specificity: exactRatio(metric.specificity, specificity, `${label} specificity`),
137
+ falsePositiveRate: exactRatio(metric.falsePositiveRate, falsePositiveRate, `${label} false-positive rate`),
138
+ f1: exactRatio(metric.f1, f1, `${label} F1`),
139
+ balancedAccuracy: exactRatio(metric.balancedAccuracy, balancedAccuracy, `${label} balanced accuracy`),
140
+ confidence95: Object.freeze({
141
+ precision: interval(confidence.precision, wilson95(truePositive, truePositive + falsePositive), `${label} precision interval`),
142
+ recall: interval(confidence.recall, wilson95(truePositive, truePositive + falseNegative), `${label} recall interval`),
143
+ specificity: interval(confidence.specificity, wilson95(trueNegative, trueNegative + falsePositive), `${label} specificity interval`),
144
+ }),
145
+ });
146
+ }
147
+ export function parseRiskCalibrationSummaryReport(raw) {
148
+ if (typeof raw !== 'string')
149
+ throw new Error('Risk calibration summary must be JSON text.');
150
+ const bytes = new TextEncoder().encode(raw).byteLength;
151
+ if (bytes < 1 || bytes > RISK_CALIBRATION_SUMMARY_MAX_BYTES) {
152
+ throw new Error(`Risk calibration summary must be between 1 byte and ${RISK_CALIBRATION_SUMMARY_MAX_BYTES} bytes.`);
153
+ }
154
+ let parsed;
155
+ try {
156
+ const withoutBom = raw.replace(/^\uFEFF/u, '');
157
+ scanBoundedJson(withoutBom);
158
+ parsed = JSON.parse(withoutBom);
159
+ }
160
+ catch (reason) {
161
+ const message = reason instanceof Error
162
+ ? reason.message.replace(/^Artefact (?:input|JSON)/u, 'Risk calibration summary')
163
+ : 'Risk calibration summary is not valid JSON.';
164
+ throw new Error(message);
165
+ }
166
+ const root = object(parsed, 'Risk calibration summary');
167
+ exactKeys(root, ROOT_KEYS, 'Risk calibration summary');
168
+ if (root.schema !== RISK_CALIBRATION_SUMMARY_SCHEMA
169
+ || root.version !== RISK_CALIBRATION_SUMMARY_VERSION
170
+ || root.mode !== 'summary') {
171
+ throw new Error('Risk calibration summary has an unsupported schema, version, or mode.');
172
+ }
173
+ const dataset = object(root.dataset, 'Risk calibration dataset metadata');
174
+ exactKeys(dataset, DATASET_KEYS, 'Risk calibration dataset metadata');
175
+ if (dataset.schema !== 'whoisleuth.risk-calibration-dataset' || (dataset.version !== 1 && dataset.version !== 2)) {
176
+ throw new Error('Risk calibration dataset metadata has an unsupported schema or version.');
177
+ }
178
+ const recordCount = boundedCount(dataset.recordCount, 'Risk calibration dataset record count');
179
+ if (recordCount < 1)
180
+ throw new Error('Risk calibration dataset metadata must describe at least one record.');
181
+ const riskModelVersion = modelVersion(root.riskModelVersion, 'Risk model version');
182
+ if (!RISK_CALIBRATION_SUMMARY_THRESHOLDS.includes(Number(root.currentReviewThreshold))) {
183
+ throw new Error('Current review threshold is not part of the fixed calibration threshold set.');
184
+ }
185
+ const currentReviewThreshold = Number(root.currentReviewThreshold);
186
+ const rawSummary = object(root.summary, 'Risk calibration summary counts');
187
+ exactKeys(rawSummary, SUMMARY_KEYS, 'Risk calibration summary counts');
188
+ const total = boundedCount(rawSummary.total, 'Total record count');
189
+ const positive = boundedCount(rawSummary.positive, 'Positive label count', total);
190
+ const negative = boundedCount(rawSummary.negative, 'Negative label count', total);
191
+ const excluded = boundedCount(rawSummary.excluded, 'Excluded label count', total);
192
+ if (total !== recordCount || positive + negative + excluded !== total) {
193
+ throw new Error('Risk calibration summary label counts are inconsistent.');
194
+ }
195
+ const rawBands = object(rawSummary.scoreBands, 'Risk calibration score bands');
196
+ exactKeys(rawBands, SCORE_BAND_KEYS, 'Risk calibration score bands');
197
+ const scoreBands = Object.freeze({
198
+ not_scored: boundedCount(rawBands.not_scored, 'Not-scored count', total),
199
+ '0_39': boundedCount(rawBands['0_39'], 'Low-band count', total),
200
+ '40_69': boundedCount(rawBands['40_69'], 'Review-band count', total),
201
+ '70_100': boundedCount(rawBands['70_100'], 'High-band count', total),
202
+ });
203
+ if (Object.values(scoreBands).reduce((sum, count) => sum + count, 0) !== total) {
204
+ throw new Error('Risk calibration score-band counts are inconsistent.');
205
+ }
206
+ if (scoreBands.not_scored > excluded) {
207
+ throw new Error('Risk calibration not-scored count cannot exceed excluded labels.');
208
+ }
209
+ if (!Array.isArray(root.thresholds) || root.thresholds.length !== RISK_CALIBRATION_SUMMARY_THRESHOLDS.length) {
210
+ throw new Error('Risk calibration summary must contain the fixed threshold set exactly once.');
211
+ }
212
+ const thresholds = Object.freeze(root.thresholds.map((value, index) => thresholdMetrics(value, `Threshold ${index + 1}`, RISK_CALIBRATION_SUMMARY_THRESHOLDS[index], positive + negative, positive, negative)));
213
+ if (!Array.isArray(root.strata) || root.strata.length > 32) {
214
+ throw new Error('Risk calibration strata must be a bounded array.');
215
+ }
216
+ const seenStrata = new Set();
217
+ const strata = Object.freeze(root.strata.map((value, index) => {
218
+ const label = `Stratum ${index + 1}`;
219
+ const stratum = object(value, label);
220
+ exactKeys(stratum, STRATUM_KEYS, label);
221
+ if (stratum.dimension !== 'review_reason' && stratum.dimension !== 'scan_depth') {
222
+ throw new Error(`${label} has an unsupported dimension.`);
223
+ }
224
+ if (typeof stratum.value !== 'string' || !stratum.value || stratum.value.length > 64 || CONTROL_RE.test(stratum.value)) {
225
+ throw new Error(`${label} has an invalid value.`);
226
+ }
227
+ const allowedValues = stratum.dimension === 'review_reason' ? REVIEW_REASON_VALUES : SCAN_DEPTH_VALUES;
228
+ if (!allowedValues.has(stratum.value))
229
+ throw new Error(`${label} has an unsupported value.`);
230
+ const key = `${stratum.dimension}\u0000${stratum.value}`;
231
+ if (seenStrata.has(key))
232
+ throw new Error(`${label} is duplicated.`);
233
+ seenStrata.add(key);
234
+ const sampleCount = boundedCount(stratum.sampleCount, `${label} sample count`, positive + negative);
235
+ if (stratum.insufficientSample !== (sampleCount < RISK_CALIBRATION_SUMMARY_MIN_STRATUM_SAMPLE)) {
236
+ throw new Error(`${label} has an inconsistent sample-sufficiency state.`);
237
+ }
238
+ return Object.freeze({
239
+ dimension: stratum.dimension,
240
+ value: stratum.value,
241
+ sampleCount,
242
+ insufficientSample: stratum.insufficientSample,
243
+ metrics: thresholdMetrics(stratum.metrics, `${label} metrics`, currentReviewThreshold, sampleCount),
244
+ });
245
+ }));
246
+ for (const dimension of ['review_reason', 'scan_depth']) {
247
+ const dimensionTotal = strata
248
+ .filter((stratum) => stratum.dimension === dimension)
249
+ .reduce((sum, stratum) => sum + stratum.sampleCount, 0);
250
+ if (dimensionTotal !== positive + negative) {
251
+ throw new Error(`Risk calibration ${dimension.replace('_', '-')} strata do not cover every included label exactly once.`);
252
+ }
253
+ }
254
+ const comparison = object(root.modelComparison, 'Risk model comparison');
255
+ exactKeys(comparison, MODEL_COMPARISON_KEYS, 'Risk model comparison');
256
+ if (typeof comparison.available !== 'boolean')
257
+ throw new Error('Risk model comparison availability must be true or false.');
258
+ const currentModelVersion = modelVersion(comparison.currentModelVersion, 'Compared current model version');
259
+ if (currentModelVersion !== riskModelVersion)
260
+ throw new Error('Risk model comparison does not match the report model version.');
261
+ const previousModelVersion = comparison.previousModelVersion === null
262
+ ? null
263
+ : modelVersion(comparison.previousModelVersion, 'Compared previous model version');
264
+ const scoresChanged = boundedCount(comparison.scoresChanged, 'Changed-score count', total);
265
+ const bandsChanged = boundedCount(comparison.bandsChanged, 'Changed-band count', total);
266
+ const thresholdClassificationsChanged = boundedCount(comparison.thresholdClassificationsChanged, 'Changed-threshold count', total);
267
+ if ((!comparison.available && (previousModelVersion !== null || scoresChanged || bandsChanged || thresholdClassificationsChanged))
268
+ || (comparison.available && previousModelVersion === null)
269
+ || bandsChanged > scoresChanged
270
+ || thresholdClassificationsChanged > scoresChanged) {
271
+ throw new Error('Risk model comparison availability and counts are inconsistent.');
272
+ }
273
+ const privacy = object(root.privacy, 'Risk calibration privacy declaration');
274
+ exactKeys(privacy, PRIVACY_KEYS, 'Risk calibration privacy declaration');
275
+ if (privacy.targetsRetained !== 0 || privacy.identifiersRetained !== 0 || privacy.rawEvidenceRetained !== 0) {
276
+ throw new Error('Risk calibration summary must retain zero targets, identifiers, and raw evidence.');
277
+ }
278
+ const interpretation = object(root.interpretation, 'Risk calibration interpretation');
279
+ exactKeys(interpretation, INTERPRETATION_KEYS, 'Risk calibration interpretation');
280
+ if (interpretation.authority !== 'analyst_context_only'
281
+ || interpretation.statement !== RISK_CALIBRATION_SUMMARY_INTERPRETATION
282
+ || interpretation.automaticTuning !== false
283
+ || interpretation.networkRequests !== false
284
+ || interpretation.persisted !== false) {
285
+ throw new Error('Risk calibration interpretation is inconsistent with the offline diagnostic boundary.');
286
+ }
287
+ return Object.freeze({
288
+ schema: RISK_CALIBRATION_SUMMARY_SCHEMA,
289
+ version: RISK_CALIBRATION_SUMMARY_VERSION,
290
+ mode: 'summary',
291
+ generatedAt: canonicalTimestamp(root.generatedAt, 'Risk calibration generation time'),
292
+ dataset: Object.freeze({
293
+ schema: 'whoisleuth.risk-calibration-dataset',
294
+ version: dataset.version,
295
+ recordCount,
296
+ }),
297
+ riskModelVersion,
298
+ currentReviewThreshold,
299
+ summary: Object.freeze({ total, positive, negative, excluded, scoreBands }),
300
+ thresholds,
301
+ strata,
302
+ modelComparison: Object.freeze({
303
+ available: comparison.available,
304
+ previousModelVersion,
305
+ currentModelVersion,
306
+ scoresChanged,
307
+ bandsChanged,
308
+ thresholdClassificationsChanged,
309
+ }),
310
+ privacy: Object.freeze({ targetsRetained: 0, identifiersRetained: 0, rawEvidenceRetained: 0 }),
311
+ interpretation: Object.freeze({
312
+ authority: 'analyst_context_only',
313
+ statement: RISK_CALIBRATION_SUMMARY_INTERPRETATION,
314
+ automaticTuning: false,
315
+ networkRequests: false,
316
+ persisted: false,
317
+ }),
318
+ });
319
+ }
320
+ export function buildRiskCalibrationSummaryReport(source) {
321
+ const report = object(source, 'Risk calibration report');
322
+ const projected = {
323
+ schema: RISK_CALIBRATION_SUMMARY_SCHEMA,
324
+ version: RISK_CALIBRATION_SUMMARY_VERSION,
325
+ mode: 'summary',
326
+ generatedAt: report.generatedAt,
327
+ dataset: report.dataset,
328
+ riskModelVersion: report.riskModelVersion,
329
+ currentReviewThreshold: report.currentReviewThreshold,
330
+ summary: report.summary,
331
+ thresholds: report.thresholds,
332
+ strata: report.strata,
333
+ modelComparison: report.modelComparison,
334
+ privacy: { targetsRetained: 0, identifiersRetained: 0, rawEvidenceRetained: 0 },
335
+ interpretation: report.interpretation,
336
+ };
337
+ return parseRiskCalibrationSummaryReport(JSON.stringify(projected));
338
+ }
@@ -65,14 +65,13 @@ function reviewRpkiRoute(input) {
65
65
  limitations: baseLimitations,
66
66
  });
67
67
  }
68
- const truncated = rows.length > MAX_RPKI_RECORDS;
68
+ const inputTruncated = rows.length > MAX_RPKI_RECORDS;
69
69
  const normalized = rows.slice(0, MAX_RPKI_RECORDS).map(normalizeAuthorization);
70
70
  const rejectedCount = normalized.filter((item) => item === null).length + Math.max(0, rows.length - MAX_RPKI_RECORDS);
71
71
  const authorizations = normalized.filter((item) => item !== null);
72
72
  const covering = authorizations.filter((item) => prefixContains(item.parsedPrefix, route));
73
- const matches = covering
73
+ const evaluated = covering
74
74
  .sort((left, right) => right.parsedPrefix.length - left.parsedPrefix.length || left.asn - right.asn)
75
- .slice(0, MAX_RPKI_MATCHES)
76
75
  .map((item) => Object.freeze({
77
76
  prefix: item.prefix,
78
77
  maxLength: item.maxLength,
@@ -81,13 +80,15 @@ function reviewRpkiRoute(input) {
81
80
  : route.length > item.maxLength ? 'max_length_exceeded'
82
81
  : 'valid',
83
82
  }));
84
- const matchingAuthorizationCount = matches.filter((item) => item.state === 'valid').length;
83
+ const matchingAuthorizationCount = evaluated.filter((item) => item.state === 'valid').length;
84
+ const matches = evaluated.slice(0, MAX_RPKI_MATCHES);
85
+ const incompleteInput = inputTruncated || rejectedCount > 0;
85
86
  const state = covering.length === 0
86
- ? 'not_found'
87
+ ? incompleteInput ? 'partial' : 'not_found'
87
88
  : matchingAuthorizationCount > 0
88
- ? truncated || rejectedCount > 0 || covering.length > MAX_RPKI_MATCHES ? 'partial'
89
+ ? incompleteInput || covering.length > MAX_RPKI_MATCHES ? 'partial'
89
90
  : 'valid'
90
- : 'invalid';
91
+ : incompleteInput || covering.length > MAX_RPKI_MATCHES ? 'partial' : 'invalid';
91
92
  return Object.freeze({
92
93
  schema: RPKI_EVIDENCE_SCHEMA,
93
94
  version: RPKI_EVIDENCE_VERSION,
@@ -97,7 +98,7 @@ function reviewRpkiRoute(input) {
97
98
  coveringAuthorizationCount: covering.length,
98
99
  matchingAuthorizationCount,
99
100
  rejectedCount,
100
- truncated,
101
+ truncated: inputTruncated || covering.length > MAX_RPKI_MATCHES,
101
102
  matches: Object.freeze(matches),
102
103
  limitations: baseLimitations,
103
104
  });
@@ -25,6 +25,7 @@ import { Agent, fetch as undiciFetch } from 'undici';
25
25
  import { MAX_OUTBOUND_REDIRECTS } from "./outbound-request-bounds.mjs";
26
26
  const MAX_REDIRECTS = MAX_OUTBOUND_REDIRECTS;
27
27
  const MAX_SAFE_FETCH_URL_LENGTH = 4096;
28
+ const MAX_SAFE_FETCH_ADDRESS_CANDIDATES = 64;
28
29
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
29
30
  async function defaultSafeFetchRequest(url, options) {
30
31
  // TypeScript's DOM and undici fetch declarations model equivalent web
@@ -195,6 +196,33 @@ function isPrivateAddress(ip) {
195
196
  return isPrivateIpv6(ip);
196
197
  return true; // not a recognizable IP literal - fail closed
197
198
  }
199
+ function normalizePublicAddressCandidates(value, hostname) {
200
+ if (!Array.isArray(value) || value.length === 0)
201
+ throw new Error(`No DNS records for ${hostname}`);
202
+ if (value.length > MAX_SAFE_FETCH_ADDRESS_CANDIDATES) {
203
+ throw new Error(`Refusing to fetch ${hostname}: DNS returned too many address candidates`);
204
+ }
205
+ const normalized = [];
206
+ const seen = new Set();
207
+ for (const item of value) {
208
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
209
+ throw new Error(`Refusing to fetch ${hostname}: DNS returned an invalid address record`);
210
+ }
211
+ const { address, family } = item;
212
+ if (typeof address !== 'string' || (family !== 4 && family !== 6) || net.isIP(address) !== family) {
213
+ throw new Error(`Refusing to fetch ${hostname}: DNS returned an invalid address record`);
214
+ }
215
+ if (isPrivateAddress(address)) {
216
+ throw new Error(`Refusing to fetch ${hostname}: resolves to a private/reserved address (${address})`);
217
+ }
218
+ const key = `${family}:${address}`;
219
+ if (!seen.has(key)) {
220
+ seen.add(key);
221
+ normalized.push({ address, family });
222
+ }
223
+ }
224
+ return normalized;
225
+ }
198
226
  // Resolves the hostname and returns every address it maps to, having
199
227
  // verified all of them are public. Throws (fails closed) if any address is
200
228
  // private/reserved, or if there are no records at all.
@@ -206,14 +234,7 @@ async function resolvePublicAddresses(hostname) {
206
234
  catch (err) {
207
235
  throw new Error(`DNS resolution failed for ${hostname}: ${err instanceof Error ? err.message : String(err)}`);
208
236
  }
209
- if (records.length === 0)
210
- throw new Error(`No DNS records for ${hostname}`);
211
- for (const { address } of records) {
212
- if (isPrivateAddress(address)) {
213
- throw new Error(`Refusing to fetch ${hostname}: resolves to a private/reserved address (${address})`);
214
- }
215
- }
216
- return records;
237
+ return normalizePublicAddressCandidates(records, hostname);
217
238
  }
218
239
  // A per-request undici Agent whose connections are pinned to the given,
219
240
  // already-validated address(es) - overriding the `lookup` function used at
@@ -261,6 +282,12 @@ function normalizedFetchUrl(value) {
261
282
  throw new Error('Refusing to fetch an oversized URL');
262
283
  return normalized;
263
284
  }
285
+ function resolutionHostname(parsed) {
286
+ const hostname = parsed.hostname;
287
+ return hostname.startsWith('[') && hostname.endsWith(']')
288
+ ? hostname.slice(1, -1)
289
+ : hostname;
290
+ }
264
291
  function boundedDuration(value) {
265
292
  return Math.max(0, Math.min(120_000, Math.round(Number(value) || 0)));
266
293
  }
@@ -274,6 +301,24 @@ async function closeDispatcher(dispatcher) {
274
301
  // Cleanup must never replace the request result or its more useful error.
275
302
  }
276
303
  }
304
+ function abortError(signal) {
305
+ if (signal.reason instanceof Error)
306
+ return signal.reason;
307
+ const error = new Error('The outbound request was aborted.');
308
+ error.name = 'AbortError';
309
+ return error;
310
+ }
311
+ async function raceWithSignal(promise, signal) {
312
+ if (!signal)
313
+ return promise;
314
+ if (signal.aborted)
315
+ throw abortError(signal);
316
+ return new Promise((resolve, reject) => {
317
+ const abort = () => reject(abortError(signal));
318
+ signal.addEventListener('abort', abort, { once: true });
319
+ void promise.then((value) => { signal.removeEventListener('abort', abort); resolve(value); }, (error) => { signal.removeEventListener('abort', abort); reject(error); });
320
+ });
321
+ }
277
322
  // Detailed form of the shared safe request engine. It follows redirects
278
323
  // manually under the same DNS validation and connection-pinning policy as
279
324
  // safeFetch(), while retaining a bounded hop trace for consumers that need
@@ -300,7 +345,11 @@ async function safeFetchDetailed(url, options = {}, dependencies = {}) {
300
345
  let redirectCount = 0;
301
346
  while (true) {
302
347
  const parsed = new URL(currentUrl);
303
- const records = await resolveAddresses(parsed.hostname);
348
+ const hostname = resolutionHostname(parsed);
349
+ const signal = options.signal ?? null;
350
+ const records = normalizePublicAddressCandidates(await raceWithSignal(resolveAddresses(hostname), signal), hostname);
351
+ if (signal?.aborted)
352
+ throw abortError(signal);
304
353
  const dispatcher = makeDispatcher(records);
305
354
  const hopStartedAt = now();
306
355
  // `dispatcher` is a supported undici extension used to pin the connection.
@@ -449,4 +498,4 @@ async function readBytesCapped(res, maxBytes) {
449
498
  reader.cancel().catch(() => { });
450
499
  return { bytes: Buffer.concat(chunks), truncated, bytesRead: received };
451
500
  }
452
- export { MAX_REDIRECTS, MAX_SAFE_FETCH_URL_LENGTH, safeFetch, safeFetchDetailed, readTextCapped, readBytesCapped, isPrivateAddress, resolvePublicAddresses, };
501
+ export { MAX_REDIRECTS, MAX_SAFE_FETCH_URL_LENGTH, MAX_SAFE_FETCH_ADDRESS_CANDIDATES, safeFetch, safeFetchDetailed, readTextCapped, readBytesCapped, isPrivateAddress, resolvePublicAddresses, };
@@ -61,6 +61,23 @@ function normalizedPublishedUri(value, schemes) {
61
61
  function normalizedHttpsUrl(value) {
62
62
  return normalizedPublishedUri(value, HTTPS_PROTOCOLS);
63
63
  }
64
+ function normalizedCanonicalComparisonUrl(value) {
65
+ if (!value || value.length > MAX_SECURITY_TXT_URL_LENGTH || URI_CONTROL_CHARACTER_RE.test(value))
66
+ return null;
67
+ try {
68
+ const url = new URL(value.trim());
69
+ if (url.protocol !== 'https:' || !url.hostname || url.username || url.password)
70
+ return null;
71
+ // A fragment is never part of the retrieval URI. Preserve the query only
72
+ // for the transient RFC 9116 Canonical comparison; retained values remain
73
+ // query-free under the existing privacy-minimised output contract.
74
+ url.hash = '';
75
+ return url.toString().slice(0, MAX_SECURITY_TXT_URL_LENGTH);
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
64
81
  function eligibleHttpsFetchUrl(value) {
65
82
  if (!value || value.length > MAX_SECURITY_TXT_URL_LENGTH || URI_CONTROL_CHARACTER_RE.test(value))
66
83
  return null;
@@ -113,6 +130,7 @@ function parseSecurityTxt(text, options = {}) {
113
130
  const policies = [];
114
131
  const encryption = [];
115
132
  const canonical = [];
133
+ const canonicalComparison = [];
116
134
  const preferredLanguages = [];
117
135
  const expires = [];
118
136
  for (const rawLine of lines.slice(0, MAX_SECURITY_TXT_LINES)) {
@@ -157,8 +175,11 @@ function parseSecurityTxt(text, options = {}) {
157
175
  }
158
176
  else if (field === 'canonical') {
159
177
  normalized = normalizedHttpsUrl(value);
160
- if (normalized)
178
+ const comparisonValue = normalizedCanonicalComparisonUrl(value);
179
+ if (normalized && comparisonValue) {
161
180
  canonical.push(normalized);
181
+ canonicalComparison.push(comparisonValue);
182
+ }
162
183
  else
163
184
  malformedCount += 1;
164
185
  }
@@ -187,8 +208,10 @@ function parseSecurityTxt(text, options = {}) {
187
208
  .some((entry) => entry.truncated);
188
209
  const expiresAt = expires.length === 1 ? expires[0] : null;
189
210
  const finalUrl = options.finalUrl ? normalizedHttpsUrl(options.finalUrl) : null;
190
- const canonicalMatches = boundedCanonical.values.length && finalUrl
191
- ? boundedCanonical.values.some((value) => value.toLowerCase() === finalUrl.toLowerCase())
211
+ const finalComparisonUrl = options.finalUrl ? normalizedCanonicalComparisonUrl(options.finalUrl) : null;
212
+ const canonicalMatches = boundedCanonical.values.length && finalUrl && finalComparisonUrl
213
+ ? canonicalComparison.slice(0, MAX_SECURITY_TXT_VALUES)
214
+ .some((value) => value.toLowerCase() === finalComparisonUrl.toLowerCase())
192
215
  : null;
193
216
  const stale = Boolean(expiresAt && Date.parse(expiresAt) <= (options.now ?? Date.now()));
194
217
  const requiredMalformed = boundedContacts.values.length === 0 || expires.length !== 1;
@@ -450,16 +450,21 @@ function defaultTcpExchange(query, resolver, options) {
450
450
  const chunks = [];
451
451
  let totalLength = 0;
452
452
  let settled = false;
453
+ let hardDeadline = null;
453
454
  const finish = (error, response) => {
454
455
  if (settled)
455
456
  return;
456
457
  settled = true;
458
+ if (hardDeadline)
459
+ clearTimeout(hardDeadline);
457
460
  socket.destroy();
458
461
  if (error)
459
462
  reject(error);
460
463
  else
461
464
  resolve(response);
462
465
  };
466
+ hardDeadline = setTimeout(() => finish(new ServiceBindingDnsError('DNS TCP query timed out', 'ETIMEOUT')), options.timeoutMs);
467
+ hardDeadline.unref();
463
468
  socket.setTimeout(options.timeoutMs, () => finish(new ServiceBindingDnsError('DNS TCP query timed out', 'ETIMEOUT')));
464
469
  socket.once('error', (error) => finish(error));
465
470
  socket.on('data', (chunk) => {
@@ -605,4 +610,4 @@ async function resolveServiceBindingRecords(nameValue, type, options = {}) {
605
610
  .slice(0, MAX_ERROR_LENGTH);
606
611
  throw new ServiceBindingDnsError(detail || 'DNS query failed', 'EDNSFAIL');
607
612
  }
608
- export { DNS_TIMEOUT_MS as SERVICE_BINDING_DNS_TIMEOUT_MS, HTTPS_TYPE, MAX_SERVICE_BINDING_RECORDS, SVCB_TYPE, ServiceBindingDnsError, buildServiceBindingDnsQuery, formatIpv6, parseResolverEndpoint, parseServiceBindingDnsResponse, resolveServiceBindingRecords, };
613
+ export { DNS_TIMEOUT_MS as SERVICE_BINDING_DNS_TIMEOUT_MS, HTTPS_TYPE, MAX_DNS_MESSAGE_BYTES, MAX_SERVICE_BINDING_RECORDS, SVCB_TYPE, ServiceBindingDnsError, buildServiceBindingDnsQuery, defaultTcpExchange, formatIpv6, parseResolverEndpoint, parseServiceBindingDnsResponse, resolveServiceBindingRecords, };