@slicedearth/whoisleuth-cli 2.3.0 → 2.4.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 (264) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +19 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +68 -6
  7. package/cli/artifact-validation/case-response.mjs +47 -41
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +83 -61
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-command-runner.mjs +2 -0
  18. package/cli/bulk-output.mjs +29 -3
  19. package/cli/case-command.mjs +195 -0
  20. package/cli/case-pack.mjs +39 -31
  21. package/cli/command-argument-grammar.mjs +4 -0
  22. package/cli/command-reference.mjs +145 -75
  23. package/cli/completion.mjs +298 -79
  24. package/cli/config-profile.mjs +7 -5
  25. package/cli/ct-event-intake.mjs +12 -12
  26. package/cli/discover.mjs +4 -3
  27. package/cli/discovery-observation-snapshot.mjs +44 -16
  28. package/cli/discovery-scan-command-runner.mjs +2 -0
  29. package/cli/discovery-scan.mjs +7 -4
  30. package/cli/discovery-workflow.mjs +1 -1
  31. package/cli/domain-control-monitor.mjs +50 -20
  32. package/cli/domain-control-observations.mjs +149 -102
  33. package/cli/errors.mjs +33 -1
  34. package/cli/evidence-command-runner.mjs +16 -5
  35. package/cli/evidence-signing.mjs +2 -2
  36. package/cli/external-observation-mapping.mjs +7 -8
  37. package/cli/formatters/terminal-lookup.mjs +18 -46
  38. package/cli/formatters/terminal-shared.mjs +5 -3
  39. package/cli/formatters/terminal-technology.mjs +114 -0
  40. package/cli/inline-command-families.mjs +1 -0
  41. package/cli/interactive-launcher.mjs +2 -160
  42. package/cli/interchange-report.mjs +4 -8
  43. package/cli/investigation-artifacts.mjs +176 -0
  44. package/cli/investigation-checkpoint.mjs +10 -0
  45. package/cli/investigation-folder.mjs +114 -0
  46. package/cli/investigation-manifest.mjs +1 -112
  47. package/cli/investigation-package-review.mjs +103 -0
  48. package/cli/investigation-plan.mjs +32 -19
  49. package/cli/investigation-run.mjs +188 -33
  50. package/cli/local-document-checkpoint.mjs +170 -0
  51. package/cli/lookup-browser.mjs +5 -12
  52. package/cli/lookup-command-runner.mjs +17 -6
  53. package/cli/lookup-diff.mjs +37 -3
  54. package/cli/lookup-plan.mjs +11 -5
  55. package/cli/lookup-reconcile.mjs +10 -8
  56. package/cli/mail-review.mjs +66 -14
  57. package/cli/manual.mjs +1 -0
  58. package/cli/network-command-runner.mjs +2 -0
  59. package/cli/output-file.mjs +31 -5
  60. package/cli/registry-doctor.mjs +3 -1
  61. package/cli/registry-support.mjs +1 -1
  62. package/cli/retained-artifact-diff.mjs +2 -2
  63. package/cli/review-command-runner.mjs +45 -0
  64. package/cli/runner.mjs +55 -6
  65. package/cli/sharing-review.mjs +21 -34
  66. package/cli/signer-trust.mjs +104 -0
  67. package/cli/source-reliability.mjs +74 -83
  68. package/cli/terminal-input.mjs +168 -0
  69. package/cli/workflow-command-runner.mjs +68 -13
  70. package/docs/cli-reference.md +76 -30
  71. package/docs/cli.md +289 -25
  72. package/lib/abort.mjs +4 -1
  73. package/lib/availability.mjs +161 -112
  74. package/lib/bounded-file.mjs +3 -1
  75. package/lib/bounded-html-document.mjs +125 -0
  76. package/lib/bounded-json.mjs +43 -3
  77. package/lib/browser-library-profile.mjs +42 -51
  78. package/lib/browser-library-worker.mjs +112 -0
  79. package/lib/client-behavior-profile.mjs +3 -3
  80. package/lib/ct-search.mjs +11 -4
  81. package/lib/dns-delegation-health.mjs +24 -14
  82. package/lib/dns-inheritance-review.mjs +217 -0
  83. package/lib/dns-intelligence.mjs +36 -23
  84. package/lib/dns-mx.mjs +1 -1
  85. package/lib/dnssec-evidence-validation.mjs +11 -3
  86. package/lib/domain-change-packet.mjs +7 -2
  87. package/lib/domain-change-review.mjs +14 -8
  88. package/lib/domain-control-flight-recorder.mjs +219 -118
  89. package/lib/domain-control-manifest.mjs +117 -57
  90. package/lib/domain-posture-analysis.mjs +20 -7
  91. package/lib/domain-posture-parsers.mjs +88 -6
  92. package/lib/domain-posture.mjs +113 -26
  93. package/lib/evidence-export-privacy.mjs +561 -0
  94. package/lib/evidence-export.mjs +11 -592
  95. package/lib/evidence-report.mjs +3 -1
  96. package/lib/favicon.mjs +117 -41
  97. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  98. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  99. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  100. package/lib/hostname.mjs +2 -38
  101. package/lib/html-signals.mjs +82 -85
  102. package/lib/http-evidence-bounds.mjs +2 -1
  103. package/lib/http-intelligence.mjs +39 -26
  104. package/lib/lookup-cache.mjs +34 -11
  105. package/lib/lookup-child-profile-contract.mjs +24 -15
  106. package/lib/lookup-diagnostics.mjs +24 -1
  107. package/lib/lookup-progress.mjs +30 -13
  108. package/lib/lookup-response-contract.mjs +1048 -0
  109. package/lib/lookup-response.mjs +7 -10
  110. package/lib/lookup-source-progress.mjs +17 -14
  111. package/lib/lookup.mjs +48 -27
  112. package/lib/observed-network-context.mjs +43 -27
  113. package/lib/outbound-request-bounds.mjs +4 -1
  114. package/lib/page-fingerprints.mjs +69 -133
  115. package/lib/page-role-profile.mjs +12 -8
  116. package/lib/perceptual-hash.mjs +43 -14
  117. package/lib/rdap-bootstrap.mjs +72 -22
  118. package/lib/rdap-client.mjs +15 -7
  119. package/lib/rdap-normalization.mjs +33 -18
  120. package/lib/rdap-search-workbench.mjs +1 -1
  121. package/lib/rdap-transport.mjs +47 -20
  122. package/lib/rdap-types.mjs +2 -1
  123. package/lib/rdap.mjs +18 -3
  124. package/lib/registrable-domain.mjs +21 -1
  125. package/lib/registrar-standing-contract.mjs +1 -0
  126. package/lib/registry-capabilities.mjs +5 -1
  127. package/lib/registry-capability-catalogue.mjs +2 -1
  128. package/lib/registry-dates.mjs +2 -190
  129. package/lib/registry-insights.mjs +56 -18
  130. package/lib/response-policy.mjs +86 -48
  131. package/lib/risk-calibration-summary.mjs +17 -3
  132. package/lib/rpki-evidence.mjs +8 -5
  133. package/lib/safe-fetch.mjs +54 -47
  134. package/lib/security-txt.mjs +10 -7
  135. package/lib/service-binding-dns.mjs +8 -0
  136. package/lib/smtp-transport-review.mjs +27 -15
  137. package/lib/sslbl-certificates.generated.mjs +136 -135
  138. package/lib/static-html-analysis.mjs +117 -146
  139. package/lib/structured-data-identity.mjs +1 -0
  140. package/lib/technology-markup.mjs +100 -0
  141. package/lib/threat-intelligence-manifests.mjs +11 -139
  142. package/lib/threat-intelligence-runtime.mjs +24 -52
  143. package/lib/threat-intelligence-targets.mjs +6 -34
  144. package/lib/threat-intelligence-types.mjs +39 -0
  145. package/lib/typosquat-generator.mjs +19 -15
  146. package/lib/urlhaus-intelligence.mjs +8 -3
  147. package/lib/urlscan-intelligence.mjs +1 -1
  148. package/lib/website-security-posture.mjs +30 -6
  149. package/lib/website-technology.mjs +111 -71
  150. package/lib/whois-authority.mjs +14 -2
  151. package/lib/whois-chain.mjs +9 -2
  152. package/lib/whois-common-formats.mjs +5 -5
  153. package/lib/whois-contracts.mjs +2 -1
  154. package/lib/whois-dialects.mjs +2 -2
  155. package/lib/whois-parser-support.mjs +8 -1
  156. package/lib/whois-transport.mjs +23 -7
  157. package/package.json +2 -1
  158. package/packages/cases/case-attachment-model.mjs +83 -0
  159. package/packages/cases/case-evidence-model.mjs +156 -62
  160. package/packages/cases/case-export-input.mjs +53 -0
  161. package/packages/cases/case-migration-model.mjs +59 -25
  162. package/packages/cases/case-model.mjs +1 -0
  163. package/packages/cases/case-recheck-model.mjs +91 -0
  164. package/packages/cases/case-record-core.mjs +3 -2
  165. package/packages/cases/case-record-operations.mjs +89 -27
  166. package/packages/cases/case-record-projection.mjs +23 -20
  167. package/packages/cases/case-report.mjs +63 -52
  168. package/packages/cases/case-response-actions.mjs +654 -0
  169. package/packages/cases/case-response-model.mjs +41 -1129
  170. package/packages/cases/case-response-outcomes.mjs +317 -0
  171. package/packages/cases/case-response-packet-render.mjs +153 -0
  172. package/packages/cases/case-response-packet-types.mjs +1 -0
  173. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  174. package/packages/cases/case-response-packet.mjs +38 -587
  175. package/packages/cases/case-response-records.mjs +94 -0
  176. package/packages/cases/case-response-review-inputs.mjs +371 -0
  177. package/packages/cases/case-response-values.mjs +85 -0
  178. package/packages/cases/case-review-package.mjs +45 -0
  179. package/packages/cases/case-selection.mjs +46 -0
  180. package/packages/cases/case-storage-model.mjs +25 -0
  181. package/packages/cases/response-route-freshness.mjs +16 -0
  182. package/packages/comparison/capture-context.mjs +61 -0
  183. package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
  184. package/packages/comparison/page-similarity.mjs +2 -1
  185. package/packages/comparison/relationship-evidence.mjs +85 -8
  186. package/packages/comparison/relationship-provenance.mjs +93 -0
  187. package/packages/contracts/analyst-interchange.mjs +216 -0
  188. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  189. package/packages/contracts/analyst-review-state.mjs +5 -4
  190. package/packages/contracts/case-portability.mjs +279 -116
  191. package/packages/contracts/case-views-contract.mjs +12 -0
  192. package/packages/contracts/case-views.mjs +26 -0
  193. package/packages/contracts/cli-command-semantics.mjs +1 -0
  194. package/packages/contracts/cli-lookup.mjs +0 -1
  195. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  196. package/packages/contracts/domain-control-manifest.mjs +127 -73
  197. package/packages/contracts/domain-control-monitor.mjs +48 -45
  198. package/packages/contracts/domain-control-review.mjs +179 -121
  199. package/packages/contracts/domain-name.mjs +21 -0
  200. package/packages/contracts/external-observation-interchange.mjs +2 -1
  201. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  202. package/packages/contracts/http-url.mjs +2 -0
  203. package/packages/contracts/investigation-package-limits.mjs +24 -0
  204. package/packages/contracts/investigation-portability.mjs +56 -27
  205. package/packages/contracts/investigation-run.mjs +16 -0
  206. package/packages/contracts/ip-address.mjs +34 -0
  207. package/packages/contracts/lookup-evidence.mjs +35 -0
  208. package/packages/contracts/offline-comparison.mjs +1 -2
  209. package/packages/contracts/page-fingerprints.mjs +15 -0
  210. package/packages/contracts/risk-calibration.mjs +0 -1
  211. package/packages/contracts/schema-compatibility.mjs +1 -1
  212. package/packages/contracts/schema-lifecycle.mjs +83 -153
  213. package/packages/contracts/selected-file-limits.mjs +6 -0
  214. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  215. package/packages/contracts/web-capture.mjs +10 -0
  216. package/packages/contracts/workspace-portability.mjs +160 -225
  217. package/packages/evidence/artifact-integrity.mjs +8 -2
  218. package/packages/evidence/artifact-structure.mjs +105 -0
  219. package/packages/evidence/decision-fact.mjs +4 -1
  220. package/packages/evidence/domain-control-runtime.mjs +155 -27
  221. package/packages/evidence/domain-posture-context.mjs +79 -0
  222. package/packages/evidence/image-regions.mjs +56 -0
  223. package/packages/evidence/latest-observations.mjs +28 -0
  224. package/packages/evidence/lookup-target.mjs +104 -0
  225. package/packages/evidence/observation.mjs +16 -1
  226. package/packages/evidence/passphrase-encryption.mjs +44 -0
  227. package/packages/evidence/record-identity.mjs +72 -0
  228. package/packages/evidence/registry-dates.mjs +190 -0
  229. package/packages/evidence/retained-file.mjs +69 -0
  230. package/packages/evidence/review-age.mjs +14 -0
  231. package/packages/interchange/bagit.mjs +342 -0
  232. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  233. package/packages/interchange/crc32.mjs +17 -0
  234. package/packages/interchange/external-findings-import.mjs +89 -49
  235. package/packages/interchange/web-capture-import.mjs +382 -0
  236. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  237. package/packages/investigation/investigation-bagit.mjs +14 -0
  238. package/packages/investigation/investigation-capsule.mjs +33 -5
  239. package/packages/investigation/investigation-manifest.mjs +200 -0
  240. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  241. package/packages/investigation/investigation-package.mjs +200 -0
  242. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  243. package/packages/investigation/lookup-asset-graph.mjs +161 -141
  244. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  245. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  246. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  247. package/packages/monitoring/analyst-review-state.mjs +38 -90
  248. package/packages/workspace/brand-profile-model.mjs +158 -20
  249. package/packages/workspace/bulk-columns.mjs +17 -0
  250. package/packages/workspace/bulk-review-model.mjs +11 -5
  251. package/packages/workspace/bulk-session-model.mjs +95 -20
  252. package/packages/workspace/bulk-sort.mjs +1 -0
  253. package/packages/workspace/case-views.mjs +102 -0
  254. package/packages/workspace/hostile-input.mjs +29 -8
  255. package/packages/workspace/investigation-template-model.mjs +19 -4
  256. package/packages/workspace/page-baseline.mjs +18 -15
  257. package/packages/workspace/relationship-observation-model.mjs +41 -26
  258. package/packages/workspace/watchlist-history.mjs +2 -2
  259. package/packages/workspace/watchlist-store.mjs +8 -2
  260. package/packages/workspace/website-snapshot-model.mjs +70 -30
  261. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  262. package/packages/workspace/workspace-archive.mjs +85 -21
  263. package/third-party-notices.txt +29 -2
  264. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -1,190 +1,2 @@
1
- // Deterministic normalization for lifecycle dates published by RDAP and
2
- // WHOIS services. Raw upstream strings remain the provenance source; these
3
- // helpers only produce additive Date/ISO companions for comparison, storage,
4
- // and presentation.
5
- const REGISTRY_MONTHS = Object.freeze({
6
- jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
7
- jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
8
- });
9
- const REGISTRY_MONTH_NAMES = Object.freeze({
10
- january: 1, february: 2, march: 3, april: 4, may: 5, june: 6,
11
- july: 7, august: 8, september: 9, october: 10, november: 11, december: 12,
12
- });
13
- function utcDateFromParts(year, month, day, hour = 0, minute = 0, second = 0, millisecond = 0) {
14
- if (![year, month, day, hour, minute, second, millisecond].every(Number.isInteger))
15
- return null;
16
- if (year < 1000 || year > 9999 || month < 1 || month > 12 || day < 1
17
- || hour < 0 || hour > 23 || minute < 0 || minute > 59
18
- || second < 0 || second > 59 || millisecond < 0 || millisecond > 999)
19
- return null;
20
- const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second, millisecond));
21
- if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day
22
- || date.getUTCHours() !== hour || date.getUTCMinutes() !== minute
23
- || date.getUTCSeconds() !== second || date.getUTCMilliseconds() !== millisecond)
24
- return null;
25
- return date;
26
- }
27
- // Parse the explicit non-ISO formats observed across supported ccTLD WHOIS
28
- // services before considering ISO-shaped input. Runtime Date parsing treats
29
- // dotted dates ambiguously and timezone-less timestamps in the host's local
30
- // timezone, so every accepted shape is validated and normalized through UTC.
31
- function parseRegistryDate(input) {
32
- if (typeof input !== 'string')
33
- return null;
34
- // The documented .nz WHOIS examples place one space before the RFC3339
35
- // `T` separator. Canonicalize that registry-specific presentation quirk
36
- // before applying the existing strict ISO-shaped parser.
37
- const value = input.trim()
38
- .replace(/^(\d{4}-\d{2}-\d{2})[ ]+[Tt](?=\d{2}:)/, '$1T')
39
- // The .ee WHOIS service separates an otherwise ISO-shaped numeric
40
- // timezone from its clock with one space.
41
- .replace(/^(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?)[ ]+([+-]\d{2}:?\d{2})$/, '$1$2');
42
- if (!value)
43
- return null;
44
- // DD.MM.YYYY[ HH:MM:SS] - e.g. 14.03.2024 10:46:48
45
- let match = value.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}):(\d{2}):(\d{2}))?$/);
46
- if (match) {
47
- const [, day = '', month = '', year = '', hour = '0', minute = '0', second = '0'] = match;
48
- return utcDateFromParts(+year, +month, +day, +(hour || 0), +(minute || 0), +(second || 0));
49
- }
50
- // YYYY. MM. DD. - e.g. 2006. 09. 18.
51
- match = value.match(/^(\d{4})\.\s*(\d{1,2})\.\s*(\d{1,2})\.?$/);
52
- if (match) {
53
- const [, year = '', month = '', day = ''] = match;
54
- return utcDateFromParts(+year, +month, +day);
55
- }
56
- // YYYY.MM.DD HH:MM:SS - used by NASK's .pl WHOIS service.
57
- match = value.match(/^(\d{4})\.(\d{1,2})\.(\d{1,2})\s+(\d{1,2}):(\d{2}):(\d{2})$/);
58
- if (match) {
59
- const [, year = '', month = '', day = '', hour = '', minute = '', second = ''] = match;
60
- return utcDateFromParts(+year, +month, +day, +hour, +minute, +second);
61
- }
62
- // YYYY/MM/DD - an unambiguous year-first form used by CIRA WHOIS.
63
- match = value.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/);
64
- if (match) {
65
- const [, year = '', month = '', day = ''] = match;
66
- return utcDateFromParts(+year, +month, +day);
67
- }
68
- // YYYYMMDD with an optional time or Registro.br contact-reference suffix.
69
- // The raw value remains available as provenance while the validated date
70
- // and optional clock time are projected into the additive ISO companion.
71
- match = value.match(/^(\d{4})(\d{2})(\d{2})(?:\s+(\d{1,2}):(\d{2}):(\d{2})|\s+#[0-9]{1,20})?$/);
72
- if (match) {
73
- const [, year = '', month = '', day = '', hour = '0', minute = '0', second = '0'] = match;
74
- return utcDateFromParts(+year, +month, +day, +(hour || 0), +(minute || 0), +(second || 0));
75
- }
76
- // YYYY-Mon-DD. - e.g. 1999-Feb-16.
77
- match = value.match(/^(\d{4})-([A-Za-z]{3})-(\d{1,2})\.?$/);
78
- if (match) {
79
- const month = REGISTRY_MONTHS[(match[2] ?? '').toLowerCase()];
80
- return month ? utcDateFromParts(+(match[1] ?? ''), month, +(match[3] ?? '')) : null;
81
- }
82
- // DD-Mon-YYYY[ HH:MM:SS] - used by several ICANN-style ccTLD services.
83
- match = value.match(/^(\d{1,2})-([A-Za-z]{3})-(\d{4})(?:\s+(\d{1,2}):(\d{2}):(\d{2}))?$/);
84
- if (match) {
85
- const month = REGISTRY_MONTHS[(match[2] ?? '').toLowerCase()];
86
- return month
87
- ? utcDateFromParts(+(match[3] ?? ''), month, +(match[1] ?? ''), +(match[4] || 0), +(match[5] || 0), +(match[6] || 0))
88
- : null;
89
- }
90
- // DD-MM-YYYY - the day-first form published by the currently profiled
91
- // registries that use bare numeric hyphen dates. A future month-first
92
- // source must receive its own marker-gated parser rather than reusing this
93
- // global shape. The four-digit final year remains distinct from ISO below.
94
- match = value.match(/^(\d{1,2})-(\d{1,2})-(\d{4})$/);
95
- if (match) {
96
- const [, day = '', month = '', year = ''] = match;
97
- return utcDateFromParts(+year, +month, +day);
98
- }
99
- // YYYY-MM-DD HH:MM:SS (GMT+H:MM) - the explicit offset form published by
100
- // the Kazakhstan registry. Parentheses and the GMT marker are required so
101
- // unrelated prose is not accepted as a lifecycle timestamp.
102
- match = value.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})\s+\(GMT([+-])(\d{1,2}):(\d{2})\)$/i);
103
- if (match) {
104
- const [, year = '', month = '', day = '', hour = '', minute = '', second = '', sign = '', offsetHours = '', offsetMinutes = '',] = match;
105
- const local = utcDateFromParts(+year, +month, +day, +hour, +minute, +second);
106
- if (!local || +offsetHours > 23 || +offsetMinutes > 59)
107
- return null;
108
- const offsetMs = (+offsetHours * 60 + +offsetMinutes) * 60000;
109
- return new Date(local.getTime() + (sign === '+' ? -offsetMs : offsetMs));
110
- }
111
- // DD-MM-YYYY HH:MM:SS GMT+H - the Tunisian registry's bounded local-time
112
- // presentation. Accept an optional minute component but require the GMT
113
- // marker and validate the calendar and offset before normalization.
114
- match = value.match(/^(\d{1,2})-(\d{1,2})-(\d{4})\s+(\d{2}):(\d{2}):(\d{2})\s+GMT([+-])(\d{1,2})(?::(\d{2}))?$/i);
115
- if (match) {
116
- const [, day = '', month = '', year = '', hour = '', minute = '', second = '', sign = '', offsetHours = '', offsetMinutes = '0',] = match;
117
- const local = utcDateFromParts(+year, +month, +day, +hour, +minute, +second);
118
- if (!local || +offsetHours > 23 || +offsetMinutes > 59)
119
- return null;
120
- const offsetMs = (+offsetHours * 60 + +offsetMinutes) * 60000;
121
- return new Date(local.getTime() + (sign === '+' ? -offsetMs : offsetMs));
122
- }
123
- // DD Mon YYYY - the compact English month form published by THNIC.
124
- match = value.match(/^(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})$/);
125
- if (match) {
126
- const month = REGISTRY_MONTHS[(match[2] ?? '').toLowerCase()];
127
- return month ? utcDateFromParts(+(match[3] ?? ''), month, +(match[1] ?? '')) : null;
128
- }
129
- // Ordinal-day Month YYYY[ at HH:MM:SS.sss] - the English form published
130
- // by the Channel Islands registry. The suffix and `at` token are required
131
- // so ordinary prose containing a month name is not accepted as lifecycle
132
- // evidence.
133
- match = value.match(/^(\d{1,2})(st|nd|rd|th)\s+([A-Za-z]{3,9})\s+(\d{4})(?:\s+at\s+(\d{1,2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?)?$/i);
134
- if (match) {
135
- const day = +(match[1] ?? '');
136
- const suffix = (match[2] ?? '').toLowerCase();
137
- const expectedSuffix = day % 100 >= 11 && day % 100 <= 13
138
- ? 'th'
139
- : ({ 1: 'st', 2: 'nd', 3: 'rd' }[day % 10] || 'th');
140
- if (suffix !== expectedSuffix)
141
- return null;
142
- const monthText = (match[3] ?? '').toLowerCase();
143
- const month = REGISTRY_MONTH_NAMES[monthText] || REGISTRY_MONTHS[monthText];
144
- const millisecond = match[8] ? Number(`${match[8]}00`.slice(0, 3)) : 0;
145
- return month
146
- ? utcDateFromParts(+(match[4] ?? ''), month, day, +(match[5] || 0), +(match[6] || 0), +(match[7] || 0), millisecond)
147
- : null;
148
- }
149
- // Ddd Mon DD YYYY[ HH:MM:SS] - the English textual form used by the
150
- // Belgian registry. Weekday text is presentation-only; the validated
151
- // calendar components determine the canonical UTC companion.
152
- match = value.match(/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+([A-Za-z]{3})\s+(\d{1,2})\s+(\d{4})(?:\s+(\d{1,2}):(\d{2}):(\d{2}))?$/i);
153
- if (match) {
154
- const month = REGISTRY_MONTHS[(match[1] ?? '').toLowerCase()];
155
- return month
156
- ? utcDateFromParts(+(match[3] ?? ''), month, +(match[2] ?? ''), +(match[4] || 0), +(match[5] || 0), +(match[6] || 0))
157
- : null;
158
- }
159
- // Month DD YYYY - ISNIC publishes English month names with variable
160
- // spacing and no timezone. Keep the accepted shape narrow and accept only
161
- // the twelve exact English names from a fixed table.
162
- match = value.match(/^([A-Za-z]{3,9})\s+(\d{1,2})\s+(\d{4})$/);
163
- if (match) {
164
- const month = REGISTRY_MONTH_NAMES[(match[1] ?? '').toLowerCase()];
165
- return month ? utcDateFromParts(+(match[3] ?? ''), month, +(match[2] ?? '')) : null;
166
- }
167
- // ISO 8601-shaped dates and timestamps. A missing timezone is deliberately
168
- // interpreted as UTC so Express and function deployments agree.
169
- match = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:[Tt ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(?:([Zz])|([+-])(\d{2}):?(\d{2}))?)?$/);
170
- if (!match)
171
- return null;
172
- const [, year = '', month = '', day = '', hour = '0', minute = '0', second = '0', fraction, zulu, offsetSign, offsetHours = '0', offsetMinutes = '0',] = match;
173
- const millisecond = fraction ? Number(`${fraction}000`.slice(0, 3)) : 0;
174
- const local = utcDateFromParts(+year, +month, +day, +(hour || 0), +(minute || 0), +(second || 0), millisecond);
175
- if (!local)
176
- return null;
177
- if (!offsetSign || zulu)
178
- return local;
179
- const offsetHour = +offsetHours;
180
- const offsetMinute = +offsetMinutes;
181
- if (offsetHour > 23 || offsetMinute > 59)
182
- return null;
183
- const offsetMs = (offsetHour * 60 + offsetMinute) * 60000;
184
- return new Date(local.getTime() + (offsetSign === '+' ? -offsetMs : offsetMs));
185
- }
186
- function registryDateIso(value) {
187
- const date = parseRegistryDate(value);
188
- return date ? date.toISOString() : null;
189
- }
190
- export { parseRegistryDate, registryDateIso };
1
+ // Stable source-facing entry point; portable consumers use the evidence owner.
2
+ export { parseRegistryDate, registryDateIso } from "../packages/evidence/registry-dates.mjs";
@@ -7,6 +7,8 @@
7
7
  import { compareRegistrySources } from "./registry-comparison.mjs";
8
8
  import { inspectRdapCapabilities } from "./rdap-capabilities.mjs";
9
9
  import { isRecord, recordOrEmpty } from "./json-record.mjs";
10
+ import { MAX_RDAP_REDACTIONS, MAX_RDAP_STATUSES } from "./rdap-types.mjs";
11
+ import { MAX_WHOIS_STATUSES } from "./whois-contracts.mjs";
10
12
  export const REGISTRY_INSIGHTS_VERSION = 1;
11
13
  export const MAX_REGISTRY_INSIGHT_STATUSES = 40;
12
14
  export const MAX_REGISTRY_INSIGHT_ISSUES = 12;
@@ -37,6 +39,20 @@ function strings(value, maximum = MAX_REGISTRY_INSIGHT_STATUSES) {
37
39
  function normalizedStatus(value) {
38
40
  return text(value, 160).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
39
41
  }
42
+ function statusInventory(value, maximum) {
43
+ if (!Array.isArray(value))
44
+ return { values: [], partial: value !== undefined };
45
+ const unique = new Set();
46
+ let partial = value.length > maximum;
47
+ for (const item of value.slice(0, maximum)) {
48
+ if (typeof item !== 'string' || item.length > 160 || CONTROL_RE.test(item) || !item.trim()) {
49
+ partial = true;
50
+ continue;
51
+ }
52
+ unique.add(item.trim());
53
+ }
54
+ return { values: [...unique], partial };
55
+ }
40
56
  function sourceState(status, partial) {
41
57
  if (partial || status === 'partial')
42
58
  return 'partial';
@@ -45,9 +61,13 @@ function sourceState(status, partial) {
45
61
  ? 'complete'
46
62
  : 'unavailable';
47
63
  }
48
- function redactionBlob(parsed) {
49
- const redactions = Array.isArray(parsed.redactions) ? parsed.redactions.slice(0, 50) : [];
50
- return redactions.map((item) => {
64
+ function registrantRedactionBlob(parsed) {
65
+ const redactions = Array.isArray(parsed.redactions) ? parsed.redactions.slice(0, MAX_RDAP_REDACTIONS) : [];
66
+ return redactions.filter((item) => {
67
+ // A path or reason can mention a role without selecting it. Do not infer
68
+ // registrant scope from arbitrary JSONPath expressions or other prose.
69
+ return /^(?:registry )?registrant(?:\b|$)/iu.test(text(record(item).name, 240));
70
+ }).map((item) => {
51
71
  const value = record(item);
52
72
  return [
53
73
  value.name,
@@ -92,7 +112,8 @@ function disclosure(source, parsedValue, status) {
92
112
  ? parsed.chainStatus === 'partial' || strings(parsed.fieldsTruncated, 20).length > 0
93
113
  : parsed.serverTruncated === true
94
114
  || parsed.entitiesTruncated === true
95
- || parsed.statusesTruncated === true;
115
+ || parsed.statusesTruncated === true
116
+ || parsed.redactionsTruncated === true;
96
117
  const publicationState = sourceState(status, partial);
97
118
  if (publicationState === 'unavailable') {
98
119
  return {
@@ -110,7 +131,7 @@ function disclosure(source, parsedValue, status) {
110
131
  parsed.registrantPhone,
111
132
  parsed.registrantAddress,
112
133
  ].map((part) => text(part, 320)).filter(Boolean).join(' ');
113
- const redactions = redactionBlob(parsed);
134
+ const redactions = registrantRedactionBlob(parsed);
114
135
  const combined = `${registrant} ${redactions}`.trim();
115
136
  if (WITHHELD_RE.test(combined)) {
116
137
  return { source, state: 'withheld', detail: 'The publication indicates that registrant data was withheld or not disclosed.' };
@@ -124,6 +145,13 @@ function disclosure(source, parsedValue, status) {
124
145
  if (registrant) {
125
146
  return { source, state: 'public', detail: 'The source published at least one usable registrant contact field.' };
126
147
  }
148
+ if (Array.isArray(parsed.redactions) && parsed.redactions.length > 0) {
149
+ return {
150
+ source,
151
+ state: 'unavailable',
152
+ detail: 'Redaction declarations do not establish registrant scope, so missing registrant fields remain unclassified.',
153
+ };
154
+ }
127
155
  if (publicationState === 'partial') {
128
156
  return {
129
157
  source,
@@ -137,9 +165,9 @@ function disclosure(source, parsedValue, status) {
137
165
  detail: 'The source completed without a usable registrant contact or an explicit disclosure marker.',
138
166
  };
139
167
  }
140
- function lifecycle(statusesValue, evidenceState) {
141
- const rawStatuses = strings(statusesValue);
142
- const normalized = rawStatuses.map(normalizedStatus);
168
+ function lifecycle(statuses, evidenceState) {
169
+ const rawStatuses = statuses.slice(0, MAX_REGISTRY_INSIGHT_STATUSES);
170
+ const normalized = statuses.map(normalizedStatus);
143
171
  const has = (value) => normalized.includes(value);
144
172
  const observedState = (value) => has(value) ? true : evidenceState === 'complete' ? false : null;
145
173
  const pendingDelete = observedState('pendingdelete');
@@ -200,16 +228,20 @@ function lifecycle(statusesValue, evidenceState) {
200
228
  serverStatuses: serverLocks,
201
229
  },
202
230
  acquisitionPath,
203
- limitation: 'EPP statuses describe the current registry lifecycle. They do not guarantee deletion, release timing, transfer completion, eligibility, price, or acquisition success.',
231
+ limitation: 'EPP statuses describe the current registry lifecycle. They do not guarantee deletion, release timing, transfer completion, eligibility, price, or acquisition success.'
232
+ + (statuses.length > rawStatuses.length ? ` The display lists ${rawStatuses.length} of ${statuses.length} distinct statuses; lifecycle decisions use the full admitted inventory.` : ''),
204
233
  };
205
234
  }
206
- function publicationDiagnostic(source, parsedValue, status, observedAt) {
235
+ function publicationDiagnostic(source, parsedValue, status, observedAt, statuses) {
207
236
  const usablePublication = isRecord(parsedValue);
208
237
  const parsed = record(parsedValue);
209
238
  const partial = parsed.serverTruncated === true
210
239
  || parsed.chainStatus === 'partial'
211
240
  || parsed.entitiesTruncated === true
212
241
  || parsed.statusesTruncated === true
242
+ || statuses.partial
243
+ || parsed.eventsTruncated === true
244
+ || parsed.redactionsTruncated === true
213
245
  || strings(parsed.fieldsTruncated, 20).length > 0;
214
246
  const state = usablePublication ? sourceState(status, partial) : 'unavailable';
215
247
  const issues = [];
@@ -219,14 +251,18 @@ function publicationDiagnostic(source, parsedValue, status, observedAt) {
219
251
  issues.push('The RDAP server declared a truncated response.');
220
252
  if (parsed.entitiesTruncated === true)
221
253
  issues.push('The normalised entity inventory was capped.');
222
- if (parsed.statusesTruncated === true)
223
- issues.push('The normalised status inventory was capped.');
254
+ if (parsed.statusesTruncated === true || statuses.partial)
255
+ issues.push('The normalised status inventory contains omitted or rejected values.');
256
+ if (parsed.eventsTruncated === true)
257
+ issues.push('The normalised event inventory contains omitted or rejected fields.');
258
+ if (parsed.redactionsTruncated === true)
259
+ issues.push('The normalised redaction inventory contains omitted or rejected declarations.');
224
260
  if (strings(parsed.fieldsTruncated, 20).length)
225
261
  issues.push('One or more normalised WHOIS fields were capped.');
226
262
  if (parsed.chainStatus === 'partial')
227
263
  issues.push('The WHOIS referral chain was incomplete or conflicting.');
228
264
  const conformance = strings(parsed.conformance, 30);
229
- const redactions = Array.isArray(parsed.redactions) ? parsed.redactions.slice(0, 50) : [];
265
+ const redactions = Array.isArray(parsed.redactions) ? parsed.redactions.slice(0, MAX_RDAP_REDACTIONS) : [];
230
266
  return {
231
267
  source,
232
268
  state,
@@ -277,14 +313,16 @@ export function buildRegistryInsights(input) {
277
313
  : counts.equivalent
278
314
  ? 'consistent'
279
315
  : 'unavailable';
316
+ const rdapStatuses = statusInventory(rdap.statuses, MAX_RDAP_STATUSES);
317
+ const whoisStatuses = statusInventory(whois.statuses, MAX_WHOIS_STATUSES);
280
318
  const publications = [
281
- publicationDiagnostic('registry_rdap', input.rdapParsed, input.rdapStatus, input.rdapFetchedAt),
282
- publicationDiagnostic('whois', input.whoisParsed, input.whoisStatus, input.whoisQueriedAt),
283
- publicationDiagnostic('registrar_rdap', input.registrarRdapParsed, input.registrarRdapStatus, input.registrarRdapFetchedAt),
319
+ publicationDiagnostic('registry_rdap', input.rdapParsed, input.rdapStatus, input.rdapFetchedAt, rdapStatuses),
320
+ publicationDiagnostic('whois', input.whoisParsed, input.whoisStatus, input.whoisQueriedAt, whoisStatuses),
321
+ publicationDiagnostic('registrar_rdap', input.registrarRdapParsed, input.registrarRdapStatus, input.registrarRdapFetchedAt, statusInventory(registrarRdap.statuses, MAX_RDAP_STATUSES)),
284
322
  ];
285
323
  const lifecycleCandidates = [
286
- { statuses: strings(rdap.statuses), state: publications[0].state },
287
- { statuses: strings(whois.statuses), state: publications[1].state },
324
+ { statuses: rdapStatuses.values, state: publications[0].state },
325
+ { statuses: whoisStatuses.values, state: publications[1].state },
288
326
  ].filter((candidate) => candidate.state !== 'unavailable');
289
327
  const lifecycleSource = lifecycleCandidates.find((candidate) => candidate.statuses.length)
290
328
  ?? lifecycleCandidates[0]
@@ -1,7 +1,7 @@
1
1
  // Privacy-minimized interpretation of selected response policies. Header and
2
2
  // cookie values are read transiently from the already-selected homepage
3
3
  // response and reduced to fixed signal identifiers plus bounded counts.
4
- export const RESPONSE_POLICY_VERSION = 2;
4
+ export const RESPONSE_POLICY_VERSION = 3;
5
5
  export const MAX_RESPONSE_POLICY_HEADER_BYTES = 8 * 1024;
6
6
  export const MAX_CSP_META_POLICIES = 4;
7
7
  export const MAX_RESPONSE_POLICY_DIRECTIVES = 64;
@@ -13,7 +13,9 @@ export const MIN_RECOMMENDED_HSTS_SECONDS = 180 * 24 * 60 * 60;
13
13
  const CONTROL_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
14
14
  const DIRECTIVE_NAME_RE = /^[a-z][a-z0-9-]{0,63}$/;
15
15
  const ATTRIBUTE_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]{1,128}$/;
16
- const CSP_NONCE_OR_HASH_RE = /^'(?:nonce-[^']+|sha(?:256|384|512)-[^']+)'$/i;
16
+ // CSP source-list grammar, not an entropy or digest-strength judgement. Invalid
17
+ // source expressions are ignored by browsers and cannot suppress unsafe-inline.
18
+ const CSP_NONCE_OR_HASH_RE = /^'(?:nonce-|sha(?:256|384|512)-)[A-Za-z0-9+/_-]+={0,2}'$/i;
17
19
  const REFERRER_POLICIES = new Set([
18
20
  'no-referrer',
19
21
  'no-referrer-when-downgrade',
@@ -42,7 +44,8 @@ function readHeader(headers, name) {
42
44
  return { state: 'absent', value: null };
43
45
  if (CONTROL_RE.test(raw))
44
46
  return { state: 'malformed', value: null };
45
- if (new TextEncoder().encode(raw).byteLength > MAX_RESPONSE_POLICY_HEADER_BYTES) {
47
+ if (raw.length > MAX_RESPONSE_POLICY_HEADER_BYTES
48
+ || new TextEncoder().encode(raw).byteLength > MAX_RESPONSE_POLICY_HEADER_BYTES) {
46
49
  return { state: 'partial', value: null };
47
50
  }
48
51
  return { state: 'present', value: raw.trim() };
@@ -55,61 +58,96 @@ function addSignal(signals, id, count) {
55
58
  function unavailableHeaderState(header) {
56
59
  return header.state === 'present' ? 'malformed' : header.state;
57
60
  }
58
- function analyzeCsp(header, signals) {
61
+ function inlineControl(sources, scriptsBlocked) {
62
+ if (scriptsBlocked)
63
+ return 'restricted';
64
+ if (!sources)
65
+ return 'uncontrolled';
66
+ return sources.includes("'unsafe-inline'")
67
+ && !sources.includes("'strict-dynamic'")
68
+ && !sources.some((token) => CSP_NONCE_OR_HASH_RE.test(token))
69
+ ? 'unqualified'
70
+ : 'restricted';
71
+ }
72
+ function intersectInlineControls(controls) {
73
+ if (controls.includes('restricted'))
74
+ return 'restricted';
75
+ if (controls.includes('unknown'))
76
+ return 'unknown';
77
+ return controls.includes('unqualified') ? 'unqualified' : 'uncontrolled';
78
+ }
79
+ function analyzeCsp(header, signals, fromMeta = false) {
59
80
  if (header.state !== 'present' || !header.value) {
60
81
  return { state: unavailableHeaderState(header), inlineControl: 'unknown' };
61
82
  }
62
- const directives = new Map();
83
+ // Fetch combines repeated policy headers with commas. Each policy is
84
+ // independently enforced; later policies cannot loosen an earlier one.
85
+ const policies = [];
86
+ let directiveCount = 0;
63
87
  let tokenCount = 0;
64
- let partial = false;
65
- for (const rawDirective of header.value.split(';')) {
66
- const normalized = rawDirective.trim();
67
- if (!normalized)
68
- continue;
69
- const [rawName = '', ...rawTokens] = normalized.split(/\s+/u);
70
- const name = rawName.toLowerCase();
71
- if (!DIRECTIVE_NAME_RE.test(name))
72
- return { state: 'malformed', inlineControl: 'unknown' };
73
- if (directives.has(name))
74
- continue;
75
- if (directives.size >= MAX_RESPONSE_POLICY_DIRECTIVES) {
76
- partial = true;
77
- break;
88
+ for (const rawPolicy of (fromMeta ? [header.value] : header.value.split(','))) {
89
+ const directives = new Map();
90
+ for (const rawDirective of rawPolicy.split(';')) {
91
+ const normalized = rawDirective.trim();
92
+ if (!normalized)
93
+ continue;
94
+ const [rawName = '', ...rawTokens] = normalized.split(/[\t\n\f\r ]+/u);
95
+ const name = rawName.toLowerCase();
96
+ if (!DIRECTIVE_NAME_RE.test(name))
97
+ return { state: 'malformed', inlineControl: 'unknown' };
98
+ // Duplicate directives use their first value. Count even ignored input
99
+ // against the aggregate work bound rather than resetting per policy.
100
+ directiveCount += 1;
101
+ tokenCount += rawTokens.length;
102
+ if (directiveCount > MAX_RESPONSE_POLICY_DIRECTIVES || tokenCount > MAX_RESPONSE_POLICY_TOKENS) {
103
+ return { state: 'partial', inlineControl: 'unknown' };
104
+ }
105
+ if (!directives.has(name))
106
+ directives.set(name, rawTokens.map((token) => token.toLowerCase()));
78
107
  }
79
- const remaining = Math.max(0, MAX_RESPONSE_POLICY_TOKENS - tokenCount);
80
- const tokens = rawTokens.slice(0, remaining).map((token) => token.toLowerCase());
81
- if (tokens.length !== rawTokens.length)
82
- partial = true;
83
- tokenCount += tokens.length;
84
- directives.set(name, tokens);
108
+ if (directives.size)
109
+ policies.push(directives);
85
110
  }
86
- if (!directives.size)
111
+ if (!policies.length)
87
112
  return { state: 'malformed', inlineControl: 'unknown' };
88
- const defaultSources = directives.get('default-src');
89
- const scriptSources = directives.get('script-src') || defaultSources;
90
- if (!defaultSources)
113
+ if (policies.every((policy) => !policy.has('default-src')))
91
114
  addSignal(signals, 'csp_default_source_missing');
92
- if (!directives.has('base-uri'))
115
+ if (policies.every((policy) => !policy.has('base-uri')))
93
116
  addSignal(signals, 'csp_base_uri_missing');
94
- if (!directives.has('object-src') && !defaultSources)
117
+ if (policies.every((policy) => !policy.has('object-src') && !policy.has('default-src'))) {
95
118
  addSignal(signals, 'csp_object_source_unbounded');
96
- let inlineControl = scriptSources ? 'restricted' : 'uncontrolled';
97
- if (scriptSources) {
98
- if (scriptSources.some((token) => ['*', 'http:', 'https:', 'data:', 'blob:'].includes(token))) {
99
- addSignal(signals, 'csp_permissive_script_source');
100
- }
101
- if (scriptSources.includes("'unsafe-eval'"))
102
- addSignal(signals, 'csp_unsafe_eval');
103
- if (scriptSources.includes("'unsafe-inline'")
104
- && !scriptSources.some((token) => CSP_NONCE_OR_HASH_RE.test(token))) {
105
- addSignal(signals, 'csp_unsafe_inline');
106
- inlineControl = 'unqualified';
107
- }
108
119
  }
109
- return {
110
- state: partial ? 'partial' : 'parsed',
111
- inlineControl: partial ? 'unknown' : inlineControl,
112
- };
120
+ const contexts = policies.map((policy) => {
121
+ const scripts = policy.get('script-src') ?? policy.get('default-src');
122
+ const elements = policy.get('script-src-elem') ?? scripts;
123
+ const attributes = policy.get('script-src-attr') ?? scripts;
124
+ const sandbox = fromMeta ? undefined : policy.get('sandbox');
125
+ const scriptsBlocked = sandbox !== undefined && !sandbox.includes('allow-scripts');
126
+ return {
127
+ blocks: inlineControl(elements, scriptsBlocked),
128
+ attributes: inlineControl(attributes, scriptsBlocked),
129
+ // script-src-elem/attr never govern evaluation, even when they contain
130
+ // an unsafe-eval token. An absent script/default source is uncontrolled.
131
+ evaluation: !scriptsBlocked && (!scripts || scripts.includes("'unsafe-eval'")),
132
+ declaresEvaluation: scripts?.includes("'unsafe-eval'") === true,
133
+ broadSource: !scriptsBlocked && (!elements || (!elements.includes("'strict-dynamic'")
134
+ && elements.some((token) => ['*', 'http:', 'https:', 'data:', 'blob:'].includes(token)))),
135
+ declaresBroadSource: elements !== undefined,
136
+ };
137
+ });
138
+ if (contexts.every((context) => context.broadSource) && contexts.some((context) => context.declaresBroadSource)) {
139
+ addSignal(signals, 'csp_permissive_script_source');
140
+ }
141
+ if (contexts.every((context) => context.evaluation) && contexts.some((context) => context.declaresEvaluation)) {
142
+ addSignal(signals, 'csp_unsafe_eval');
143
+ }
144
+ const blocks = intersectInlineControls(contexts.map((context) => context.blocks));
145
+ if (blocks === 'unqualified')
146
+ addSignal(signals, 'csp_unsafe_inline');
147
+ if (intersectInlineControls(contexts.map((context) => context.attributes)) === 'unqualified') {
148
+ addSignal(signals, 'csp_unsafe_inline_attributes');
149
+ }
150
+ return { state: 'parsed', inlineControl: blocks };
113
151
  }
114
152
  export function analyzeCspMetaPolicies(value, sourceTruncated = false) {
115
153
  const candidates = Array.isArray(value) ? value : [];
@@ -127,7 +165,7 @@ export function analyzeCspMetaPolicies(value, sourceTruncated = false) {
127
165
  }
128
166
  const policyContent = policy.content;
129
167
  const content = readHeader({ get: () => policyContent }, 'content-security-policy');
130
- const analysis = analyzeCsp(content, []);
168
+ const analysis = analyzeCsp(content, [], true);
131
169
  if (analysis.state !== 'parsed') {
132
170
  truncated = true;
133
171
  continue;
@@ -207,6 +207,15 @@ export function parseRiskCalibrationSummaryReport(raw) {
207
207
  throw new Error('Risk calibration summary must contain the fixed threshold set exactly once.');
208
208
  }
209
209
  const thresholds = Object.freeze(root.thresholds.map((value, index) => thresholdMetrics(value, `Threshold ${index + 1}`, RISK_CALIBRATION_SUMMARY_THRESHOLDS[index], positive + negative, positive, negative)));
210
+ for (let index = 1; index < thresholds.length; index += 1) {
211
+ const lower = thresholds[index - 1];
212
+ const higher = thresholds[index];
213
+ // The population and class totals are already fixed by thresholdMetrics.
214
+ // Raising the threshold can remove positive classifications, never add them.
215
+ if (higher.truePositive > lower.truePositive || higher.falsePositive > lower.falsePositive) {
216
+ throw new Error('Risk calibration positive classifications increase at a higher threshold.');
217
+ }
218
+ }
210
219
  if (!Array.isArray(root.strata) || root.strata.length > MAX_RISK_CALIBRATION_SUMMARY_STRATA) {
211
220
  throw new Error('Risk calibration strata must be a bounded array.');
212
221
  }
@@ -243,13 +252,18 @@ export function parseRiskCalibrationSummaryReport(raw) {
243
252
  metrics: thresholdMetrics(stratum.metrics, `${label} metrics`, currentReviewThreshold, sampleCount),
244
253
  });
245
254
  }));
255
+ const currentMetrics = thresholds.find((metric) => metric.threshold === currentReviewThreshold);
246
256
  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);
257
+ const members = strata.filter((stratum) => stratum.dimension === dimension);
258
+ const dimensionTotal = members.reduce((sum, stratum) => sum + stratum.sampleCount, 0);
250
259
  if (dimensionTotal !== positive + negative) {
251
260
  throw new Error(`Risk calibration ${dimension.replace('_', '-')} strata do not cover every included label exactly once.`);
252
261
  }
262
+ for (const field of ['truePositive', 'falsePositive', 'trueNegative', 'falseNegative']) {
263
+ if (members.reduce((sum, stratum) => sum + stratum.metrics[field], 0) !== currentMetrics[field]) {
264
+ throw new Error(`Risk calibration ${dimension.replace('_', '-')} strata disagree with the current-threshold confusion counts.`);
265
+ }
266
+ }
253
267
  }
254
268
  const comparison = object(root.modelComparison, 'Risk model comparison');
255
269
  exactKeys(comparison, MODEL_COMPARISON_KEYS, 'Risk model comparison');
@@ -9,9 +9,10 @@ function record(value) {
9
9
  : null;
10
10
  }
11
11
  function normalizeAsn(value) {
12
- const text = typeof value === 'string' ? value.trim().replace(/^AS/iu, '') : value;
13
- const number = Number(text);
14
- return Number.isInteger(number) && number >= 0 && number <= 4_294_967_295 ? number : null;
12
+ const number = typeof value === 'number' ? value
13
+ : typeof value === 'string' && value.length <= 16 && /^(?:AS)?\d{1,10}$/iu.test(value.trim())
14
+ ? Number(value.trim().replace(/^AS/iu, '')) : NaN;
15
+ return Number.isSafeInteger(number) && number >= 0 && number <= 4_294_967_295 ? number : null;
15
16
  }
16
17
  function normalizeAuthorization(value) {
17
18
  const source = record(value);
@@ -21,7 +22,8 @@ function normalizeAuthorization(value) {
21
22
  const parsedPrefix = parseIpPrefix(prefix);
22
23
  const asn = normalizeAsn(source.asn ?? source.asID ?? source.origin);
23
24
  const maxLengthValue = source.maxLength ?? source.max_length ?? parsedPrefix?.length;
24
- const maxLength = Number(maxLengthValue);
25
+ const maxLength = typeof maxLengthValue === 'number' ? maxLengthValue
26
+ : typeof maxLengthValue === 'string' && /^\d{1,3}$/u.test(maxLengthValue) ? Number(maxLengthValue) : NaN;
25
27
  const maximum = parsedPrefix?.family === 4 ? 32 : 128;
26
28
  if (!parsedPrefix || asn === null || !Number.isInteger(maxLength)
27
29
  || maxLength < parsedPrefix.length || maxLength > maximum)
@@ -49,8 +51,9 @@ function reviewRpkiRoute(input) {
49
51
  const baseLimitations = Object.freeze([
50
52
  'This offline review evaluates an explicitly supplied route prefix and origin ASN against an analyst-supplied VRP snapshot.',
51
53
  'It does not collect BGP announcements, establish route ownership, or prove that the snapshot was current or complete.',
54
+ 'AS0 authorisations do not authorise an announcement. An AS0 route origin is invalid input; snapshot validation and signature verification remain external to this review.',
52
55
  ]);
53
- if (!route || originAsn === null || !rows) {
56
+ if (!route || originAsn === null || originAsn === 0 || !rows) {
54
57
  return Object.freeze({
55
58
  schema: RPKI_EVIDENCE_SCHEMA,
56
59
  version: RPKI_EVIDENCE_VERSION,