@slicedearth/whoisleuth-cli 1.43.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 (153) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +67 -10
  4. package/bin/whoisleuth.mjs +22 -9
  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 +67 -31
  10. package/cli/bulk-command-runner.mjs +16 -6
  11. package/cli/bulk.mjs +18 -7
  12. package/cli/case-pack.mjs +819 -34
  13. package/cli/command-reference.mjs +48 -24
  14. package/cli/completion.mjs +50 -11
  15. package/cli/config-profile.mjs +15 -12
  16. package/cli/ct-event-intake.mjs +32 -2
  17. package/cli/discovery-observation-snapshot.mjs +8 -14
  18. package/cli/discovery-scan-command-runner.mjs +18 -6
  19. package/cli/discovery-snapshot.mjs +8 -15
  20. package/cli/discovery-workflow.mjs +2 -3
  21. package/cli/doctor.mjs +2 -2
  22. package/cli/domain-control-monitor.mjs +21 -15
  23. package/cli/domain-control-observations.mjs +25 -2
  24. package/cli/errors.mjs +7 -1
  25. package/cli/evidence-command-runner.mjs +17 -11
  26. package/cli/evidence-signing.mjs +73 -45
  27. package/cli/export-evidence.mjs +11 -4
  28. package/cli/formatters/html.mjs +3 -3
  29. package/cli/formatters/json.mjs +12 -4
  30. package/cli/formatters/terminal.mjs +4 -1
  31. package/cli/http.mjs +2 -1
  32. package/cli/input.mjs +57 -0
  33. package/cli/interchange-report.mjs +226 -0
  34. package/cli/investigation-manifest.mjs +16 -17
  35. package/cli/investigation-run.mjs +71 -23
  36. package/cli/local-mmdb-review.mjs +45 -14
  37. package/cli/lookup-command-runner.mjs +2 -2
  38. package/cli/lookup-diff.mjs +9 -0
  39. package/cli/lookup-reconcile.mjs +1 -1
  40. package/cli/mail-review.mjs +5 -5
  41. package/cli/manual.mjs +1 -1
  42. package/cli/network-command-runner.mjs +78 -0
  43. package/cli/offline-evidence-review.mjs +28 -8
  44. package/cli/page-compare.mjs +8 -5
  45. package/cli/registry-cohort.mjs +385 -61
  46. package/cli/registry-fixture-scaffold.mjs +4 -3
  47. package/cli/registry-support.mjs +2 -1
  48. package/cli/retained-artifact-diff.mjs +376 -0
  49. package/cli/risk-calibration.mjs +7 -5
  50. package/cli/runner.mjs +150 -177
  51. package/cli/saved-lookup.mjs +26 -3
  52. package/cli/sharing-review.mjs +12 -6
  53. package/docs/cli-reference.md +467 -74
  54. package/docs/cli.md +25 -5
  55. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  56. package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
  57. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  58. package/frontend/src/lib/analysis/brand-profile-model.js +33 -7
  59. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  60. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  61. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  62. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  63. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  64. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  65. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  66. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  67. package/frontend/src/lib/analysis/case-evidence-model.js +9 -1
  68. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  69. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  70. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  71. package/frontend/src/lib/analysis/case-record-core.js +1 -1
  72. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  73. package/frontend/src/lib/analysis/case-record-operations.js +43 -13
  74. package/frontend/src/lib/analysis/case-report.js +39 -6
  75. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  76. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  77. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  78. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  79. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  80. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  81. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  82. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  83. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  84. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  85. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  86. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  87. package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
  88. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  89. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  90. package/frontend/src/lib/analysis/lookup-decision-support.js +63 -8
  91. package/frontend/src/lib/analysis/lookup-presentation.js +70 -14
  92. package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
  93. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  94. package/frontend/src/lib/analysis/utils.js +27 -4
  95. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  96. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  97. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  98. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  99. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
  100. package/frontend/src/lib/analysis/workspace-archive.js +105 -19
  101. package/lib/application-version.mjs +3 -0
  102. package/lib/availability.mjs +14 -12
  103. package/lib/bounded-file.mjs +82 -0
  104. package/lib/bounded-json-response.mjs +170 -0
  105. package/lib/bounded-json.mjs +249 -0
  106. package/lib/browser-library-profile.mjs +167 -38
  107. package/lib/certificate-extension-profile.mjs +2 -1
  108. package/lib/cryptographic-assurance.mjs +155 -0
  109. package/lib/ct-search.mjs +87 -47
  110. package/lib/dns-intelligence.mjs +3 -2
  111. package/lib/dns-resolver-selection.mjs +6 -4
  112. package/lib/dnssec-chain-validation.mjs +1053 -0
  113. package/lib/dnssec-evidence-validation.mjs +10 -3
  114. package/lib/domain-change-packet.mjs +7 -5
  115. package/lib/domain-control-manifest.mjs +17 -129
  116. package/lib/domain-posture.mjs +7 -4
  117. package/lib/evidence-export.mjs +751 -78
  118. package/lib/evidence-report-markdown.mjs +3 -0
  119. package/lib/evidence-report.mjs +5 -0
  120. package/lib/favicon.mjs +8 -5
  121. package/lib/http-evidence-bounds.mjs +3 -0
  122. package/lib/http-intelligence.mjs +2 -5
  123. package/lib/interchange-fidelity-registry.mjs +122 -0
  124. package/lib/local-geoip-evidence.mjs +2 -1
  125. package/lib/lookup-cache.mjs +5 -1
  126. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  127. package/lib/lookup-response-contract.mjs +691 -3
  128. package/lib/lookup.mjs +2 -2
  129. package/lib/observation.mjs +7 -6
  130. package/lib/outbound-identity.mjs +2 -3
  131. package/lib/perceptual-hash.mjs +12 -4
  132. package/lib/portable-generator.mjs +21 -0
  133. package/lib/provider-policy-admission.mjs +9 -4
  134. package/lib/rdap-normalization.mjs +18 -5
  135. package/lib/rdap-registrar.mjs +5 -1
  136. package/lib/rdap-transport.mjs +6 -6
  137. package/lib/registry-capabilities.mjs +3 -0
  138. package/lib/registry-capability-catalogue.mjs +12 -2
  139. package/lib/registry-comparison.mjs +28 -11
  140. package/lib/risk-calibration-summary.mjs +338 -0
  141. package/lib/rpki-evidence.mjs +9 -8
  142. package/lib/safe-fetch.mjs +96 -42
  143. package/lib/security-txt.mjs +26 -3
  144. package/lib/service-binding-dns.mjs +6 -1
  145. package/lib/smtp-transport-review.mjs +784 -0
  146. package/lib/sslbl-certificates.generated.mjs +136 -134
  147. package/lib/threat-intelligence-runtime.mjs +4 -13
  148. package/lib/threat-intelligence-types.mjs +18 -1
  149. package/lib/tls-intelligence.mjs +11 -5
  150. package/lib/tlsa-evidence.mjs +37 -7
  151. package/lib/website-technology.mjs +2 -6
  152. package/package.json +1 -1
  153. 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
@@ -128,21 +129,26 @@ function isPrivateIpv6(ip) {
128
129
  return true; // ::1 (loopback)
129
130
  if (g0.startsWith('ff'))
130
131
  return true; // multicast ff00::/8
131
- // IPv4-mapped (::ffff:0:0/96) and the deprecated IPv4-compatible
132
- // (::0:0/96) forms both put the embedded address in the last two groups -
133
- // checked on the canonical expanded form above, so it doesn't matter
134
- // whether the address was originally written in dotted-decimal or hex.
135
- if ([g0, g1, g2, g3, g4].every((g) => g === '0000') && (g5 === 'ffff' || g5 === '0000')) {
132
+ // IPv4-mapped addresses put the embedded address in the last two groups.
133
+ // Accept only a mapped public IPv4 target. The deprecated IPv4-compatible
134
+ // form is rejected below rather than treated as a routable IPv6 address.
135
+ if ([g0, g1, g2, g3, g4].every((g) => g === '0000') && g5 === 'ffff') {
136
136
  const embedded = groupsToIpv4(g6, g7);
137
137
  if (embedded && isPrivateIpv4(embedded))
138
138
  return true;
139
+ if (embedded)
140
+ return false;
139
141
  }
142
+ if ([g0, g1, g2, g3, g4, g5].every((g) => g === '0000'))
143
+ return true;
140
144
  // NAT64 well-known prefix (64:ff9b::/96) - same tail embedding, different
141
145
  // prefix; some IPv6-only networks synthesize these to reach IPv4 hosts.
142
146
  if (g0 === '0064' && g1 === 'ff9b' && [g2, g3, g4, g5].every((g) => g === '0000')) {
143
147
  const embedded = groupsToIpv4(g6, g7);
144
148
  if (embedded && isPrivateIpv4(embedded))
145
149
  return true;
150
+ if (embedded)
151
+ return false;
146
152
  }
147
153
  // RFC 8215 local-use NAT64 prefix (64:ff9b:1::/48). Unlike the well-known
148
154
  // /96 above, operators may choose how IPv4 bits are embedded within this
@@ -150,21 +156,13 @@ function isPrivateIpv6(ip) {
150
156
  // rather than attempting to decode one layout and leaving others open.
151
157
  if (g0 === '0064' && g1 === 'ff9b' && g2 === '0001')
152
158
  return true;
153
- // 6to4 (2002::/16) - the embedded IPv4 lives in groups 1-2, not the tail.
154
- if (g0 === '2002') {
155
- const embedded = groupsToIpv4(g1, g2);
156
- if (embedded && isPrivateIpv4(embedded))
157
- return true;
158
- }
159
- // Teredo (2001:0000::/32) carries both a server IPv4 address and an
160
- // obfuscated client IPv4 address. Decode both so a transition address
161
- // cannot smuggle a private/loopback target past the IPv4 guard.
162
- if (g0 === '2001' && g1 === '0000') {
163
- const server = groupsToIpv4(g2, g3);
164
- const client = groupsToIpv4(((~parseInt(g6, 16)) & 0xffff).toString(16), ((~parseInt(g7, 16)) & 0xffff).toString(16));
165
- if ((server && isPrivateIpv4(server)) || (client && isPrivateIpv4(client)))
166
- return true;
167
- }
159
+ // Deprecated transition mechanisms are not direct global-unicast targets.
160
+ // Reject their entire ranges instead of attempting to classify embedded
161
+ // addresses whose relays and routing behaviour are outside this boundary.
162
+ if (g0 === '2002')
163
+ return true; // 6to4 2002::/16
164
+ if (g0 === '2001' && g1 === '0000')
165
+ return true; // Teredo 2001::/32
168
166
  if (/^fe[89ab][0-9a-f]$/.test(g0))
169
167
  return true; // link-local fe80::/10
170
168
  if (/^fe[cdef][0-9a-f]$/.test(g0))
@@ -175,11 +173,20 @@ function isPrivateIpv6(ip) {
175
173
  return true; // discard-only 100::/64
176
174
  if (g0 === '2001' && g1 === '0db8')
177
175
  return true; // documentation 2001:db8::/32
176
+ if (g0 === '3fff' && parseInt(g1, 16) <= 0x0fff)
177
+ return true; // documentation 3fff::/20
178
178
  if (g0 === '2001' && g1 === '0002' && g2 === '0000')
179
179
  return true; // benchmarking 2001:2::/48
180
180
  if (g0 === '2001' && /^00[1-2][0-9a-f]$/.test(g1))
181
181
  return true; // ORCHID/ORCHIDv2
182
- return false;
182
+ // Ordinary publicly routed IPv6 unicast addresses are allocated from
183
+ // 2000::/3. Treat everything outside that allocation as reserved unless it
184
+ // matched one of the explicitly supported public transition forms above.
185
+ // An allow-list boundary is intentional here: newly written or unassigned
186
+ // address space must not silently become an outbound SSRF target merely
187
+ // because it was absent from this file's special-purpose exclusions.
188
+ const firstGroup = parseInt(g0, 16);
189
+ return !Number.isInteger(firstGroup) || firstGroup < 0x2000 || firstGroup > 0x3fff;
183
190
  }
184
191
  function isPrivateAddress(ip) {
185
192
  const version = net.isIP(ip);
@@ -189,6 +196,33 @@ function isPrivateAddress(ip) {
189
196
  return isPrivateIpv6(ip);
190
197
  return true; // not a recognizable IP literal - fail closed
191
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
+ }
192
226
  // Resolves the hostname and returns every address it maps to, having
193
227
  // verified all of them are public. Throws (fails closed) if any address is
194
228
  // private/reserved, or if there are no records at all.
@@ -200,14 +234,7 @@ async function resolvePublicAddresses(hostname) {
200
234
  catch (err) {
201
235
  throw new Error(`DNS resolution failed for ${hostname}: ${err instanceof Error ? err.message : String(err)}`);
202
236
  }
203
- if (records.length === 0)
204
- throw new Error(`No DNS records for ${hostname}`);
205
- for (const { address } of records) {
206
- if (isPrivateAddress(address)) {
207
- throw new Error(`Refusing to fetch ${hostname}: resolves to a private/reserved address (${address})`);
208
- }
209
- }
210
- return records;
237
+ return normalizePublicAddressCandidates(records, hostname);
211
238
  }
212
239
  // A per-request undici Agent whose connections are pinned to the given,
213
240
  // already-validated address(es) - overriding the `lookup` function used at
@@ -255,6 +282,12 @@ function normalizedFetchUrl(value) {
255
282
  throw new Error('Refusing to fetch an oversized URL');
256
283
  return normalized;
257
284
  }
285
+ function resolutionHostname(parsed) {
286
+ const hostname = parsed.hostname;
287
+ return hostname.startsWith('[') && hostname.endsWith(']')
288
+ ? hostname.slice(1, -1)
289
+ : hostname;
290
+ }
258
291
  function boundedDuration(value) {
259
292
  return Math.max(0, Math.min(120_000, Math.round(Number(value) || 0)));
260
293
  }
@@ -268,6 +301,24 @@ async function closeDispatcher(dispatcher) {
268
301
  // Cleanup must never replace the request result or its more useful error.
269
302
  }
270
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
+ }
271
322
  // Detailed form of the shared safe request engine. It follows redirects
272
323
  // manually under the same DNS validation and connection-pinning policy as
273
324
  // safeFetch(), while retaining a bounded hop trace for consumers that need
@@ -294,7 +345,11 @@ async function safeFetchDetailed(url, options = {}, dependencies = {}) {
294
345
  let redirectCount = 0;
295
346
  while (true) {
296
347
  const parsed = new URL(currentUrl);
297
- 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);
298
353
  const dispatcher = makeDispatcher(records);
299
354
  const hopStartedAt = now();
300
355
  // `dispatcher` is a supported undici extension used to pin the connection.
@@ -373,13 +428,12 @@ async function readTextCapped(res, maxBytes, options = {}) {
373
428
  bytesRead,
374
429
  ...(hasher ? { sha256: hasher.digest('hex') } : {}),
375
430
  });
376
- const reader = res.body && res.body.getReader ? res.body.getReader() : null;
377
- if (!reader) {
378
- const full = Buffer.from(await res.arrayBuffer());
379
- const captured = full.subarray(0, maxBytes);
380
- hasher?.update(captured);
381
- return result(new TextDecoder().decode(captured), full.length > maxBytes, captured.length);
431
+ if (res.body === null)
432
+ return result('', false, 0);
433
+ if (typeof res.body?.getReader !== 'function') {
434
+ throw new Error('Response body does not expose a bounded readable stream');
382
435
  }
436
+ const reader = res.body.getReader();
383
437
  const decoder = new TextDecoder();
384
438
  let text = '';
385
439
  let received = 0;
@@ -413,12 +467,12 @@ async function readTextCapped(res, maxBytes, options = {}) {
413
467
  // text (a favicon image) - collects raw bytes into a Buffer instead of
414
468
  // decoding as UTF-8, which would corrupt non-text bytes.
415
469
  async function readBytesCapped(res, maxBytes) {
416
- const reader = res.body && res.body.getReader ? res.body.getReader() : null;
417
- if (!reader) {
418
- const buf = Buffer.from(await res.arrayBuffer());
419
- const bytes = buf.subarray(0, maxBytes);
420
- return { bytes, truncated: buf.length > maxBytes, bytesRead: bytes.length };
470
+ if (res.body === null)
471
+ return { bytes: Buffer.alloc(0), truncated: false, bytesRead: 0 };
472
+ if (typeof res.body?.getReader !== 'function') {
473
+ throw new Error('Response body does not expose a bounded readable stream');
421
474
  }
475
+ const reader = res.body.getReader();
422
476
  const chunks = [];
423
477
  let received = 0;
424
478
  let truncated = false;
@@ -444,4 +498,4 @@ async function readBytesCapped(res, maxBytes) {
444
498
  reader.cancel().catch(() => { });
445
499
  return { bytes: Buffer.concat(chunks), truncated, bytesRead: received };
446
500
  }
447
- 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, };