@slicedearth/whoisleuth-cli 2.2.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 (282) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +20 -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 +454 -1748
  7. package/cli/artifact-validation/case-response.mjs +66 -35
  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 +112 -72
  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-checkpoint.mjs +34 -3
  18. package/cli/bulk-command-runner.mjs +2 -0
  19. package/cli/bulk-output.mjs +29 -3
  20. package/cli/case-command.mjs +195 -0
  21. package/cli/case-pack.mjs +66 -111
  22. package/cli/command-argument-grammar.mjs +215 -0
  23. package/cli/command-reference.mjs +342 -285
  24. package/cli/completion.mjs +303 -80
  25. package/cli/config-profile.mjs +7 -5
  26. package/cli/ct-event-intake.mjs +12 -12
  27. package/cli/discover.mjs +4 -3
  28. package/cli/discovery-observation-snapshot.mjs +59 -18
  29. package/cli/discovery-scan-command-runner.mjs +21 -0
  30. package/cli/discovery-scan.mjs +13 -4
  31. package/cli/discovery-workflow.mjs +1 -1
  32. package/cli/domain-control-monitor.mjs +50 -20
  33. package/cli/domain-control-observations.mjs +149 -102
  34. package/cli/errors.mjs +33 -1
  35. package/cli/evidence-command-runner.mjs +16 -5
  36. package/cli/evidence-signing.mjs +2 -2
  37. package/cli/external-observation-mapping.mjs +7 -8
  38. package/cli/formatters/terminal-lookup.mjs +18 -46
  39. package/cli/formatters/terminal-shared.mjs +5 -3
  40. package/cli/formatters/terminal-technology.mjs +114 -0
  41. package/cli/inline-command-families.mjs +2 -0
  42. package/cli/input.mjs +116 -0
  43. package/cli/interactive-launcher.mjs +2 -160
  44. package/cli/interchange-report.mjs +18 -8
  45. package/cli/investigation-artifacts.mjs +176 -0
  46. package/cli/investigation-checkpoint.mjs +10 -0
  47. package/cli/investigation-folder.mjs +114 -0
  48. package/cli/investigation-manifest.mjs +1 -112
  49. package/cli/investigation-package-review.mjs +103 -0
  50. package/cli/investigation-plan.mjs +32 -19
  51. package/cli/investigation-run.mjs +188 -33
  52. package/cli/local-document-checkpoint.mjs +170 -0
  53. package/cli/lookalike-calibration.mjs +7 -2
  54. package/cli/lookup-brief.mjs +4 -2
  55. package/cli/lookup-browser-view.mjs +1 -1
  56. package/cli/lookup-browser.mjs +5 -12
  57. package/cli/lookup-command-runner.mjs +17 -6
  58. package/cli/lookup-diff.mjs +37 -3
  59. package/cli/lookup-plan.mjs +11 -5
  60. package/cli/lookup-reconcile.mjs +10 -8
  61. package/cli/mail-header-review.mjs +467 -0
  62. package/cli/mail-review.mjs +66 -14
  63. package/cli/manual.mjs +1 -0
  64. package/cli/network-command-runner.mjs +2 -0
  65. package/cli/output-file.mjs +31 -5
  66. package/cli/registry-doctor.mjs +3 -1
  67. package/cli/registry-support.mjs +1 -1
  68. package/cli/retained-artifact-diff.mjs +2 -2
  69. package/cli/review-command-runner.mjs +70 -0
  70. package/cli/risk-calibration.mjs +19 -17
  71. package/cli/runner.mjs +62 -7
  72. package/cli/sharing-review.mjs +21 -34
  73. package/cli/signer-trust.mjs +104 -0
  74. package/cli/source-reliability.mjs +74 -83
  75. package/cli/terminal-input.mjs +168 -0
  76. package/cli/workflow-command-runner.mjs +68 -13
  77. package/docs/cli-reference.md +77 -24
  78. package/docs/cli.md +300 -25
  79. package/lib/abort.mjs +4 -1
  80. package/lib/availability.mjs +161 -112
  81. package/lib/bounded-file.mjs +3 -1
  82. package/lib/bounded-html-document.mjs +125 -0
  83. package/lib/bounded-json.mjs +51 -8
  84. package/lib/browser-library-profile.mjs +42 -51
  85. package/lib/browser-library-worker.mjs +112 -0
  86. package/lib/client-behavior-profile.mjs +3 -3
  87. package/lib/ct-search.mjs +11 -4
  88. package/lib/dns-convergence-review.mjs +1 -0
  89. package/lib/dns-delegation-health.mjs +24 -14
  90. package/lib/dns-inheritance-review.mjs +217 -0
  91. package/lib/dns-intelligence.mjs +73 -26
  92. package/lib/dns-mx.mjs +1 -1
  93. package/lib/dnssec-chain-validation.mjs +6 -0
  94. package/lib/dnssec-evidence-validation.mjs +11 -3
  95. package/lib/domain-change-packet.mjs +49 -16
  96. package/lib/domain-change-review.mjs +14 -8
  97. package/lib/domain-control-flight-recorder.mjs +221 -117
  98. package/lib/domain-control-manifest.mjs +129 -69
  99. package/lib/domain-posture-analysis.mjs +20 -7
  100. package/lib/domain-posture-parsers.mjs +201 -8
  101. package/lib/domain-posture.mjs +147 -29
  102. package/lib/evidence-export-privacy.mjs +561 -0
  103. package/lib/evidence-export.mjs +11 -543
  104. package/lib/evidence-report.mjs +5 -3
  105. package/lib/favicon.mjs +117 -41
  106. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  107. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  108. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  109. package/lib/hostname.mjs +2 -38
  110. package/lib/html-signals.mjs +111 -108
  111. package/lib/http-evidence-bounds.mjs +2 -1
  112. package/lib/http-intelligence.mjs +39 -26
  113. package/lib/local-geoip-evidence.mjs +19 -3
  114. package/lib/lookup-cache.mjs +34 -11
  115. package/lib/lookup-child-profile-contract.mjs +24 -15
  116. package/lib/lookup-diagnostics.mjs +24 -1
  117. package/lib/lookup-progress.mjs +30 -13
  118. package/lib/lookup-response-contract.mjs +1048 -0
  119. package/lib/lookup-response.mjs +8 -10
  120. package/lib/lookup-source-progress.mjs +40 -14
  121. package/lib/lookup.mjs +48 -27
  122. package/lib/nameserver-preflight-review.mjs +1 -0
  123. package/lib/observed-network-context.mjs +43 -27
  124. package/lib/outbound-request-bounds.mjs +4 -1
  125. package/lib/page-fingerprints.mjs +69 -133
  126. package/lib/page-role-profile.mjs +12 -8
  127. package/lib/perceptual-hash.mjs +43 -14
  128. package/lib/rdap-bootstrap.mjs +72 -22
  129. package/lib/rdap-capabilities.mjs +1 -1
  130. package/lib/rdap-client.mjs +15 -7
  131. package/lib/rdap-normalization.mjs +33 -18
  132. package/lib/rdap-search-workbench.mjs +1 -1
  133. package/lib/rdap-transport.mjs +47 -20
  134. package/lib/rdap-types.mjs +2 -1
  135. package/lib/rdap.mjs +18 -3
  136. package/lib/registrable-domain.mjs +21 -1
  137. package/lib/registrar-standing-contract.mjs +1 -0
  138. package/lib/registry-capabilities.mjs +5 -1
  139. package/lib/registry-capability-catalogue.mjs +2 -1
  140. package/lib/registry-dates.mjs +2 -190
  141. package/lib/registry-insights.mjs +85 -34
  142. package/lib/response-policy.mjs +86 -48
  143. package/lib/risk-calibration-summary.mjs +18 -6
  144. package/lib/rpki-evidence.mjs +8 -5
  145. package/lib/safe-fetch.mjs +54 -47
  146. package/lib/security-txt.mjs +42 -7
  147. package/lib/service-binding-dns.mjs +8 -0
  148. package/lib/smtp-transport-review.mjs +27 -15
  149. package/lib/sslbl-certificates.generated.mjs +136 -135
  150. package/lib/static-html-analysis.mjs +178 -151
  151. package/lib/structured-data-identity.mjs +1 -0
  152. package/lib/technology-markup.mjs +100 -0
  153. package/lib/threat-intelligence-manifests.mjs +11 -139
  154. package/lib/threat-intelligence-runtime.mjs +24 -52
  155. package/lib/threat-intelligence-targets.mjs +6 -34
  156. package/lib/threat-intelligence-types.mjs +39 -0
  157. package/lib/typosquat-generator.mjs +19 -15
  158. package/lib/urlhaus-intelligence.mjs +8 -3
  159. package/lib/urlscan-intelligence.mjs +1 -1
  160. package/lib/website-security-posture.mjs +30 -6
  161. package/lib/website-technology.mjs +153 -45
  162. package/lib/whois-authority.mjs +14 -2
  163. package/lib/whois-chain.mjs +9 -2
  164. package/lib/whois-common-formats.mjs +5 -5
  165. package/lib/whois-contracts.mjs +2 -1
  166. package/lib/whois-dialects.mjs +2 -2
  167. package/lib/whois-parser-support.mjs +8 -1
  168. package/lib/whois-transport.mjs +23 -7
  169. package/lib/zone-intent-review.mjs +16 -6
  170. package/package.json +2 -1
  171. package/packages/cases/case-attachment-model.mjs +83 -0
  172. package/packages/cases/case-evidence-model.mjs +156 -62
  173. package/packages/cases/case-export-input.mjs +53 -0
  174. package/packages/cases/case-migration-model.mjs +127 -44
  175. package/packages/cases/case-model.mjs +1 -0
  176. package/packages/cases/case-recheck-model.mjs +91 -0
  177. package/packages/cases/case-record-contracts.mjs +5 -20
  178. package/packages/cases/case-record-core.mjs +12 -22
  179. package/packages/cases/case-record-decisions.mjs +72 -0
  180. package/packages/cases/case-record-model.mjs +1 -0
  181. package/packages/cases/case-record-operations.mjs +95 -31
  182. package/packages/cases/case-record-projection.mjs +221 -0
  183. package/packages/cases/case-report.mjs +70 -54
  184. package/packages/cases/case-response-actions.mjs +654 -0
  185. package/packages/cases/case-response-model.mjs +64 -1115
  186. package/packages/cases/case-response-outcomes.mjs +317 -0
  187. package/packages/cases/case-response-packet-render.mjs +153 -0
  188. package/packages/cases/case-response-packet-types.mjs +1 -0
  189. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  190. package/packages/cases/case-response-packet.mjs +74 -572
  191. package/packages/cases/case-response-records.mjs +94 -0
  192. package/packages/cases/case-response-review-inputs.mjs +371 -0
  193. package/packages/cases/case-response-values.mjs +85 -0
  194. package/packages/cases/case-review-package.mjs +45 -0
  195. package/packages/cases/case-selection.mjs +46 -0
  196. package/packages/cases/case-storage-model.mjs +31 -2
  197. package/packages/cases/response-route-freshness.mjs +16 -0
  198. package/packages/comparison/capture-context.mjs +61 -0
  199. package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
  200. package/packages/comparison/page-similarity.mjs +5 -2
  201. package/packages/comparison/relationship-evidence.mjs +85 -8
  202. package/packages/comparison/relationship-provenance.mjs +93 -0
  203. package/packages/contracts/analyst-interchange.mjs +216 -0
  204. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  205. package/packages/contracts/analyst-review-state.mjs +5 -4
  206. package/packages/contracts/case-portability.mjs +366 -83
  207. package/packages/contracts/case-views-contract.mjs +12 -0
  208. package/packages/contracts/case-views.mjs +26 -0
  209. package/packages/contracts/cli-command-semantics.mjs +94 -0
  210. package/packages/contracts/cli-lookup.mjs +0 -1
  211. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  212. package/packages/contracts/domain-control-manifest.mjs +127 -73
  213. package/packages/contracts/domain-control-monitor.mjs +48 -45
  214. package/packages/contracts/domain-control-review.mjs +179 -121
  215. package/packages/contracts/domain-name.mjs +21 -0
  216. package/packages/contracts/external-observation-interchange.mjs +2 -1
  217. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  218. package/packages/contracts/http-url.mjs +2 -0
  219. package/packages/contracts/investigation-package-limits.mjs +24 -0
  220. package/packages/contracts/investigation-portability.mjs +56 -27
  221. package/packages/contracts/investigation-run.mjs +16 -0
  222. package/packages/contracts/ip-address.mjs +34 -0
  223. package/packages/contracts/lookup-evidence.mjs +35 -0
  224. package/packages/contracts/offline-comparison.mjs +1 -2
  225. package/packages/contracts/page-fingerprints.mjs +15 -0
  226. package/packages/contracts/risk-calibration.mjs +15 -11
  227. package/packages/contracts/schema-compatibility.mjs +1 -1
  228. package/packages/contracts/schema-lifecycle.mjs +83 -153
  229. package/packages/contracts/selected-file-limits.mjs +6 -0
  230. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  231. package/packages/contracts/web-capture.mjs +10 -0
  232. package/packages/contracts/workspace-portability.mjs +183 -211
  233. package/packages/evidence/artifact-integrity.mjs +8 -2
  234. package/packages/evidence/artifact-structure.mjs +105 -0
  235. package/packages/evidence/decision-fact.mjs +4 -1
  236. package/packages/evidence/domain-control-runtime.mjs +201 -33
  237. package/packages/evidence/domain-posture-context.mjs +79 -0
  238. package/packages/evidence/image-regions.mjs +56 -0
  239. package/packages/evidence/latest-observations.mjs +28 -0
  240. package/packages/evidence/lookup-target.mjs +104 -0
  241. package/packages/evidence/observation.mjs +16 -1
  242. package/packages/evidence/passphrase-encryption.mjs +44 -0
  243. package/packages/evidence/record-identity.mjs +72 -0
  244. package/packages/evidence/registry-dates.mjs +190 -0
  245. package/packages/evidence/retained-file.mjs +69 -0
  246. package/packages/evidence/review-age.mjs +14 -0
  247. package/packages/interchange/bagit.mjs +342 -0
  248. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  249. package/packages/interchange/crc32.mjs +17 -0
  250. package/packages/interchange/external-findings-import.mjs +92 -51
  251. package/packages/interchange/web-capture-import.mjs +382 -0
  252. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  253. package/packages/investigation/investigation-bagit.mjs +14 -0
  254. package/packages/investigation/investigation-capsule.mjs +33 -5
  255. package/packages/investigation/investigation-manifest.mjs +200 -0
  256. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  257. package/packages/investigation/investigation-package.mjs +200 -0
  258. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  259. package/packages/investigation/lookup-asset-graph.mjs +193 -165
  260. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  261. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  262. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  263. package/packages/monitoring/analyst-review-state.mjs +38 -90
  264. package/packages/workspace/brand-profile-model.mjs +276 -22
  265. package/packages/workspace/bulk-columns.mjs +17 -0
  266. package/packages/workspace/bulk-review-model.mjs +23 -11
  267. package/packages/workspace/bulk-session-model.mjs +100 -24
  268. package/packages/workspace/bulk-sort.mjs +1 -0
  269. package/packages/workspace/case-views.mjs +102 -0
  270. package/packages/workspace/detection-rule-model.mjs +70 -22
  271. package/packages/workspace/hostile-input.mjs +29 -8
  272. package/packages/workspace/investigation-template-model.mjs +19 -4
  273. package/packages/workspace/page-baseline.mjs +18 -15
  274. package/packages/workspace/relationship-observation-model.mjs +41 -23
  275. package/packages/workspace/watchlist-history.mjs +2 -2
  276. package/packages/workspace/watchlist-store.mjs +8 -2
  277. package/packages/workspace/website-snapshot-model.mjs +164 -26
  278. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  279. package/packages/workspace/workspace-archive.mjs +87 -21
  280. package/third-party-notices.txt +29 -2
  281. package/cli/evidence-command-arguments.mjs +0 -156
  282. 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,21 +165,22 @@ 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) {
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
- const pendingDelete = has('pendingdelete');
145
- const redemption = has('redemptionperiod');
146
- const pendingTransfer = has('pendingtransfer');
147
- const serverHold = has('serverhold');
148
- const clientHold = has('clienthold');
172
+ const observedState = (value) => has(value) ? true : evidenceState === 'complete' ? false : null;
173
+ const pendingDelete = observedState('pendingdelete');
174
+ const redemption = observedState('redemptionperiod');
175
+ const pendingTransfer = observedState('pendingtransfer');
176
+ const serverHold = observedState('serverhold');
177
+ const clientHold = observedState('clienthold');
149
178
  const serverLocks = normalized.filter((value) => /^server(?:delete|renew|transfer|update)prohibited$/u.test(value));
150
179
  const clientLocks = normalized.filter((value) => /^client(?:delete|renew|transfer|update)prohibited$/u.test(value));
151
- let stage = 'registered';
152
- let label = 'Registered';
153
- let acquisitionPath = ['Continue monitoring registry status and expiry.'];
154
- if (pendingDelete) {
180
+ let stage = 'unknown';
181
+ let label = 'Lifecycle unavailable';
182
+ let acquisitionPath = ['Obtain a qualifying registry or WHOIS publication before interpreting lifecycle or lock state.'];
183
+ if (pendingDelete === true) {
155
184
  stage = 'pending_delete';
156
185
  label = 'Pending delete';
157
186
  acquisitionPath = [
@@ -160,7 +189,7 @@ function lifecycle(statusesValue) {
160
189
  'Use a registrar or registry-supported acquisition path when policy permits.',
161
190
  ];
162
191
  }
163
- else if (redemption) {
192
+ else if (redemption === true) {
164
193
  stage = 'redemption';
165
194
  label = 'Redemption period';
166
195
  acquisitionPath = [
@@ -169,16 +198,21 @@ function lifecycle(statusesValue) {
169
198
  'Do not treat the domain as available or guaranteed to drop.',
170
199
  ];
171
200
  }
172
- else if (pendingTransfer) {
201
+ else if (pendingTransfer === true) {
173
202
  stage = 'pending_transfer';
174
203
  label = 'Pending transfer';
175
204
  acquisitionPath = ['A registrar transfer is pending; this is not an availability signal.'];
176
205
  }
177
- else if (serverHold || clientHold) {
206
+ else if (serverHold === true || clientHold === true) {
178
207
  stage = 'hold';
179
208
  label = 'Registration hold';
180
209
  acquisitionPath = ['A hold affects delegation or publication; it does not mean the registration is available.'];
181
210
  }
211
+ else if (evidenceState === 'complete' || rawStatuses.length > 0) {
212
+ stage = 'registered';
213
+ label = 'Registered';
214
+ acquisitionPath = ['Continue monitoring registry status and expiry.'];
215
+ }
182
216
  return {
183
217
  stage,
184
218
  label,
@@ -188,22 +222,26 @@ function lifecycle(statusesValue) {
188
222
  pendingTransfer,
189
223
  hold: { client: clientHold, server: serverHold },
190
224
  locks: {
191
- client: clientLocks.length > 0,
192
- server: serverLocks.length > 0,
225
+ client: clientLocks.length > 0 ? true : evidenceState === 'complete' ? false : null,
226
+ server: serverLocks.length > 0 ? true : evidenceState === 'complete' ? false : null,
193
227
  clientStatuses: clientLocks,
194
228
  serverStatuses: serverLocks,
195
229
  },
196
230
  acquisitionPath,
197
- 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.` : ''),
198
233
  };
199
234
  }
200
- function publicationDiagnostic(source, parsedValue, status, observedAt) {
235
+ function publicationDiagnostic(source, parsedValue, status, observedAt, statuses) {
201
236
  const usablePublication = isRecord(parsedValue);
202
237
  const parsed = record(parsedValue);
203
238
  const partial = parsed.serverTruncated === true
204
239
  || parsed.chainStatus === 'partial'
205
240
  || parsed.entitiesTruncated === true
206
241
  || parsed.statusesTruncated === true
242
+ || statuses.partial
243
+ || parsed.eventsTruncated === true
244
+ || parsed.redactionsTruncated === true
207
245
  || strings(parsed.fieldsTruncated, 20).length > 0;
208
246
  const state = usablePublication ? sourceState(status, partial) : 'unavailable';
209
247
  const issues = [];
@@ -213,14 +251,18 @@ function publicationDiagnostic(source, parsedValue, status, observedAt) {
213
251
  issues.push('The RDAP server declared a truncated response.');
214
252
  if (parsed.entitiesTruncated === true)
215
253
  issues.push('The normalised entity inventory was capped.');
216
- if (parsed.statusesTruncated === true)
217
- 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.');
218
260
  if (strings(parsed.fieldsTruncated, 20).length)
219
261
  issues.push('One or more normalised WHOIS fields were capped.');
220
262
  if (parsed.chainStatus === 'partial')
221
263
  issues.push('The WHOIS referral chain was incomplete or conflicting.');
222
264
  const conformance = strings(parsed.conformance, 30);
223
- 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) : [];
224
266
  return {
225
267
  source,
226
268
  state,
@@ -271,7 +313,20 @@ export function buildRegistryInsights(input) {
271
313
  : counts.equivalent
272
314
  ? 'consistent'
273
315
  : 'unavailable';
274
- const statuses = strings(rdap.statuses).length ? rdap.statuses : whois.statuses;
316
+ const rdapStatuses = statusInventory(rdap.statuses, MAX_RDAP_STATUSES);
317
+ const whoisStatuses = statusInventory(whois.statuses, MAX_WHOIS_STATUSES);
318
+ const publications = [
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)),
322
+ ];
323
+ const lifecycleCandidates = [
324
+ { statuses: rdapStatuses.values, state: publications[0].state },
325
+ { statuses: whoisStatuses.values, state: publications[1].state },
326
+ ].filter((candidate) => candidate.state !== 'unavailable');
327
+ const lifecycleSource = lifecycleCandidates.find((candidate) => candidate.statuses.length)
328
+ ?? lifecycleCandidates[0]
329
+ ?? { statuses: [], state: 'unavailable' };
275
330
  const routes = [
276
331
  ...contactRoute('registry', rdap.abuse, 'Registry RDAP abuse entity'),
277
332
  ...contactRoute('registrar', rdap.registrar, 'Registry RDAP registrar entity'),
@@ -294,7 +349,7 @@ export function buildRegistryInsights(input) {
294
349
  whois: disclosure('whois', input.whoisParsed, input.whoisStatus),
295
350
  limitation: 'Disclosure state describes what each point-in-time source published. It does not infer the identity, intent, reachability, or legal status of a registrant.',
296
351
  },
297
- lifecycle: lifecycle(statuses),
352
+ lifecycle: lifecycle(lifecycleSource.statuses, lifecycleSource.state),
298
353
  reconciliation: {
299
354
  state: reconciliationState,
300
355
  conflictCount,
@@ -312,11 +367,7 @@ export function buildRegistryInsights(input) {
312
367
  ? 'The comparable fields are normalised as equivalent.'
313
368
  : 'No comparable fields were available.',
314
369
  },
315
- publications: [
316
- publicationDiagnostic('registry_rdap', input.rdapParsed, input.rdapStatus, input.rdapFetchedAt),
317
- publicationDiagnostic('whois', input.whoisParsed, input.whoisStatus, input.whoisQueriedAt),
318
- publicationDiagnostic('registrar_rdap', input.registrarRdapParsed, input.registrarRdapStatus, input.registrarRdapFetchedAt),
319
- ],
370
+ publications,
320
371
  rdapCapabilities: {
321
372
  registry: inspectRdapCapabilities(input.rdapParsed, input.rdapStatus),
322
373
  registrar: inspectRdapCapabilities(input.registrarRdapParsed, input.registrarRdapStatus),
@@ -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;