@slicedearth/whoisleuth-cli 1.45.0 → 2.0.0

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 (268) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +74 -124
  4. package/SECURITY.md +6 -3
  5. package/bin/whoisleuth.mjs +7 -4
  6. package/cli/archive-inspect.mjs +28 -10
  7. package/cli/arguments.mjs +450 -173
  8. package/cli/artifact-structure.mjs +2507 -0
  9. package/cli/artifact-verify.mjs +286 -64
  10. package/cli/bounded-json.mjs +1 -0
  11. package/cli/bulk-checkpoint.mjs +35 -23
  12. package/cli/bulk-command-runner.mjs +2 -5
  13. package/cli/bulk.mjs +9 -2
  14. package/cli/case-pack.mjs +435 -46
  15. package/cli/ci-report.mjs +6 -1
  16. package/cli/collection-preflight.mjs +2 -2
  17. package/cli/command-catalogue.mjs +12 -3
  18. package/cli/command-reference.mjs +948 -106
  19. package/cli/completion.mjs +504 -218
  20. package/cli/config-profile.mjs +13 -3
  21. package/cli/ct-event-intake.mjs +32 -2
  22. package/cli/discover.mjs +7 -1
  23. package/cli/discovery-observation-snapshot.mjs +24 -28
  24. package/cli/discovery-scan-command-runner.mjs +13 -14
  25. package/cli/discovery-scan.mjs +17 -3
  26. package/cli/discovery-snapshot.mjs +15 -7
  27. package/cli/discovery-workflow.mjs +2 -3
  28. package/cli/doctor.mjs +2 -2
  29. package/cli/domain-control-monitor.mjs +215 -36
  30. package/cli/domain-control-observations.mjs +34 -17
  31. package/cli/errors.mjs +8 -3
  32. package/cli/evidence-command-runner.mjs +17 -11
  33. package/cli/evidence-signing.mjs +68 -50
  34. package/cli/export-evidence.mjs +11 -4
  35. package/cli/external-observation-mapping.mjs +3 -3
  36. package/cli/formatters/html.mjs +3 -3
  37. package/cli/formatters/json.mjs +20 -32
  38. package/cli/formatters/terminal.mjs +586 -22
  39. package/cli/input.mjs +62 -0
  40. package/cli/interactive-launcher.mjs +226 -0
  41. package/cli/interchange-report.mjs +226 -0
  42. package/cli/investigation-manifest.mjs +16 -17
  43. package/cli/investigation-plan.mjs +167 -16
  44. package/cli/investigation-run.mjs +80 -24
  45. package/cli/local-mmdb-review.mjs +45 -14
  46. package/cli/lookalike-calibration.mjs +3 -1
  47. package/cli/lookup-brief.mjs +13 -8
  48. package/cli/lookup-browser.mjs +844 -0
  49. package/cli/lookup-command-runner.mjs +96 -33
  50. package/cli/lookup-diff.mjs +11 -2
  51. package/cli/lookup-plan.mjs +1 -0
  52. package/cli/mail-review.mjs +206 -94
  53. package/cli/manual.mjs +5 -5
  54. package/cli/network-command-runner.mjs +78 -0
  55. package/cli/offline-evidence-review.mjs +45 -14
  56. package/cli/open-asset-model-bridge.mjs +1 -1
  57. package/cli/output-file.mjs +70 -14
  58. package/cli/page-compare.mjs +18 -10
  59. package/cli/progress-events.mjs +2 -1
  60. package/cli/progress.mjs +3 -3
  61. package/cli/registry-cohort.mjs +343 -61
  62. package/cli/registry-doctor.mjs +67 -28
  63. package/cli/registry-support.mjs +2 -1
  64. package/cli/retained-artifact-diff.mjs +375 -0
  65. package/cli/risk-calibration.mjs +89 -40
  66. package/cli/runner.mjs +322 -246
  67. package/cli/saved-lookup.mjs +439 -49
  68. package/cli/sharing-review.mjs +18 -7
  69. package/cli/source-reliability.mjs +75 -36
  70. package/cli/terminal-presentation.mjs +43 -18
  71. package/docs/cli-reference.md +125 -1334
  72. package/docs/cli.md +87 -236
  73. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
  74. package/frontend/src/lib/analysis/domain-control-records.js +3 -0
  75. package/lib/application-version.mjs +3 -0
  76. package/lib/availability.mjs +72 -52
  77. package/lib/bounded-contract-normalizers.mjs +2 -4
  78. package/lib/bounded-file.mjs +91 -4
  79. package/lib/bounded-json.mjs +287 -0
  80. package/lib/browser-library-profile.mjs +1 -1
  81. package/lib/certificate-extension-profile.mjs +2 -1
  82. package/lib/classify.mjs +56 -5
  83. package/lib/client-behavior-profile.mjs +1 -1
  84. package/lib/credential-surface-profile.mjs +1 -1
  85. package/lib/cryptographic-assurance.mjs +173 -0
  86. package/lib/ct-query.mjs +3 -3
  87. package/lib/ct-search.mjs +120 -64
  88. package/lib/distributed-operation-budget.mjs +4 -2
  89. package/lib/dns-delegation-health.mjs +9 -6
  90. package/lib/dns-intelligence.mjs +4 -3
  91. package/lib/dns-resolver-selection.mjs +6 -4
  92. package/lib/dnssec-chain-validation.mjs +1052 -0
  93. package/lib/dnssec-evidence-validation.mjs +23 -10
  94. package/lib/domain-assurance.mjs +12 -12
  95. package/lib/domain-change-packet.mjs +7 -5
  96. package/lib/domain-control-flight-recorder.mjs +442 -78
  97. package/lib/domain-control-manifest.mjs +314 -171
  98. package/lib/domain-posture-analysis.mjs +5 -2
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/encrypted-dns-contract.mjs +2 -3
  101. package/lib/evidence-export.mjs +1243 -88
  102. package/lib/evidence-report-markdown.mjs +3 -0
  103. package/lib/evidence-report.mjs +5 -0
  104. package/lib/external-intelligence-risk.mjs +26 -10
  105. package/lib/favicon.mjs +8 -5
  106. package/lib/feature-policy.mjs +1 -1
  107. package/lib/homepage-metadata-contract.mjs +290 -0
  108. package/lib/hostname.mjs +1 -1
  109. package/lib/html-signals.mjs +89 -5
  110. package/lib/http-evidence-bounds.mjs +3 -0
  111. package/lib/http-intelligence.mjs +332 -6
  112. package/lib/interchange-fidelity-registry.mjs +124 -0
  113. package/lib/local-geoip-evidence.mjs +2 -1
  114. package/lib/lookup-cache.mjs +6 -4
  115. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  116. package/lib/lookup.mjs +27 -4
  117. package/lib/observed-network-context.mjs +1 -1
  118. package/lib/outbound-identity.mjs +2 -3
  119. package/lib/page-fingerprints.mjs +2 -2
  120. package/lib/page-role-profile.mjs +1 -1
  121. package/lib/perceptual-hash.mjs +12 -4
  122. package/lib/portable-generator.mjs +29 -0
  123. package/lib/provider-policy-admission.mjs +11 -4
  124. package/lib/rdap-bootstrap.mjs +76 -16
  125. package/lib/rdap-client.mjs +20 -12
  126. package/lib/rdap-normalization.mjs +324 -80
  127. package/lib/rdap-registrar.mjs +5 -1
  128. package/lib/rdap-transport.mjs +2 -2
  129. package/lib/rdap-validation.mjs +65 -14
  130. package/lib/rdap.mjs +2 -2
  131. package/lib/registrable-domain.mjs +36 -0
  132. package/lib/registry-capabilities.mjs +3 -0
  133. package/lib/registry-capability-catalogue.mjs +12 -2
  134. package/lib/registry-comparison.mjs +28 -11
  135. package/lib/registry-insights.mjs +7 -0
  136. package/lib/response-policy.mjs +27 -4
  137. package/lib/risk-calibration-summary.mjs +340 -0
  138. package/lib/risk-scoring.mjs +4 -3
  139. package/lib/rpki-evidence.mjs +9 -8
  140. package/lib/safe-fetch.mjs +121 -50
  141. package/lib/security-txt.mjs +27 -4
  142. package/lib/service-binding-dns.mjs +6 -1
  143. package/lib/smtp-transport-review.mjs +787 -0
  144. package/lib/sslbl-certificates.generated.mjs +136 -134
  145. package/lib/sslbl-intelligence.mjs +6 -8
  146. package/lib/static-html-analysis.mjs +301 -4
  147. package/lib/structured-data-identity.mjs +51 -19
  148. package/lib/threat-intelligence-fixtures.mjs +2 -4
  149. package/lib/threat-intelligence-manifests.mjs +2 -7
  150. package/lib/threat-intelligence-runtime.mjs +5 -15
  151. package/lib/threat-intelligence-types.mjs +21 -1
  152. package/lib/threatfox-intelligence.mjs +7 -3
  153. package/lib/tls-intelligence.mjs +15 -9
  154. package/lib/tlsa-evidence.mjs +37 -7
  155. package/lib/urlhaus-intelligence.mjs +4 -2
  156. package/lib/urlscan-intelligence.mjs +6 -5
  157. package/lib/website-security-posture.mjs +1 -1
  158. package/lib/website-technology.mjs +1 -1
  159. package/lib/whois-authority.mjs +43 -8
  160. package/lib/whois-chain.mjs +25 -3
  161. package/lib/whois-normalization.mjs +3 -1
  162. package/lib/whois-transport.mjs +12 -3
  163. package/lib/zone-intent-review.mjs +1 -1
  164. package/package.json +12 -4
  165. package/packages/cases/case-brand-profile-references.mjs +85 -0
  166. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
  167. package/packages/cases/case-investigation-branch-model.mjs +132 -0
  168. package/packages/cases/case-migration-model.mjs +476 -0
  169. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  170. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -19
  171. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  172. package/packages/cases/case-record-model.mjs +9 -0
  173. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +106 -33
  174. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
  175. package/packages/cases/case-response-model.mjs +1604 -0
  176. package/packages/cases/case-response-packet.mjs +1312 -0
  177. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  178. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  179. package/packages/cases/opaque-reference-id.mjs +9 -0
  180. package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
  181. package/packages/comparison/comparison-ledger-contract.mjs +241 -0
  182. package/packages/comparison/comparison-ledger-json.mjs +80 -0
  183. package/packages/comparison/comparison-ledger-serialization.mjs +52 -0
  184. package/packages/comparison/favicon-similarity.mjs +78 -0
  185. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  186. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  187. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  188. package/packages/contracts/analyst-review-state.mjs +50 -0
  189. package/packages/contracts/case-portability.mjs +1310 -0
  190. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  191. package/packages/contracts/cli-lookup.mjs +394 -0
  192. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  193. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  194. package/packages/contracts/domain-control-monitor.mjs +310 -0
  195. package/packages/contracts/domain-control-review.mjs +828 -0
  196. package/packages/contracts/domain-name.mjs +4 -0
  197. package/packages/contracts/external-observation-interchange.mjs +224 -0
  198. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  199. package/packages/contracts/investigation-portability.mjs +331 -0
  200. package/packages/contracts/offline-comparison.mjs +160 -0
  201. package/packages/contracts/risk-calibration.mjs +1288 -0
  202. package/packages/contracts/schema-compatibility.mjs +71 -0
  203. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  204. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  205. package/packages/contracts/workspace-portability.mjs +1347 -0
  206. package/packages/evidence/artifact-integrity.mjs +65 -0
  207. package/packages/evidence/decision-fact.mjs +735 -0
  208. package/packages/evidence/domain-control-runtime.mjs +547 -0
  209. package/packages/evidence/domain-name.mjs +41 -0
  210. package/packages/evidence/observation.mjs +384 -0
  211. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +86 -20
  212. package/packages/interchange/retained-text.mjs +8 -0
  213. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
  214. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
  215. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
  216. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
  217. package/packages/investigation/bulk-review-policy.mjs +1 -0
  218. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
  219. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  220. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  221. package/packages/investigation/lookup-claim-passport.mjs +232 -0
  222. package/packages/investigation/lookup-claim-readiness.mjs +287 -0
  223. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  224. package/packages/monitoring/analyst-review-state.mjs +680 -0
  225. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +97 -50
  226. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
  227. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
  228. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -5
  229. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  230. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  231. package/packages/workspace/hostile-input.mjs +172 -0
  232. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +51 -46
  233. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
  234. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  235. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  236. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  237. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  238. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  239. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +18 -12
  240. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  241. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +195 -84
  242. package/third-party-notices.txt +112 -5834
  243. package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
  244. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  245. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  246. package/frontend/src/lib/analysis/case-migration-model.js +0 -253
  247. package/frontend/src/lib/analysis/case-record-model.js +0 -7
  248. package/frontend/src/lib/analysis/case-response-model.js +0 -669
  249. package/frontend/src/lib/analysis/case-response-packet.js +0 -573
  250. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
  251. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  252. package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
  253. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  254. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  255. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  256. package/frontend/src/lib/analysis/scoring.js +0 -55
  257. package/frontend/src/lib/analysis/utils.js +0 -232
  258. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  259. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  260. package/frontend/src/lib/analysis/visualization-matrix.js +0 -88
  261. package/frontend/src/lib/analysis/visualization-models.js +0 -369
  262. package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
  263. package/lib/bounded-json-response.mjs +0 -157
  264. package/lib/lookup-response-contract.mjs +0 -346
  265. package/lib/observation.mjs +0 -114
  266. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  267. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  268. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -0,0 +1,173 @@
1
+ import { validateDnssecEvidence } from "./dnssec-evidence-validation.mjs";
2
+ import { reviewRpkiRoute } from "./rpki-evidence.mjs";
3
+ import { analyzeTlsaEvidence } from "./tlsa-evidence.mjs";
4
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
5
+ const CRYPTOGRAPHIC_ASSURANCE_INPUT_SCHEMA = 'whoisleuth.cryptographic-assurance.input';
6
+ const CRYPTOGRAPHIC_ASSURANCE_SCHEMA = 'whoisleuth.cryptographic-assurance.review';
7
+ const CRYPTOGRAPHIC_ASSURANCE_INPUT_VERSION = 1;
8
+ const CRYPTOGRAPHIC_ASSURANCE_REVIEW_VERSION = 2;
9
+ function exactRecord(value, keys, label) {
10
+ if (!value || typeof value !== 'object' || Array.isArray(value))
11
+ throw new TypeError(`${label} must be one object.`);
12
+ const record = value;
13
+ const unexpected = Object.keys(record).find((key) => !keys.has(key));
14
+ if (unexpected)
15
+ throw new TypeError(`${label} contains unsupported field "${unexpected}".`);
16
+ return record;
17
+ }
18
+ function sourceLabel(value, label) {
19
+ if (typeof value !== 'string')
20
+ throw new TypeError(`${label}.source must be text.`);
21
+ const normalized = value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim();
22
+ if (!normalized || normalized.length > 160)
23
+ throw new TypeError(`${label}.source must contain from 1 to 160 characters.`);
24
+ return normalized;
25
+ }
26
+ function observationTime(value, label) {
27
+ const normalized = normalizeExplicitIsoTimestamp(value);
28
+ if (!normalized) {
29
+ throw new TypeError(`${label}.observedAt must be one ISO-compatible timestamp.`);
30
+ }
31
+ return normalized;
32
+ }
33
+ function suppliedEvidence(value, label) {
34
+ if (value === null || value === undefined)
35
+ return null;
36
+ const wrapper = exactRecord(value, new Set(['source', 'observedAt', 'evidence']), label);
37
+ if (!wrapper.evidence || typeof wrapper.evidence !== 'object' || Array.isArray(wrapper.evidence)) {
38
+ throw new TypeError(`${label}.evidence must be one object.`);
39
+ }
40
+ return {
41
+ source: sourceLabel(wrapper.source, label),
42
+ observedAt: observationTime(wrapper.observedAt, label),
43
+ evidence: wrapper.evidence,
44
+ };
45
+ }
46
+ function unavailableCard(family, label, authority) {
47
+ return Object.freeze({
48
+ family,
49
+ label,
50
+ authority,
51
+ source: null,
52
+ observedAt: null,
53
+ state: 'unavailable',
54
+ completeness: 'unavailable',
55
+ result: null,
56
+ limitations: Object.freeze([`No ${label.toLowerCase()} evidence was supplied. No state was inferred from another assurance family.`]),
57
+ });
58
+ }
59
+ function dnssecCompleteness(report) {
60
+ return report.state === 'consistent' || report.state === 'conflict' || report.state === 'unsigned'
61
+ ? 'complete'
62
+ : report.state === 'invalid' ? 'unavailable' : 'partial';
63
+ }
64
+ function rpkiCompleteness(report) {
65
+ return report.state === 'invalid_input' ? 'unavailable' : 'partial';
66
+ }
67
+ function rpkiCardState(report) {
68
+ if (report.state === 'valid')
69
+ return 'matches_supplied_rows';
70
+ if (report.state === 'invalid')
71
+ return 'differs_from_supplied_rows';
72
+ if (report.state === 'not_found')
73
+ return 'not_found_in_supplied_rows';
74
+ return report.state;
75
+ }
76
+ function tlsaCompleteness(report) {
77
+ const comparisonMaterialComplete = !report.truncated
78
+ && report.rejectedCount === 0
79
+ && !report.authorityMaterialTruncated
80
+ && report.authorityMaterialRejectedCount === 0
81
+ && report.records.every((record) => record.state !== 'unavailable');
82
+ return comparisonMaterialComplete
83
+ && (report.state === 'matched' || report.state === 'different' || report.state === 'untrusted')
84
+ ? 'complete'
85
+ : report.state === 'invalid' || report.state === 'unavailable' ? 'unavailable' : 'partial';
86
+ }
87
+ function buildCryptographicAssuranceReview(inputValue, generatedAt = new Date().toISOString()) {
88
+ const input = exactRecord(inputValue, new Set(['schema', 'version', 'dnssec', 'routeOrigin', 'tlsa']), 'Cryptographic assurance input');
89
+ if (input.schema !== CRYPTOGRAPHIC_ASSURANCE_INPUT_SCHEMA || input.version !== CRYPTOGRAPHIC_ASSURANCE_INPUT_VERSION) {
90
+ throw new TypeError(`Cryptographic assurance input must use ${CRYPTOGRAPHIC_ASSURANCE_INPUT_SCHEMA} version ${CRYPTOGRAPHIC_ASSURANCE_INPUT_VERSION}.`);
91
+ }
92
+ const dnssecInput = suppliedEvidence(input.dnssec, 'dnssec');
93
+ const routeInput = suppliedEvidence(input.routeOrigin, 'routeOrigin');
94
+ const tlsaInput = suppliedEvidence(input.tlsa, 'tlsa');
95
+ if (!dnssecInput && !routeInput && !tlsaInput)
96
+ throw new TypeError('Cryptographic assurance input must supply at least one evidence family.');
97
+ const dnssec = dnssecInput ? validateDnssecEvidence({
98
+ ownerName: dnssecInput.evidence.ownerName,
99
+ delegationSigned: dnssecInput.evidence.delegationSigned,
100
+ dsRecords: dnssecInput.evidence.dsRecords,
101
+ dnskeyRecords: dnssecInput.evidence.dnskeyRecords,
102
+ rrSigRecords: dnssecInput.evidence.rrSigRecords,
103
+ observedAt: dnssecInput.evidence.observedAt ?? dnssecInput.observedAt,
104
+ }) : null;
105
+ const routeOrigin = routeInput ? reviewRpkiRoute({
106
+ routePrefix: routeInput.evidence.routePrefix,
107
+ originAsn: routeInput.evidence.originAsn,
108
+ authorizations: routeInput.evidence.authorizations,
109
+ }) : null;
110
+ const tlsa = tlsaInput ? analyzeTlsaEvidence({
111
+ serviceName: tlsaInput.evidence.serviceName,
112
+ dnssecState: 'unavailable',
113
+ pkixValidationState: 'unavailable',
114
+ records: tlsaInput.evidence.records,
115
+ certificateDerBase64: tlsaInput.evidence.certificateDerBase64,
116
+ spkiDerBase64: tlsaInput.evidence.spkiDerBase64,
117
+ authorityMaterials: tlsaInput.evidence.authorityMaterials,
118
+ }) : null;
119
+ const cards = [
120
+ dnssec && dnssecInput ? Object.freeze({
121
+ family: 'dnssec_validation',
122
+ label: 'DNSSEC evidence review',
123
+ authority: 'Supplied delegation, DS, DNSKEY, and signature-time evidence',
124
+ source: dnssecInput.source,
125
+ observedAt: dnssecInput.observedAt,
126
+ state: dnssec.state,
127
+ completeness: dnssecCompleteness(dnssec),
128
+ result: dnssec,
129
+ limitations: dnssec.limitations,
130
+ }) : unavailableCard('dnssec_validation', 'DNSSEC evidence review', 'Supplied delegation, DS, DNSKEY, and signature-time evidence'),
131
+ routeOrigin && routeInput ? Object.freeze({
132
+ family: 'route_origin_authorisation',
133
+ label: 'Route-origin authorisation',
134
+ authority: 'Analyst-supplied route-origin rows',
135
+ source: routeInput.source,
136
+ observedAt: routeInput.observedAt,
137
+ state: rpkiCardState(routeOrigin),
138
+ completeness: rpkiCompleteness(routeOrigin),
139
+ result: routeOrigin,
140
+ limitations: Object.freeze([
141
+ ...routeOrigin.limitations,
142
+ 'The supplied rows do not include bounded validator provenance, implementation identity, validation time, or a complete authenticated snapshot, so this card reports only their local comparison and remains partial.',
143
+ ]),
144
+ }) : unavailableCard('route_origin_authorisation', 'Route-origin authorisation', 'Analyst-supplied route-origin rows'),
145
+ tlsa && tlsaInput ? Object.freeze({
146
+ family: 'dane_tlsa',
147
+ label: 'DANE and TLSA evidence review',
148
+ authority: 'Supplied TLSA and certificate association material',
149
+ source: tlsaInput.source,
150
+ observedAt: tlsaInput.observedAt,
151
+ state: tlsa.state,
152
+ completeness: tlsaCompleteness(tlsa),
153
+ result: tlsa,
154
+ limitations: Object.freeze([
155
+ ...tlsa.limitations,
156
+ 'Caller-provided DNSSEC and PKIX state labels are not accepted as validator evidence. Positive DANE state requires separately validated prerequisite artefacts from an explicit collector.',
157
+ ]),
158
+ }) : unavailableCard('dane_tlsa', 'DANE and TLSA evidence review', 'Supplied TLSA and certificate association material'),
159
+ ];
160
+ return Object.freeze({
161
+ schema: CRYPTOGRAPHIC_ASSURANCE_SCHEMA,
162
+ version: CRYPTOGRAPHIC_ASSURANCE_REVIEW_VERSION,
163
+ generatedAt: observationTime(generatedAt, 'generatedAt'),
164
+ cards: Object.freeze(cards),
165
+ combinedState: null,
166
+ limitations: Object.freeze([
167
+ 'DNSSEC, route-origin authorisation, and DANE or TLSA remain independently sourced evidence families with separate states, observation times, completeness, and limitations.',
168
+ 'No scalar score or combined verdict is produced. A missing, failed, partial, or unsupported family is never inferred from another family.',
169
+ 'These reviews do not establish ownership, availability, activity, safety, or maliciousness.',
170
+ ]),
171
+ });
172
+ }
173
+ export { CRYPTOGRAPHIC_ASSURANCE_INPUT_SCHEMA, CRYPTOGRAPHIC_ASSURANCE_INPUT_VERSION, CRYPTOGRAPHIC_ASSURANCE_REVIEW_VERSION, CRYPTOGRAPHIC_ASSURANCE_SCHEMA, buildCryptographicAssuranceReview, };
package/lib/ct-query.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  const MAX_CT_QUERY_LENGTH = 200;
2
2
  const CT_QUERY_ERROR_CODE = 'INVALID_CT_QUERY';
3
- const CT_QUERY_ERROR_MESSAGE = `Certificate Transparency query must be at most ${MAX_CT_QUERY_LENGTH} characters and contain no control characters.`;
4
- const CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/u;
3
+ const CT_QUERY_ERROR_MESSAGE = `Certificate Transparency query must be at most ${MAX_CT_QUERY_LENGTH} characters and contain no control or invisible formatting characters.`;
4
+ const UNSAFE_QUERY_CHARACTER_RE = /[\u0000-\u001f\u007f-\u009f]|\p{Default_Ignorable_Code_Point}/u;
5
5
  class CtQueryError extends Error {
6
6
  code = CT_QUERY_ERROR_CODE;
7
7
  constructor() {
@@ -14,7 +14,7 @@ function normalizeCtQuery(value) {
14
14
  return '';
15
15
  if (typeof value !== 'string'
16
16
  || value.length > MAX_CT_QUERY_LENGTH
17
- || CONTROL_CHARACTER_RE.test(value)) {
17
+ || UNSAFE_QUERY_CHARACTER_RE.test(value)) {
18
18
  throw new CtQueryError();
19
19
  }
20
20
  return value.trim();
package/lib/ct-search.mjs CHANGED
@@ -10,7 +10,9 @@ import { parse } from 'tldts';
10
10
  import { normalizeCtQuery } from "./ct-query.mjs";
11
11
  import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
12
12
  import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
13
- import { createObservation } from "./observation.mjs";
13
+ import { createObservation } from "../packages/evidence/observation.mjs";
14
+ import { isValidAsciiHostname } from "./hostname.mjs";
15
+ import { normalizeCtTimestamp } from "../packages/evidence/observation.mjs";
14
16
  import { MAX_CT_RESPONSE_CERTIFICATE_GROUPS, MAX_CT_RESPONSE_DOMAINS_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_MATCH, MAX_CT_RESPONSE_RESULTS, MAX_CT_RESPONSE_TIMESTAMP_LENGTH, } from "./ct-response-bounds.mjs";
15
17
  const CRT_SH_TIMEOUT_MS = 20000;
16
18
  const MAX_RESULTS = MAX_CT_RESPONSE_RESULTS;
@@ -40,6 +42,12 @@ const MAX_TIMEOUT_RETRIES = 1;
40
42
  // iteration/set overhead after parsing. Legitimate responses under the byte
41
43
  // cap stay well within this limit.
42
44
  const MAX_CT_ROWS = 50_000;
45
+ // crt.sh joins SAN values with newlines. Bound the amount of per-row and
46
+ // whole-response hostname work independently of the JSON byte and row caps so
47
+ // a small number of newline-dense strings cannot drive unbounded PSL parsing,
48
+ // Set growth, or sorting before the result projection is truncated.
49
+ const MAX_CT_NAMES_PER_ROW = 1_000;
50
+ const MAX_CT_NAMES_EXAMINED = 100_000;
43
51
  // Structured-match bounds.
44
52
  const MAX_MATCHES = MAX_RESULTS; // 500
45
53
  const MAX_HOSTNAMES_PER_MATCH = MAX_CT_RESPONSE_HOSTNAMES_PER_MATCH;
@@ -56,10 +64,12 @@ const MAX_SERIAL_LENGTH = 128;
56
64
  // Maximum length for an entry_timestamp string before it is rejected.
57
65
  const MAX_TIMESTAMP_LENGTH = MAX_CT_RESPONSE_TIMESTAMP_LENGTH;
58
66
  function normalizeHostname(raw) {
67
+ if (raw.length > 255)
68
+ return null;
59
69
  let h = raw.trim().toLowerCase();
60
70
  if (h.startsWith('*.'))
61
71
  h = h.slice(2);
62
- return HOSTNAME_RE.test(h) ? h : null;
72
+ return HOSTNAME_RE.test(h) && isValidAsciiHostname(h, { requireLowercase: true }) ? h : null;
63
73
  }
64
74
  function delay(ms) {
65
75
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -75,7 +85,7 @@ async function fetchCrtSh(keyword, attempt = 0, dependencies = {}) {
75
85
  res = await fetcher(`https://crt.sh/?q=${encodeURIComponent(keyword)}&output=json`, {
76
86
  headers: whoisleuthRequestHeaders({ Accept: 'application/json' }),
77
87
  signal: controller.signal,
78
- });
88
+ }, 0);
79
89
  }
80
90
  catch (err) {
81
91
  if (!(err instanceof Error) || err.name !== 'AbortError')
@@ -98,7 +108,7 @@ async function fetchCrtSh(keyword, attempt = 0, dependencies = {}) {
98
108
  ? `crt.sh is temporarily overloaded (${res.status} after ${attempt + 1} attempts) - it's a free public service that intermittently struggles under load; try again in a moment.`
99
109
  : `crt.sh returned ${res.status}`);
100
110
  }
101
- const { text, truncated } = await readTextCapped(res, CRT_SH_MAX_BYTES);
111
+ const { text, truncated } = await readTextCapped(res, CRT_SH_MAX_BYTES, { fatalUtf8: true });
102
112
  if (truncated) {
103
113
  throw new Error(`crt.sh returned more than ${CRT_SH_MAX_BYTES / (1024 * 1024)}MB of results for "${keyword}" - try a narrower/more specific keyword.`);
104
114
  }
@@ -146,6 +156,16 @@ function resolveCertId(row, index) {
146
156
  }
147
157
  return `row:${index}`;
148
158
  }
159
+ function suppliedCertificateIdentityMalformed(row) {
160
+ const hasId = Object.hasOwn(row, 'id') && row.id !== null && row.id !== undefined;
161
+ if (hasId)
162
+ return canonicalCertId(row.id) === null;
163
+ const hasIssuer = Object.hasOwn(row, 'issuer_ca_id') && row.issuer_ca_id !== null && row.issuer_ca_id !== undefined;
164
+ const hasSerial = Object.hasOwn(row, 'serial_number') && row.serial_number !== null && row.serial_number !== undefined;
165
+ return hasIssuer || hasSerial
166
+ ? canonicalCertId(row.issuer_ca_id) === null || normalizeSerial(row.serial_number) === null
167
+ : false;
168
+ }
149
169
  /**
150
170
  * Canonicalises a crt.sh certificate ID field for use in identity comparison.
151
171
  * Accepts only safe positive integers or digit-only strings that can be
@@ -221,20 +241,9 @@ function normalizeSerial(value) {
221
241
  * @returns {string | null}
222
242
  */
223
243
  function validateEntryTimestamp(value) {
224
- if (typeof value !== 'string')
225
- return null;
226
- if (value.length === 0 || value.length > MAX_TIMESTAMP_LENGTH)
227
- return null;
228
- // Reject control characters and overlong strings before any parsing.
229
- if (/[\x00-\x1f\x7f]/.test(value))
230
- return null;
231
- const trimmed = value.trim();
232
- if (trimmed.length === 0)
233
- return null;
234
- const parsed = new Date(trimmed);
235
- if (Number.isNaN(parsed.getTime()))
236
- return null;
237
- return parsed.toISOString();
244
+ return typeof value === 'string' && value.length <= MAX_TIMESTAMP_LENGTH
245
+ ? normalizeCtTimestamp(value)
246
+ : null;
238
247
  }
239
248
  // ---------------------------------------------------------------------------
240
249
  // Pure summarizer
@@ -255,6 +264,8 @@ function validateEntryTimestamp(value) {
255
264
  * certificateCount: number
256
265
  * }>,
257
266
  * certificateGroups: CtCertificateGroup[],
267
+ * namesExamined: number,
268
+ * workTruncated: boolean,
258
269
  * truncated: boolean
259
270
  * }}
260
271
  */
@@ -275,14 +286,34 @@ function summarizeCtResults(rows) {
275
286
  // firstObservedAt: string|null, lastObservedAt: string|null }>
276
287
  const groupMap = new Map();
277
288
  const certificateMap = new Map();
278
- for (let i = 0; i < rows.length; i++) {
289
+ let namesExamined = 0;
290
+ let workTruncated = false;
291
+ let rejectedRows = 0;
292
+ rowLoop: for (let i = 0; i < rows.length; i++) {
279
293
  const row = rows[i];
280
- if (!row || typeof row !== 'object')
294
+ if (!row || typeof row !== 'object' || Array.isArray(row)) {
295
+ rejectedRows += 1;
281
296
  continue;
297
+ }
298
+ const missingNameFields = ![row.name_value, row.common_name]
299
+ .some((value) => typeof value === 'string'
300
+ && /[^\p{White_Space}\p{Default_Ignorable_Code_Point}\u0000-\u001f\u007f-\u009f]/u.test(value));
301
+ const malformedNameField = ['name_value', 'common_name'].some((field) => (Object.hasOwn(row, field) && typeof row[field] !== 'string'));
302
+ const malformedTimestamp = Object.hasOwn(row, 'entry_timestamp')
303
+ && row.entry_timestamp !== null
304
+ && row.entry_timestamp !== undefined
305
+ && validateEntryTimestamp(row.entry_timestamp) === null;
306
+ if (missingNameFields || malformedNameField || malformedTimestamp || suppliedCertificateIdentityMalformed(row)) {
307
+ rejectedRows += 1;
308
+ }
282
309
  const certId = resolveCertId(row, i);
283
- // Collect hostnames from name_value and common_name.
284
- const rawBlob = `${row.name_value || ''}\n${row.common_name || ''}`;
285
- const blob = rawBlob;
310
+ // Collect only direct string fields. Implicitly stringifying a nested
311
+ // array or object would traverse attacker-controlled structure before any
312
+ // hostname bound could take effect.
313
+ const nameFields = [
314
+ typeof row.name_value === 'string' ? row.name_value : '',
315
+ typeof row.common_name === 'string' ? row.common_name : '',
316
+ ];
286
317
  const ts = validateEntryTimestamp(row.entry_timestamp);
287
318
  let certificate = certificateMap.get(certId);
288
319
  if (!certificate) {
@@ -297,46 +328,65 @@ function summarizeCtResults(rows) {
297
328
  else if (ts !== null && (certificate.observedAt === null || ts > certificate.observedAt)) {
298
329
  certificate.observedAt = ts;
299
330
  }
300
- if (rawBlob.split('\n').some((line) => line.trim().startsWith('*.')))
301
- certificate.wildcardObserved = true;
302
- for (const line of blob.split('\n')) {
303
- const host = normalizeHostname(line);
304
- if (!host)
305
- continue;
306
- // Legacy: always add if valid hostname.
307
- legacyDomains.add(host);
308
- // Structured: resolve registrable domain.
309
- let regDomain;
310
- if (registrableDomainByHostname.has(host)) {
311
- regDomain = registrableDomainByHostname.get(host);
312
- }
313
- else {
314
- regDomain = parse(host).domain || null;
315
- registrableDomainByHostname.set(host, regDomain);
316
- }
317
- if (!regDomain)
318
- continue;
319
- certificate.hostnames.add(host);
320
- certificate.domains.add(regDomain);
321
- let group = groupMap.get(regDomain);
322
- if (!group) {
323
- group = {
324
- hostnames: new Set(),
325
- certIds: new Set(),
326
- firstObservedAt: null,
327
- lastObservedAt: null,
328
- };
329
- groupMap.set(regDomain, group);
330
- }
331
- group.hostnames.add(host);
332
- group.certIds.add(certId);
333
- if (ts !== null) {
334
- if (group.firstObservedAt === null || ts < group.firstObservedAt) {
335
- group.firstObservedAt = ts;
331
+ let rowNamesExamined = 0;
332
+ for (const field of nameFields) {
333
+ let offset = 0;
334
+ while (offset <= field.length) {
335
+ if (namesExamined >= MAX_CT_NAMES_EXAMINED) {
336
+ workTruncated = true;
337
+ break rowLoop;
338
+ }
339
+ if (rowNamesExamined >= MAX_CT_NAMES_PER_ROW) {
340
+ workTruncated = true;
341
+ continue rowLoop;
336
342
  }
337
- if (group.lastObservedAt === null || ts > group.lastObservedAt) {
338
- group.lastObservedAt = ts;
343
+ const newline = field.indexOf('\n', offset);
344
+ const line = newline === -1 ? field.slice(offset) : field.slice(offset, newline);
345
+ namesExamined += 1;
346
+ rowNamesExamined += 1;
347
+ if (line.trim().startsWith('*.'))
348
+ certificate.wildcardObserved = true;
349
+ const host = normalizeHostname(line);
350
+ if (host) {
351
+ // Legacy: always add if valid hostname.
352
+ legacyDomains.add(host);
353
+ // Structured: resolve registrable domain.
354
+ let regDomain;
355
+ if (registrableDomainByHostname.has(host)) {
356
+ regDomain = registrableDomainByHostname.get(host);
357
+ }
358
+ else {
359
+ regDomain = parse(host).domain || null;
360
+ registrableDomainByHostname.set(host, regDomain);
361
+ }
362
+ if (regDomain) {
363
+ certificate.hostnames.add(host);
364
+ certificate.domains.add(regDomain);
365
+ let group = groupMap.get(regDomain);
366
+ if (!group) {
367
+ group = {
368
+ hostnames: new Set(),
369
+ certIds: new Set(),
370
+ firstObservedAt: null,
371
+ lastObservedAt: null,
372
+ };
373
+ groupMap.set(regDomain, group);
374
+ }
375
+ group.hostnames.add(host);
376
+ group.certIds.add(certId);
377
+ if (ts !== null) {
378
+ if (group.firstObservedAt === null || ts < group.firstObservedAt) {
379
+ group.firstObservedAt = ts;
380
+ }
381
+ if (group.lastObservedAt === null || ts > group.lastObservedAt) {
382
+ group.lastObservedAt = ts;
383
+ }
384
+ }
385
+ }
339
386
  }
387
+ if (newline === -1)
388
+ break;
389
+ offset = newline + 1;
340
390
  }
341
391
  }
342
392
  }
@@ -404,7 +454,10 @@ function summarizeCtResults(rows) {
404
454
  wildcardObserved: group.wildcardObserved,
405
455
  })),
406
456
  certificateGroupsTruncated,
407
- truncated: legacyTruncated || matchTruncated || perMatchTruncated,
457
+ namesExamined,
458
+ workTruncated,
459
+ rejectedRows,
460
+ truncated: rejectedRows > 0 || workTruncated || legacyTruncated || matchTruncated || perMatchTruncated,
408
461
  };
409
462
  }
410
463
  // ---------------------------------------------------------------------------
@@ -416,11 +469,12 @@ async function searchCertificateTransparency(keyword, dependencies = {}) {
416
469
  if (!trimmed) {
417
470
  return {
418
471
  domains: [], certCount: 0, truncated: false, matches: [], certificateGroups: [], certificateGroupsTruncated: false,
472
+ namesExamined: 0, workTruncated: false, rejectedRows: 0,
419
473
  observation: createObservation({
420
474
  status: 'success', observedAt: new Date().toISOString(), source: 'certificate_transparency',
421
475
  durationMs: Date.now() - startedAt, complete: true, truncated: false,
422
476
  limitations: ['Certificate Transparency observations indicate public certificate logging, not current site activity or maliciousness.'],
423
- diagnostics: { certificateRows: 0, matches: 0, certificateGroups: 0 },
477
+ diagnostics: { certificateRows: 0, rejectedRows: 0, namesExamined: 0, workTruncated: false, matches: 0, certificateGroups: 0 },
424
478
  }),
425
479
  };
426
480
  }
@@ -438,9 +492,11 @@ async function searchCertificateTransparency(keyword, dependencies = {}) {
438
492
  truncated: summary.truncated || summary.certificateGroupsTruncated,
439
493
  limitations: [
440
494
  'Certificate Transparency observations indicate public certificate logging, not current site activity or maliciousness.',
495
+ ...(summary.rejectedRows ? [`${summary.rejectedRows} certificate row${summary.rejectedRows === 1 ? '' : 's'} contained rejected content; unusable rows or malformed fields were excluded, so retained results are partial.`] : []),
496
+ ...(summary.workTruncated ? ['The response contained more hostname entries than the bounded local analysis could examine; retained results are partial.'] : []),
441
497
  ...(summary.certificateGroupsTruncated ? ['The optional certificate-group projection was capped independently of the registrable-domain result set.'] : []),
442
498
  ],
443
- diagnostics: { certificateRows: data.length, matches: summary.matches.length, certificateGroups: summary.certificateGroups.length, certificateGroupsTruncated: summary.certificateGroupsTruncated },
499
+ diagnostics: { certificateRows: data.length, rejectedRows: summary.rejectedRows, namesExamined: summary.namesExamined, workTruncated: summary.workTruncated, matches: summary.matches.length, certificateGroups: summary.certificateGroups.length, certificateGroupsTruncated: summary.certificateGroupsTruncated },
444
500
  }),
445
501
  };
446
502
  }
@@ -8,6 +8,8 @@ import crypto from 'node:crypto';
8
8
  import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
9
9
  const DEFAULT_NAMESPACE = 'whoisleuth:operation-budget:v1';
10
10
  const DEFAULT_LEASE_TTL_MS = 5 * 60 * 1000;
11
+ const MIN_LEASE_TTL_MS = 30_000;
12
+ const MAX_LEASE_TTL_MS = 15 * 60 * 1000;
11
13
  const REQUEST_TIMEOUT_MS = 4_000;
12
14
  const MAX_RESPONSE_BYTES = 16 * 1024;
13
15
  const PROVIDER_RETRY_AFTER_SECONDS = 5;
@@ -150,7 +152,7 @@ function normalizedNamespace(value = DEFAULT_NAMESPACE) {
150
152
  }
151
153
  function normalizedLeaseTtl(value = DEFAULT_LEASE_TTL_MS) {
152
154
  const ttl = Number(value);
153
- if (!Number.isSafeInteger(ttl) || ttl < 30_000 || ttl > 15 * 60 * 1000) {
155
+ if (!Number.isSafeInteger(ttl) || ttl < MIN_LEASE_TTL_MS || ttl > MAX_LEASE_TTL_MS) {
154
156
  throw new Error('The distributed-budget lease TTL must be between 30 seconds and 15 minutes');
155
157
  }
156
158
  return ttl;
@@ -458,4 +460,4 @@ function createDistributedOperationBudget(config, dependencies = {}) {
458
460
  status,
459
461
  };
460
462
  }
461
- export { DEFAULT_NAMESPACE, DEFAULT_LEASE_TTL_MS, PROVIDER_RETRY_AFTER_SECONDS, DAY_WINDOW_MS, THIRTY_DAY_WINDOW_MS, ACQUIRE_SCRIPT, ACQUIRE_WITH_USAGE_SCRIPT, RELEASE_SCRIPT, STATUS_SCRIPT, normalizedRestUrl, normalizedToken, normalizedNamespace, normalizedLimits, normalizedUsageLimits, normalizedOperationFeature, createRestCommandClient, createDistributedOperationBudget, };
463
+ export { DEFAULT_NAMESPACE, DEFAULT_LEASE_TTL_MS, MIN_LEASE_TTL_MS, MAX_LEASE_TTL_MS, REQUEST_TIMEOUT_MS, MAX_RESPONSE_BYTES, PROVIDER_RETRY_AFTER_SECONDS, MAX_USAGE_LIMIT, DAY_WINDOW_MS, THIRTY_DAY_WINDOW_MS, ACQUIRE_SCRIPT, ACQUIRE_WITH_USAGE_SCRIPT, RELEASE_SCRIPT, STATUS_SCRIPT, normalizedRestUrl, normalizedToken, normalizedNamespace, normalizedLimits, normalizedUsageLimits, normalizedOperationFeature, createRestCommandClient, createDistributedOperationBudget, };
@@ -4,7 +4,8 @@
4
4
  // availability and never treats a failed query as record absence.
5
5
  import { promises as dns } from 'node:dns';
6
6
  import * as net from 'node:net';
7
- import { createObservation } from "./observation.mjs";
7
+ import { createObservation } from "../packages/evidence/observation.mjs";
8
+ import { isValidAsciiHostname } from "./hostname.mjs";
8
9
  import { isPrivateAddress } from "./safe-fetch.mjs";
9
10
  const DNS_DELEGATION_HEALTH_VERSION = 1;
10
11
  const MAX_AUTHORITIES = 4;
@@ -26,10 +27,8 @@ function boundedText(value, maximum) {
26
27
  return value.replace(/\s+/gu, ' ').trim() || null;
27
28
  }
28
29
  function hostname(value) {
29
- const normalized = boundedText(value, 253)?.toLowerCase().replace(/\.+$/u, '') ?? '';
30
- if (!normalized || !normalized.includes('.'))
31
- return null;
32
- return normalized.split('.').every((label) => (/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/iu.test(label))) ? normalized : null;
30
+ const candidate = boundedText(value, 253)?.replace(/\.+$/u, '') ?? '';
31
+ return isValidAsciiHostname(candidate) ? candidate.toLowerCase() : null;
33
32
  }
34
33
  function hostnames(value, maximum = MAX_NAMESERVERS) {
35
34
  const output = new Set();
@@ -529,7 +528,8 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
529
528
  ? 'Fewer than two complete authority observations were available for one or more record types'
530
529
  : 'Observed A, AAAA, CAA, and MX answers agree across selected authorities', recordMatrix.map((row) => `${row.type}: ${row.state}`).join(' · '), 'Review propagation timing and the intended zone values before relying on a DNS change.')] : []),
531
530
  ];
532
- const collectionIncomplete = parentStatus === 'error'
531
+ const collectionIncomplete = parentStatus !== 'success'
532
+ || candidates.length === 0
533
533
  || parentQuery.truncated === true
534
534
  || registry.truncated
535
535
  || authorities.some((authority) => authority.state !== 'success')
@@ -557,6 +557,9 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
557
557
  'A direct answer does not prove global reachability, and a failed query is not evidence that the record is absent.',
558
558
  'DNS health does not decide registration availability, ownership, control, intent, safety, or maliciousness.',
559
559
  'The DNSSEC check compares bounded registry publication fields; it does not validate the full cryptographic chain.',
560
+ ...(!candidates.length
561
+ ? ['No eligible authority was identified, so the delegation-health observation is incomplete.']
562
+ : []),
560
563
  ],
561
564
  diagnostics: {
562
565
  authorityCount: authorities.length,
@@ -6,12 +6,13 @@ import { promises as dns } from 'node:dns';
6
6
  import * as net from 'node:net';
7
7
  import { classifyMxRecords } from "./dns-mx.mjs";
8
8
  import { collectDnsDelegationHealth, skippedDnsDelegationHealth, } from "./dns-delegation-health.mjs";
9
- import { createObservation } from "./observation.mjs";
9
+ import { createObservation } from "../packages/evidence/observation.mjs";
10
+ import { MAX_LOOKUP_DNS_RECORDS_PER_TYPE, MAX_LOOKUP_REVERSE_DNS_PTR_RECORDS, } from "./lookup-network-evidence-bounds.mjs";
10
11
  import { isPrivateAddress } from "./safe-fetch.mjs";
11
12
  import { resolveServiceBindingRecords } from "./service-binding-dns.mjs";
12
13
  const DNS_TIMEOUT_MS = 5000;
13
- const MAX_RECORDS_PER_TYPE = 16;
14
- const MAX_PTR_RECORDS = 8;
14
+ const MAX_RECORDS_PER_TYPE = MAX_LOOKUP_DNS_RECORDS_PER_TYPE;
15
+ const MAX_PTR_RECORDS = MAX_LOOKUP_REVERSE_DNS_PTR_RECORDS;
15
16
  const MAX_HOSTNAME_LENGTH = 253;
16
17
  const MAX_POLICY_LENGTH = 1024;
17
18
  const MAX_ERROR_LENGTH = 180;
@@ -1,5 +1,6 @@
1
1
  import { Resolver } from 'node:dns/promises';
2
2
  import { isIP } from 'node:net';
3
+ import { isPrivateAddress } from "./safe-fetch.mjs";
3
4
  const MAX_SELECTED_DNS_RESOLVERS = 3;
4
5
  const MAX_SELECTED_DNS_RESOLVER_TEXT = 256;
5
6
  function normalizeSelectedDnsResolvers(value) {
@@ -7,15 +8,16 @@ function normalizeSelectedDnsResolvers(value) {
7
8
  throw new TypeError(`DNS resolver selection must contain at most ${MAX_SELECTED_DNS_RESOLVER_TEXT} characters.`);
8
9
  }
9
10
  const values = [...new Set(value.split(',').map((item) => item.trim()).filter(Boolean))];
10
- if (!values.length || values.length > MAX_SELECTED_DNS_RESOLVERS || values.some((item) => isIP(item) === 0)) {
11
- throw new TypeError(`DNS resolver selection requires 1 to ${MAX_SELECTED_DNS_RESOLVERS} literal IPv4 or IPv6 addresses.`);
11
+ if (!values.length || values.length > MAX_SELECTED_DNS_RESOLVERS
12
+ || values.some((item) => isIP(item) === 0 || isPrivateAddress(item))) {
13
+ throw new TypeError(`DNS resolver selection requires 1 to ${MAX_SELECTED_DNS_RESOLVERS} publicly routable literal IPv4 or IPv6 addresses.`);
12
14
  }
13
15
  return values;
14
16
  }
15
17
  function createSelectedDnsResolvers(servers) {
16
18
  if (!Array.isArray(servers) || !servers.length || servers.length > MAX_SELECTED_DNS_RESOLVERS
17
- || servers.some((item) => typeof item !== 'string' || isIP(item) === 0)) {
18
- throw new TypeError('Validated DNS resolver addresses are required.');
19
+ || servers.some((item) => typeof item !== 'string' || isIP(item) === 0 || isPrivateAddress(item))) {
20
+ throw new TypeError('Validated publicly routable DNS resolver addresses are required.');
19
21
  }
20
22
  const resolver = new Resolver();
21
23
  resolver.setServers([...servers]);