@slicedearth/whoisleuth-cli 1.45.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +74 -124
  4. package/SECURITY.md +6 -3
  5. package/bin/whoisleuth.mjs +7 -4
  6. package/cli/archive-inspect.mjs +28 -10
  7. package/cli/arguments.mjs +450 -173
  8. package/cli/artifact-structure.mjs +2507 -0
  9. package/cli/artifact-verify.mjs +286 -64
  10. package/cli/bounded-json.mjs +1 -0
  11. package/cli/bulk-checkpoint.mjs +35 -23
  12. package/cli/bulk-command-runner.mjs +2 -5
  13. package/cli/bulk.mjs +9 -2
  14. package/cli/case-pack.mjs +435 -46
  15. package/cli/ci-report.mjs +6 -1
  16. package/cli/collection-preflight.mjs +2 -2
  17. package/cli/command-catalogue.mjs +12 -3
  18. package/cli/command-reference.mjs +948 -106
  19. package/cli/completion.mjs +504 -218
  20. package/cli/config-profile.mjs +13 -3
  21. package/cli/ct-event-intake.mjs +32 -2
  22. package/cli/discover.mjs +7 -1
  23. package/cli/discovery-observation-snapshot.mjs +24 -28
  24. package/cli/discovery-scan-command-runner.mjs +13 -14
  25. package/cli/discovery-scan.mjs +17 -3
  26. package/cli/discovery-snapshot.mjs +15 -7
  27. package/cli/discovery-workflow.mjs +2 -3
  28. package/cli/doctor.mjs +2 -2
  29. package/cli/domain-control-monitor.mjs +215 -36
  30. package/cli/domain-control-observations.mjs +34 -17
  31. package/cli/errors.mjs +8 -3
  32. package/cli/evidence-command-runner.mjs +17 -11
  33. package/cli/evidence-signing.mjs +68 -50
  34. package/cli/export-evidence.mjs +11 -4
  35. package/cli/external-observation-mapping.mjs +3 -3
  36. package/cli/formatters/html.mjs +3 -3
  37. package/cli/formatters/json.mjs +20 -32
  38. package/cli/formatters/terminal.mjs +586 -22
  39. package/cli/input.mjs +62 -0
  40. package/cli/interactive-launcher.mjs +226 -0
  41. package/cli/interchange-report.mjs +226 -0
  42. package/cli/investigation-manifest.mjs +16 -17
  43. package/cli/investigation-plan.mjs +167 -16
  44. package/cli/investigation-run.mjs +80 -24
  45. package/cli/local-mmdb-review.mjs +45 -14
  46. package/cli/lookalike-calibration.mjs +3 -1
  47. package/cli/lookup-brief.mjs +13 -8
  48. package/cli/lookup-browser.mjs +844 -0
  49. package/cli/lookup-command-runner.mjs +96 -33
  50. package/cli/lookup-diff.mjs +11 -2
  51. package/cli/lookup-plan.mjs +1 -0
  52. package/cli/mail-review.mjs +206 -94
  53. package/cli/manual.mjs +5 -5
  54. package/cli/network-command-runner.mjs +78 -0
  55. package/cli/offline-evidence-review.mjs +45 -14
  56. package/cli/open-asset-model-bridge.mjs +1 -1
  57. package/cli/output-file.mjs +70 -14
  58. package/cli/page-compare.mjs +18 -10
  59. package/cli/progress-events.mjs +2 -1
  60. package/cli/progress.mjs +3 -3
  61. package/cli/registry-cohort.mjs +343 -61
  62. package/cli/registry-doctor.mjs +67 -28
  63. package/cli/registry-support.mjs +2 -1
  64. package/cli/retained-artifact-diff.mjs +375 -0
  65. package/cli/risk-calibration.mjs +89 -40
  66. package/cli/runner.mjs +322 -246
  67. package/cli/saved-lookup.mjs +439 -49
  68. package/cli/sharing-review.mjs +18 -7
  69. package/cli/source-reliability.mjs +75 -36
  70. package/cli/terminal-presentation.mjs +43 -18
  71. package/docs/cli-reference.md +125 -1334
  72. package/docs/cli.md +87 -236
  73. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
  74. package/frontend/src/lib/analysis/domain-control-records.js +3 -0
  75. package/lib/application-version.mjs +3 -0
  76. package/lib/availability.mjs +72 -52
  77. package/lib/bounded-contract-normalizers.mjs +2 -4
  78. package/lib/bounded-file.mjs +91 -4
  79. package/lib/bounded-json.mjs +287 -0
  80. package/lib/browser-library-profile.mjs +1 -1
  81. package/lib/certificate-extension-profile.mjs +2 -1
  82. package/lib/classify.mjs +56 -5
  83. package/lib/client-behavior-profile.mjs +1 -1
  84. package/lib/credential-surface-profile.mjs +1 -1
  85. package/lib/cryptographic-assurance.mjs +173 -0
  86. package/lib/ct-query.mjs +3 -3
  87. package/lib/ct-search.mjs +120 -64
  88. package/lib/distributed-operation-budget.mjs +4 -2
  89. package/lib/dns-delegation-health.mjs +9 -6
  90. package/lib/dns-intelligence.mjs +4 -3
  91. package/lib/dns-resolver-selection.mjs +6 -4
  92. package/lib/dnssec-chain-validation.mjs +1052 -0
  93. package/lib/dnssec-evidence-validation.mjs +23 -10
  94. package/lib/domain-assurance.mjs +12 -12
  95. package/lib/domain-change-packet.mjs +7 -5
  96. package/lib/domain-control-flight-recorder.mjs +442 -78
  97. package/lib/domain-control-manifest.mjs +314 -171
  98. package/lib/domain-posture-analysis.mjs +5 -2
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/encrypted-dns-contract.mjs +2 -3
  101. package/lib/evidence-export.mjs +1243 -88
  102. package/lib/evidence-report-markdown.mjs +3 -0
  103. package/lib/evidence-report.mjs +5 -0
  104. package/lib/external-intelligence-risk.mjs +26 -10
  105. package/lib/favicon.mjs +8 -5
  106. package/lib/feature-policy.mjs +1 -1
  107. package/lib/homepage-metadata-contract.mjs +290 -0
  108. package/lib/hostname.mjs +1 -1
  109. package/lib/html-signals.mjs +89 -5
  110. package/lib/http-evidence-bounds.mjs +3 -0
  111. package/lib/http-intelligence.mjs +332 -6
  112. package/lib/interchange-fidelity-registry.mjs +124 -0
  113. package/lib/local-geoip-evidence.mjs +2 -1
  114. package/lib/lookup-cache.mjs +6 -4
  115. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  116. package/lib/lookup.mjs +27 -4
  117. package/lib/observed-network-context.mjs +1 -1
  118. package/lib/outbound-identity.mjs +2 -3
  119. package/lib/page-fingerprints.mjs +2 -2
  120. package/lib/page-role-profile.mjs +1 -1
  121. package/lib/perceptual-hash.mjs +12 -4
  122. package/lib/portable-generator.mjs +29 -0
  123. package/lib/provider-policy-admission.mjs +11 -4
  124. package/lib/rdap-bootstrap.mjs +76 -16
  125. package/lib/rdap-client.mjs +20 -12
  126. package/lib/rdap-normalization.mjs +324 -80
  127. package/lib/rdap-registrar.mjs +5 -1
  128. package/lib/rdap-transport.mjs +2 -2
  129. package/lib/rdap-validation.mjs +65 -14
  130. package/lib/rdap.mjs +2 -2
  131. package/lib/registrable-domain.mjs +36 -0
  132. package/lib/registry-capabilities.mjs +3 -0
  133. package/lib/registry-capability-catalogue.mjs +12 -2
  134. package/lib/registry-comparison.mjs +28 -11
  135. package/lib/registry-insights.mjs +7 -0
  136. package/lib/response-policy.mjs +27 -4
  137. package/lib/risk-calibration-summary.mjs +340 -0
  138. package/lib/risk-scoring.mjs +4 -3
  139. package/lib/rpki-evidence.mjs +9 -8
  140. package/lib/safe-fetch.mjs +121 -50
  141. package/lib/security-txt.mjs +27 -4
  142. package/lib/service-binding-dns.mjs +6 -1
  143. package/lib/smtp-transport-review.mjs +787 -0
  144. package/lib/sslbl-certificates.generated.mjs +136 -134
  145. package/lib/sslbl-intelligence.mjs +6 -8
  146. package/lib/static-html-analysis.mjs +301 -4
  147. package/lib/structured-data-identity.mjs +51 -19
  148. package/lib/threat-intelligence-fixtures.mjs +2 -4
  149. package/lib/threat-intelligence-manifests.mjs +2 -7
  150. package/lib/threat-intelligence-runtime.mjs +5 -15
  151. package/lib/threat-intelligence-types.mjs +21 -1
  152. package/lib/threatfox-intelligence.mjs +7 -3
  153. package/lib/tls-intelligence.mjs +15 -9
  154. package/lib/tlsa-evidence.mjs +37 -7
  155. package/lib/urlhaus-intelligence.mjs +4 -2
  156. package/lib/urlscan-intelligence.mjs +6 -5
  157. package/lib/website-security-posture.mjs +1 -1
  158. package/lib/website-technology.mjs +1 -1
  159. package/lib/whois-authority.mjs +43 -8
  160. package/lib/whois-chain.mjs +25 -3
  161. package/lib/whois-normalization.mjs +3 -1
  162. package/lib/whois-transport.mjs +12 -3
  163. package/lib/zone-intent-review.mjs +1 -1
  164. package/package.json +12 -4
  165. package/packages/cases/case-brand-profile-references.mjs +85 -0
  166. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
  167. package/packages/cases/case-investigation-branch-model.mjs +132 -0
  168. package/packages/cases/case-migration-model.mjs +476 -0
  169. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  170. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -19
  171. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  172. package/packages/cases/case-record-model.mjs +9 -0
  173. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +106 -33
  174. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
  175. package/packages/cases/case-response-model.mjs +1604 -0
  176. package/packages/cases/case-response-packet.mjs +1312 -0
  177. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  178. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  179. package/packages/cases/opaque-reference-id.mjs +9 -0
  180. package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
  181. package/packages/comparison/comparison-ledger-contract.mjs +241 -0
  182. package/packages/comparison/comparison-ledger-json.mjs +80 -0
  183. package/packages/comparison/comparison-ledger-serialization.mjs +52 -0
  184. package/packages/comparison/favicon-similarity.mjs +78 -0
  185. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  186. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  187. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  188. package/packages/contracts/analyst-review-state.mjs +50 -0
  189. package/packages/contracts/case-portability.mjs +1310 -0
  190. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  191. package/packages/contracts/cli-lookup.mjs +394 -0
  192. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  193. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  194. package/packages/contracts/domain-control-monitor.mjs +310 -0
  195. package/packages/contracts/domain-control-review.mjs +828 -0
  196. package/packages/contracts/domain-name.mjs +4 -0
  197. package/packages/contracts/external-observation-interchange.mjs +224 -0
  198. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  199. package/packages/contracts/investigation-portability.mjs +331 -0
  200. package/packages/contracts/offline-comparison.mjs +160 -0
  201. package/packages/contracts/risk-calibration.mjs +1288 -0
  202. package/packages/contracts/schema-compatibility.mjs +71 -0
  203. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  204. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  205. package/packages/contracts/workspace-portability.mjs +1347 -0
  206. package/packages/evidence/artifact-integrity.mjs +65 -0
  207. package/packages/evidence/decision-fact.mjs +735 -0
  208. package/packages/evidence/domain-control-runtime.mjs +547 -0
  209. package/packages/evidence/domain-name.mjs +41 -0
  210. package/packages/evidence/observation.mjs +384 -0
  211. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +86 -20
  212. package/packages/interchange/retained-text.mjs +8 -0
  213. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
  214. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
  215. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
  216. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
  217. package/packages/investigation/bulk-review-policy.mjs +1 -0
  218. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
  219. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  220. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  221. package/packages/investigation/lookup-claim-passport.mjs +232 -0
  222. package/packages/investigation/lookup-claim-readiness.mjs +287 -0
  223. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  224. package/packages/monitoring/analyst-review-state.mjs +680 -0
  225. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +97 -50
  226. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
  227. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
  228. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -5
  229. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  230. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  231. package/packages/workspace/hostile-input.mjs +172 -0
  232. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +51 -46
  233. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
  234. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  235. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  236. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  237. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  238. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  239. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +18 -12
  240. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  241. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +195 -84
  242. package/third-party-notices.txt +112 -5834
  243. package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
  244. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  245. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  246. package/frontend/src/lib/analysis/case-migration-model.js +0 -253
  247. package/frontend/src/lib/analysis/case-record-model.js +0 -7
  248. package/frontend/src/lib/analysis/case-response-model.js +0 -669
  249. package/frontend/src/lib/analysis/case-response-packet.js +0 -573
  250. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
  251. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  252. package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
  253. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  254. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  255. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  256. package/frontend/src/lib/analysis/scoring.js +0 -55
  257. package/frontend/src/lib/analysis/utils.js +0 -232
  258. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  259. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  260. package/frontend/src/lib/analysis/visualization-matrix.js +0 -88
  261. package/frontend/src/lib/analysis/visualization-models.js +0 -369
  262. package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
  263. package/lib/bounded-json-response.mjs +0 -157
  264. package/lib/lookup-response-contract.mjs +0 -346
  265. package/lib/observation.mjs +0 -114
  266. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  267. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  268. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -63,6 +63,9 @@ function formatLookupEvidenceMarkdown(document, options = {}) {
63
63
  lines.push('', '## Limitations', '');
64
64
  for (const limitation of report.limitations)
65
65
  lines.push(`- ${limitation}`);
66
+ if (report.attribution) {
67
+ lines.push('', '---', '', report.attribution);
68
+ }
66
69
  lines.push('');
67
70
  return lines.join('\n');
68
71
  }
@@ -1,4 +1,5 @@
1
1
  import { registryAccessProfileLabel } from "./registry-access.mjs";
2
+ import { buildPortableGeneratorMetadata, portableGeneratorAttribution, } from "./portable-generator.mjs";
2
3
  const MAX_REPORT_VALUE_LENGTH = 300;
3
4
  const MAX_REPORT_LIST_ITEMS = 50;
4
5
  const MAX_REPORT_COMPARISON_FIELDS = 20;
@@ -90,6 +91,7 @@ function reportField(label, value, fallback = 'Not reported') {
90
91
  function buildLookupEvidenceReport(document, options = {}) {
91
92
  const report = objectOrEmpty(document);
92
93
  const application = objectOrEmpty(report.application);
94
+ const generator = buildPortableGeneratorMetadata(application.version);
93
95
  const query = objectOrEmpty(report.query);
94
96
  const diagnostics = objectOrEmpty(report.diagnostics);
95
97
  const rdapDiagnostics = objectOrEmpty(diagnostics.rdap);
@@ -389,6 +391,9 @@ function buildLookupEvidenceReport(document, options = {}) {
389
391
  'Registry access constraints describe collection reachability only. They do not decide registration, availability, ownership, safety, or maliciousness.',
390
392
  ] : []),
391
393
  ],
394
+ attribution: options.includeAttribution === false
395
+ ? null
396
+ : portableGeneratorAttribution(generator),
392
397
  };
393
398
  }
394
399
  export { MAX_REPORT_COMPARISON_FIELDS, MAX_REPORT_LIST_ITEMS, MAX_REPORT_RISK_FACTORS, MAX_REPORT_VALUE_LENGTH, buildLookupEvidenceReport, cleanReportText, };
@@ -13,6 +13,9 @@ const QUALIFYING_CATEGORIES = new Set(['phishing', 'malware']);
13
13
  // Only built-in provider IDs can affect the built-in score. The two community
14
14
  // malware datasets share one publisher family and therefore cannot corroborate
15
15
  // one another by themselves.
16
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
17
+ import { canonicalRegistrableDomain } from "./registrable-domain.mjs";
18
+ import { THREAT_INTELLIGENCE_CONTRACT_VERSION, THREAT_INTELLIGENCE_ENVELOPE_VERSION, THREAT_INTELLIGENCE_SCHEMA, } from "./threat-intelligence-types.mjs";
16
19
  const PUBLISHER_FAMILIES = Object.freeze({
17
20
  urlscan_search: 'archived-scan-publisher',
18
21
  urlhaus_host: 'community-malware-publisher',
@@ -22,20 +25,24 @@ function record(value) {
22
25
  return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
23
26
  }
24
27
  function timestamp(value) {
25
- if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u001f\u007f]/u.test(value))
26
- return null;
27
- const parsed = Date.parse(value);
28
- return Number.isFinite(parsed) ? parsed : null;
28
+ const normalized = normalizeExplicitIsoTimestamp(value);
29
+ return normalized ? Date.parse(normalized) : null;
29
30
  }
30
- function providerEvidence(value) {
31
+ function providerEvidence(value, expectedDomain) {
31
32
  const provider = record(value);
32
33
  const identity = record(provider?.provider);
33
34
  const observation = record(provider?.observation);
35
+ const target = record(provider?.target);
34
36
  const providerId = typeof identity?.id === 'string' ? identity.id : '';
35
37
  const publisherFamily = Object.hasOwn(PUBLISHER_FAMILIES, providerId)
36
38
  ? PUBLISHER_FAMILIES[providerId]
37
39
  : null;
38
- if (!publisherFamily
40
+ if (provider?.schema !== THREAT_INTELLIGENCE_SCHEMA
41
+ || provider.version !== THREAT_INTELLIGENCE_CONTRACT_VERSION
42
+ || target?.type !== 'domain'
43
+ || target.exposure !== 'registrable_domain'
44
+ || target.value !== expectedDomain
45
+ || !publisherFamily
39
46
  || typeof provider?.state !== 'string'
40
47
  || !POSITIVE_STATES.has(provider.state)
41
48
  || !Array.isArray(provider.findings))
@@ -47,8 +54,12 @@ function providerEvidence(value) {
47
54
  const finding = record(item);
48
55
  if (!finding || typeof finding.category !== 'string' || !QUALIFYING_CATEGORIES.has(finding.category))
49
56
  continue;
57
+ const firstObservedAt = timestamp(finding.firstObservedAt);
58
+ const lastObservedAt = timestamp(finding.lastObservedAt);
59
+ if (firstObservedAt !== null && lastObservedAt !== null && firstObservedAt > lastObservedAt)
60
+ continue;
50
61
  qualifyingFindings += 1;
51
- const candidate = timestamp(finding.lastObservedAt) ?? timestamp(finding.firstObservedAt);
62
+ const candidate = lastObservedAt ?? firstObservedAt;
52
63
  if (candidate !== null && (latestFindingAt === null || candidate > latestFindingAt))
53
64
  latestFindingAt = candidate;
54
65
  }
@@ -67,12 +78,17 @@ function providerEvidence(value) {
67
78
  recent: ageDays !== null && ageDays <= EXTERNAL_INTELLIGENCE_RECENT_DAYS,
68
79
  };
69
80
  }
70
- export function calibrateExternalIntelligenceRisk(value) {
81
+ export function calibrateExternalIntelligenceRisk(value, expectedDomainValue) {
71
82
  const envelope = record(value);
72
- const providers = Array.isArray(envelope?.providers) ? envelope.providers.slice(0, MAX_PROVIDERS) : [];
83
+ const expectedDomain = canonicalRegistrableDomain(expectedDomainValue);
84
+ const providers = expectedDomain
85
+ && envelope?.version === THREAT_INTELLIGENCE_ENVELOPE_VERSION
86
+ && Array.isArray(envelope.providers)
87
+ ? envelope.providers.slice(0, MAX_PROVIDERS)
88
+ : [];
73
89
  const byProvider = new Map();
74
90
  for (const item of providers) {
75
- const evidence = providerEvidence(item);
91
+ const evidence = providerEvidence(item, expectedDomain ?? '');
76
92
  if (!evidence || byProvider.has(evidence.providerId))
77
93
  continue;
78
94
  byProvider.set(evidence.providerId, evidence);
package/lib/favicon.mjs CHANGED
@@ -73,13 +73,13 @@ function decodeDataUri(uri) {
73
73
  }
74
74
  // Returns the favicon bytes for one candidate URL (a real fetch, or an inline
75
75
  // data: decode), or null if it can't be retrieved/used.
76
- async function fetchFaviconBytes(url, headers) {
76
+ async function fetchFaviconBytes(url, headers, fetcher, timeoutMs) {
77
77
  if (/^data:/i.test(url))
78
78
  return decodeDataUri(url);
79
79
  const controller = new AbortController();
80
- const timeout = setTimeout(() => controller.abort(), FAVICON_FETCH_TIMEOUT_MS);
80
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
81
81
  try {
82
- const res = await safeFetch(url, { signal: controller.signal, headers });
82
+ const res = await fetcher(url, { signal: controller.signal, headers });
83
83
  if (!res.ok) {
84
84
  // Not reading this body - release it explicitly instead of leaving an
85
85
  // unconsumed stream (and the connection it's tied to) open until
@@ -123,12 +123,15 @@ function buildFaviconCandidates(domain, html = '') {
123
123
  add(`http://${domain}/favicon.svg`);
124
124
  return candidates.slice(0, MAX_FAVICON_CANDIDATES);
125
125
  }
126
- async function fetchFaviconHash(domain, { html = '' } = {}) {
126
+ async function fetchFaviconHash(domain, { html = '', fetcher = safeFetch, timeoutMs = FAVICON_FETCH_TIMEOUT_MS } = {}) {
127
127
  const headers = whoisleuthRequestHeaders();
128
128
  const candidates = buildFaviconCandidates(domain, html);
129
+ const requestTimeoutMs = Number.isInteger(timeoutMs) && timeoutMs >= 10 && timeoutMs <= FAVICON_FETCH_TIMEOUT_MS
130
+ ? timeoutMs
131
+ : FAVICON_FETCH_TIMEOUT_MS;
129
132
  for (const url of candidates) {
130
133
  // eslint-disable-next-line no-await-in-loop
131
- const bytes = await fetchFaviconBytes(url, headers);
134
+ const bytes = await fetchFaviconBytes(url, headers, fetcher, requestTimeoutMs);
132
135
  if (bytes) {
133
136
  return {
134
137
  hash: crypto.createHash('sha256').update(bytes).digest('hex'),
@@ -14,7 +14,7 @@ const NETWORK_FEATURE_DEFINITIONS = Object.freeze({
14
14
  availability: Object.freeze({ env: 'WHOISLEUTH_DISABLE_AVAILABILITY', label: 'availability analysis' }),
15
15
  dns_intelligence: Object.freeze({ env: 'WHOISLEUTH_DISABLE_DNS_INTELLIGENCE', label: 'DNS intelligence' }),
16
16
  website_probe: Object.freeze({ env: 'WHOISLEUTH_DISABLE_WEBSITE_PROBE', label: 'website probing' }),
17
- tls_intelligence: Object.freeze({ env: 'WHOISLEUTH_DISABLE_TLS_INTELLIGENCE', label: 'TLS intelligence' }),
17
+ tls_intelligence: Object.freeze({ env: 'WHOISLEUTH_DISABLE_TLS_INTELLIGENCE', label: 'TLS evidence collection' }),
18
18
  certificate_transparency: Object.freeze({ env: 'WHOISLEUTH_DISABLE_CERTIFICATE_TRANSPARENCY', label: 'Certificate Transparency search' }),
19
19
  domain_posture: Object.freeze({ env: 'WHOISLEUTH_DISABLE_DOMAIN_POSTURE', label: 'domain-posture auditing' }),
20
20
  });
@@ -0,0 +1,290 @@
1
+ // Exact bounded contracts for metadata derived from the already-captured Deep
2
+ // homepage response. These validators perform no collection and accept only
3
+ // fixed enums, booleans, counts, and reviewed limitation text.
4
+ const PAGE_PUBLICATION_METADATA_VERSION = 1;
5
+ const HTTP_DELIVERY_METADATA_VERSION = 1;
6
+ const MAX_PAGE_PUBLICATION_TAG_COUNT = 8_192;
7
+ const MAX_PAGE_PUBLICATION_META_ELEMENTS = 128;
8
+ const MAX_PAGE_PUBLICATION_DECLARATIONS = 64;
9
+ const MAX_PAGE_PUBLICATION_ROBOTS_DIRECTIVES = 64;
10
+ const MAX_HTTP_DELIVERY_TOKENS = 32;
11
+ const MAX_HTTP_DELIVERY_HEADER_BYTES = 1_024;
12
+ const MAX_HTTP_CACHE_SECONDS = 315_360_000;
13
+ const MAX_HOMEPAGE_METADATA_LIMITATIONS = 4;
14
+ const MAX_HOMEPAGE_METADATA_LIMITATION_LENGTH = 300;
15
+ const PAGE_PUBLICATION_LIMITATIONS = Object.freeze({
16
+ scope: 'Counts and declarations describe only the captured static homepage HTML; they are not a full accessibility, indexing, or performance audit.',
17
+ body: 'Homepage body capture reached its byte limit; later declarations and elements may be absent.',
18
+ bounds: 'Static HTML token or attribute bounds were reached; publication metadata is partial.',
19
+ malformed: 'At least one publication declaration could not be interpreted; raw declaration values were not retained.',
20
+ });
21
+ const HTTP_DELIVERY_LIMITATIONS = Object.freeze({
22
+ scope: 'Selected-response headers are point-in-time declarations and do not prove intermediary caching, compression effectiveness, or page performance.',
23
+ bounds: 'At least one delivery header exceeded its byte or item bound and was not partially parsed.',
24
+ malformed: 'At least one delivery header could not be interpreted; raw header values were not retained.',
25
+ });
26
+ const COMPONENT_STATES = new Set(['observed', 'not_observed', 'partial', 'malformed']);
27
+ const ROBOTS_DIRECTIVES = new Set([
28
+ 'all', 'follow', 'index', 'max-image-preview', 'max-snippet', 'max-video-preview',
29
+ 'noarchive', 'nocache', 'nofollow', 'noimageindex', 'noindex', 'none', 'nositelinkssearchbox',
30
+ 'nosnippet', 'notranslate', 'unavailable_after',
31
+ ]);
32
+ const TWITTER_CARD_TYPES = new Set(['summary', 'summary_large_image', 'player', 'app', 'other']);
33
+ const CONTENT_CODINGS = new Set(['gzip', 'br', 'deflate', 'zstd', 'identity', 'other']);
34
+ const CONTROL_RE = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/u;
35
+ function record(value) {
36
+ return value && typeof value === 'object' && !Array.isArray(value)
37
+ ? value
38
+ : null;
39
+ }
40
+ function exactKeys(value, keys) {
41
+ const actual = Object.keys(value).sort();
42
+ const expected = [...keys].sort();
43
+ return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
44
+ }
45
+ function boundedCount(value, maximum) {
46
+ return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= maximum;
47
+ }
48
+ function fixedSortedStrings(value, allowed, maximum) {
49
+ if (!Array.isArray(value) || value.length > maximum || !value.every((item) => typeof item === 'string' && allowed.has(item)))
50
+ return false;
51
+ return value.every((item, index) => index === 0 || String(value[index - 1]) < item);
52
+ }
53
+ function fixedLimitations(value, allowed) {
54
+ if (!Array.isArray(value) || value.length < 1 || value.length > MAX_HOMEPAGE_METADATA_LIMITATIONS)
55
+ return false;
56
+ return value.every((item, index) => typeof item === 'string'
57
+ && item.length <= MAX_HOMEPAGE_METADATA_LIMITATION_LENGTH
58
+ && !CONTROL_RE.test(item)
59
+ && allowed.includes(item)
60
+ && value.indexOf(item) === index);
61
+ }
62
+ function validComponentState(value) {
63
+ return typeof value === 'string' && COMPONENT_STATES.has(value);
64
+ }
65
+ function validPagePublicationMetadata(value) {
66
+ const root = record(value);
67
+ if (!root || !exactKeys(root, [
68
+ 'version', 'status', 'complete', 'truncated', 'limitations', 'robots', 'twitterCard',
69
+ 'headings', 'images', 'renderBlockingCandidates',
70
+ ])
71
+ || root.version !== PAGE_PUBLICATION_METADATA_VERSION
72
+ || !['success', 'partial'].includes(String(root.status))
73
+ || typeof root.complete !== 'boolean'
74
+ || typeof root.truncated !== 'boolean'
75
+ || !fixedLimitations(root.limitations, Object.values(PAGE_PUBLICATION_LIMITATIONS)))
76
+ return false;
77
+ const robots = record(root.robots);
78
+ const twitter = record(root.twitterCard);
79
+ const headings = record(root.headings);
80
+ const images = record(root.images);
81
+ const blocking = record(root.renderBlockingCandidates);
82
+ if (!robots || !twitter || !headings || !images || !blocking)
83
+ return false;
84
+ if (!exactKeys(robots, [
85
+ 'status', 'complete', 'truncated', 'directives', 'recognizedDirectiveCount', 'unknownDirectiveCount', 'conflicting',
86
+ ])
87
+ || !validComponentState(robots.status)
88
+ || typeof robots.complete !== 'boolean'
89
+ || typeof robots.truncated !== 'boolean'
90
+ || !fixedSortedStrings(robots.directives, ROBOTS_DIRECTIVES, MAX_PAGE_PUBLICATION_ROBOTS_DIRECTIVES)
91
+ || !boundedCount(robots.recognizedDirectiveCount, MAX_PAGE_PUBLICATION_ROBOTS_DIRECTIVES)
92
+ || !boundedCount(robots.unknownDirectiveCount, MAX_PAGE_PUBLICATION_ROBOTS_DIRECTIVES)
93
+ || Number(robots.recognizedDirectiveCount) + Number(robots.unknownDirectiveCount) > MAX_PAGE_PUBLICATION_ROBOTS_DIRECTIVES
94
+ || Number(robots.recognizedDirectiveCount) < robots.directives.length
95
+ || typeof robots.conflicting !== 'boolean')
96
+ return false;
97
+ if (robots.status === 'partial'
98
+ ? robots.complete !== false || robots.truncated !== true
99
+ : robots.status === 'malformed'
100
+ ? robots.complete !== false || robots.truncated !== false
101
+ : robots.complete !== true || robots.truncated !== false)
102
+ return false;
103
+ const robotValues = new Set(robots.directives);
104
+ const conflict = (robotValues.has('index') || robotValues.has('all'))
105
+ && (robotValues.has('noindex') || robotValues.has('none'))
106
+ || (robotValues.has('follow') || robotValues.has('all'))
107
+ && (robotValues.has('nofollow') || robotValues.has('none'));
108
+ if (robots.conflicting !== conflict)
109
+ return false;
110
+ if (robots.status === 'observed' && (Number(robots.recognizedDirectiveCount) + Number(robots.unknownDirectiveCount) === 0
111
+ || (Number(robots.recognizedDirectiveCount) === 0) !== (robots.directives.length === 0)))
112
+ return false;
113
+ if (robots.status === 'not_observed' && (robots.directives.length || robots.recognizedDirectiveCount !== 0
114
+ || robots.unknownDirectiveCount !== 0 || robots.conflicting !== false))
115
+ return false;
116
+ if (!exactKeys(twitter, [
117
+ 'status', 'complete', 'truncated', 'cardType', 'declarationCount', 'titlePresent', 'descriptionPresent', 'imagePresent',
118
+ 'imageAltPresent', 'sitePresent', 'creatorPresent', 'playerPresent', 'appPresent',
119
+ ])
120
+ || !validComponentState(twitter.status)
121
+ || typeof twitter.complete !== 'boolean'
122
+ || typeof twitter.truncated !== 'boolean'
123
+ || !(twitter.cardType === null || typeof twitter.cardType === 'string' && TWITTER_CARD_TYPES.has(twitter.cardType))
124
+ || !boundedCount(twitter.declarationCount, MAX_PAGE_PUBLICATION_DECLARATIONS)
125
+ || !['titlePresent', 'descriptionPresent', 'imagePresent', 'imageAltPresent', 'sitePresent', 'creatorPresent', 'playerPresent', 'appPresent']
126
+ .every((key) => typeof twitter[key] === 'boolean'))
127
+ return false;
128
+ if (twitter.status === 'partial'
129
+ ? twitter.complete !== false || twitter.truncated !== true
130
+ : twitter.status === 'malformed'
131
+ ? twitter.complete !== false || twitter.truncated !== false
132
+ : twitter.complete !== true || twitter.truncated !== false)
133
+ return false;
134
+ if (twitter.status === 'not_observed' && (twitter.cardType !== null || twitter.declarationCount !== 0
135
+ || ['titlePresent', 'descriptionPresent', 'imagePresent', 'imageAltPresent', 'sitePresent', 'creatorPresent', 'playerPresent', 'appPresent']
136
+ .some((key) => twitter[key] === true)))
137
+ return false;
138
+ const twitterValuePresent = twitter.cardType !== null
139
+ || ['titlePresent', 'descriptionPresent', 'imagePresent', 'imageAltPresent', 'sitePresent', 'creatorPresent', 'playerPresent', 'appPresent']
140
+ .some((key) => twitter[key] === true);
141
+ if (twitterValuePresent && twitter.declarationCount === 0)
142
+ return false;
143
+ if (twitter.status === 'observed' && (twitter.declarationCount === 0 || !twitterValuePresent))
144
+ return false;
145
+ if (!exactKeys(headings, ['complete', 'truncated', 'total', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
146
+ || typeof headings.complete !== 'boolean'
147
+ || typeof headings.truncated !== 'boolean'
148
+ || headings.complete === headings.truncated
149
+ || !['total', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].every((key) => boundedCount(headings[key], MAX_PAGE_PUBLICATION_TAG_COUNT))
150
+ || headings.total !== ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].reduce((sum, key) => sum + Number(headings[key]), 0))
151
+ return false;
152
+ if (!exactKeys(images, [
153
+ 'totalComplete', 'classificationComplete', 'truncated',
154
+ 'total', 'altMissing', 'altEmpty', 'altNonEmpty', 'altUnclassified',
155
+ ])
156
+ || typeof images.totalComplete !== 'boolean'
157
+ || typeof images.classificationComplete !== 'boolean'
158
+ || typeof images.truncated !== 'boolean'
159
+ || images.truncated !== (images.totalComplete !== true || images.classificationComplete !== true)
160
+ || images.classificationComplete === true && (images.totalComplete !== true || images.altUnclassified !== 0)
161
+ || !['total', 'altMissing', 'altEmpty', 'altNonEmpty', 'altUnclassified'].every((key) => boundedCount(images[key], MAX_PAGE_PUBLICATION_TAG_COUNT))
162
+ || images.total !== ['altMissing', 'altEmpty', 'altNonEmpty', 'altUnclassified'].reduce((sum, key) => sum + Number(images[key]), 0))
163
+ return false;
164
+ if (!exactKeys(blocking, ['complete', 'truncated', 'script', 'stylesheet', 'total', 'scope'])
165
+ || typeof blocking.complete !== 'boolean'
166
+ || typeof blocking.truncated !== 'boolean'
167
+ || blocking.complete === blocking.truncated
168
+ || !['script', 'stylesheet', 'total'].every((key) => boundedCount(blocking[key], MAX_PAGE_PUBLICATION_TAG_COUNT))
169
+ || blocking.total !== Number(blocking.script) + Number(blocking.stylesheet)
170
+ || blocking.scope !== 'explicit-head-static-v1')
171
+ return false;
172
+ const componentTruncated = robots.truncated === true || twitter.truncated === true
173
+ || headings.truncated === true || images.truncated === true || blocking.truncated === true;
174
+ const componentIncomplete = robots.complete !== true || twitter.complete !== true
175
+ || headings.complete !== true || images.totalComplete !== true
176
+ || images.classificationComplete !== true || blocking.complete !== true;
177
+ if (root.truncated !== componentTruncated)
178
+ return false;
179
+ if (root.status === 'success' ? root.complete !== true || componentIncomplete : root.complete !== false || !componentIncomplete)
180
+ return false;
181
+ const limitations = root.limitations;
182
+ const hasTruncationLimitation = limitations.some((item) => (item === PAGE_PUBLICATION_LIMITATIONS.body || item === PAGE_PUBLICATION_LIMITATIONS.bounds));
183
+ const hasMalformedLimitation = limitations.includes(PAGE_PUBLICATION_LIMITATIONS.malformed);
184
+ const malformedComponent = [robots.status, twitter.status].includes('malformed');
185
+ if (root.truncated !== hasTruncationLimitation
186
+ || malformedComponent && !hasMalformedLimitation
187
+ || root.status === 'success' && hasMalformedLimitation)
188
+ return false;
189
+ return limitations.includes(PAGE_PUBLICATION_LIMITATIONS.scope);
190
+ }
191
+ function validPresenceValidity(value) {
192
+ const item = record(value);
193
+ return Boolean(item && exactKeys(item, ['present', 'valid'])
194
+ && typeof item.present === 'boolean'
195
+ && (item.valid === null || typeof item.valid === 'boolean')
196
+ && (item.present === true || item.valid === null));
197
+ }
198
+ function validHttpDeliveryMetadata(value) {
199
+ const root = record(value);
200
+ if (!root || !exactKeys(root, ['version', 'status', 'complete', 'truncated', 'limitations', 'contentEncoding', 'cachePolicy'])
201
+ || root.version !== HTTP_DELIVERY_METADATA_VERSION
202
+ || !['success', 'partial'].includes(String(root.status))
203
+ || typeof root.complete !== 'boolean'
204
+ || typeof root.truncated !== 'boolean'
205
+ || !fixedLimitations(root.limitations, Object.values(HTTP_DELIVERY_LIMITATIONS)))
206
+ return false;
207
+ const encoding = record(root.contentEncoding);
208
+ const cache = record(root.cachePolicy);
209
+ if (!encoding || !cache)
210
+ return false;
211
+ if (!exactKeys(encoding, ['status', 'codings', 'encoded', 'unknownCodingCount'])
212
+ || !validComponentState(encoding.status)
213
+ || !fixedSortedStrings(encoding.codings, CONTENT_CODINGS, MAX_HTTP_DELIVERY_TOKENS)
214
+ || !(encoding.encoded === null || typeof encoding.encoded === 'boolean')
215
+ || !boundedCount(encoding.unknownCodingCount, MAX_HTTP_DELIVERY_TOKENS))
216
+ return false;
217
+ const codings = encoding.codings;
218
+ if (encoding.status === 'observed') {
219
+ if (!codings.length || typeof encoding.encoded !== 'boolean')
220
+ return false;
221
+ if (encoding.encoded !== codings.some((coding) => coding !== 'identity'))
222
+ return false;
223
+ if ((encoding.unknownCodingCount === 0) !== !codings.includes('other'))
224
+ return false;
225
+ }
226
+ else if (codings.length || encoding.encoded !== null || encoding.unknownCodingCount !== 0)
227
+ return false;
228
+ if (!exactKeys(cache, [
229
+ 'status', 'noStore', 'noCache', 'mustRevalidate', 'public', 'private', 'immutable',
230
+ 'maxAgeSeconds', 'sMaxAgeSeconds', 'ageSeconds', 'maxAgePresent', 'sMaxAgePresent',
231
+ 'agePresent', 'unknownDirectiveCount', 'etag',
232
+ 'lastModified', 'expires',
233
+ ])
234
+ || !validComponentState(cache.status)
235
+ || !['noStore', 'noCache', 'mustRevalidate', 'public', 'private', 'immutable'].every((key) => typeof cache[key] === 'boolean')
236
+ || !['maxAgeSeconds', 'sMaxAgeSeconds', 'ageSeconds'].every((key) => cache[key] === null || boundedCount(cache[key], MAX_HTTP_CACHE_SECONDS))
237
+ || !['maxAgePresent', 'sMaxAgePresent', 'agePresent'].every((key) => typeof cache[key] === 'boolean')
238
+ || cache.maxAgePresent === false && cache.maxAgeSeconds !== null
239
+ || cache.sMaxAgePresent === false && cache.sMaxAgeSeconds !== null
240
+ || cache.agePresent === false && cache.ageSeconds !== null
241
+ || !boundedCount(cache.unknownDirectiveCount, MAX_HTTP_DELIVERY_TOKENS)
242
+ || !validPresenceValidity(cache.etag)
243
+ || !validPresenceValidity(cache.lastModified)
244
+ || !validPresenceValidity(cache.expires))
245
+ return false;
246
+ if (cache.status === 'not_observed' && (['noStore', 'noCache', 'mustRevalidate', 'public', 'private', 'immutable'].some((key) => cache[key] === true)
247
+ || ['maxAgeSeconds', 'sMaxAgeSeconds', 'ageSeconds'].some((key) => cache[key] !== null)
248
+ || ['maxAgePresent', 'sMaxAgePresent', 'agePresent'].some((key) => cache[key] === true)
249
+ || cache.unknownDirectiveCount !== 0
250
+ || [cache.etag, cache.lastModified, cache.expires].some((item) => item.present === true)))
251
+ return false;
252
+ const numericCacheEvidence = [
253
+ [cache.maxAgePresent, cache.maxAgeSeconds],
254
+ [cache.sMaxAgePresent, cache.sMaxAgeSeconds],
255
+ [cache.agePresent, cache.ageSeconds],
256
+ ];
257
+ const validatorEvidence = [cache.etag, cache.lastModified, cache.expires]
258
+ .map((item) => item);
259
+ const cacheEvidencePresent = ['noStore', 'noCache', 'mustRevalidate', 'public', 'private', 'immutable']
260
+ .some((key) => cache[key] === true)
261
+ || numericCacheEvidence.some(([present]) => present === true)
262
+ || cache.unknownDirectiveCount !== 0
263
+ || validatorEvidence.some((item) => item.present === true);
264
+ if (cache.status === 'observed' && (!cacheEvidencePresent
265
+ || numericCacheEvidence.some(([present, result]) => present === true && result === null)
266
+ || validatorEvidence.some((item) => item.present === true && item.valid !== true)))
267
+ return false;
268
+ const uncertainValidator = [cache.etag, cache.lastModified, cache.expires]
269
+ .some((item) => item.present === true
270
+ && item.valid === null);
271
+ if (uncertainValidator && !['partial', 'malformed'].includes(String(cache.status)))
272
+ return false;
273
+ const componentIncomplete = [encoding.status, cache.status].some((state) => state === 'partial' || state === 'malformed');
274
+ if (root.status === 'success' ? root.complete !== true || componentIncomplete : root.complete !== false || !componentIncomplete)
275
+ return false;
276
+ if (root.truncated !== [encoding.status, cache.status].includes('partial'))
277
+ return false;
278
+ const limitations = root.limitations;
279
+ const hasBoundsLimitation = limitations.includes(HTTP_DELIVERY_LIMITATIONS.bounds);
280
+ const hasMalformedLimitation = limitations.includes(HTTP_DELIVERY_LIMITATIONS.malformed);
281
+ const definitelyMalformed = [encoding.status, cache.status].includes('malformed')
282
+ || numericCacheEvidence.some(([present, result]) => present === true && result === null)
283
+ || validatorEvidence.some((item) => item.present === true && item.valid === false);
284
+ if (root.truncated !== hasBoundsLimitation
285
+ || definitelyMalformed && !hasMalformedLimitation
286
+ || root.status === 'success' && hasMalformedLimitation)
287
+ return false;
288
+ return limitations.includes(HTTP_DELIVERY_LIMITATIONS.scope);
289
+ }
290
+ export { HTTP_DELIVERY_LIMITATIONS, MAX_HTTP_DELIVERY_HEADER_BYTES, HTTP_DELIVERY_METADATA_VERSION, MAX_HTTP_CACHE_SECONDS, MAX_HTTP_DELIVERY_TOKENS, MAX_PAGE_PUBLICATION_DECLARATIONS, MAX_PAGE_PUBLICATION_META_ELEMENTS, MAX_PAGE_PUBLICATION_ROBOTS_DIRECTIVES, MAX_PAGE_PUBLICATION_TAG_COUNT, PAGE_PUBLICATION_LIMITATIONS, PAGE_PUBLICATION_METADATA_VERSION, validHttpDeliveryMetadata, validPagePublicationMetadata, };
package/lib/hostname.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // Linear-time validation for already-normalized ASCII DNS hostnames. Keep this
2
2
  // helper deliberately independent of public-suffix policy: callers decide
3
3
  // whether a syntactically valid hostname must also be registrable.
4
- const ASCII_LABEL_RE = /^[a-z0-9-]+$/iu;
4
+ const ASCII_LABEL_RE = /^[A-Za-z0-9-]+$/u;
5
5
  function isValidAsciiHostname(value, options = {}) {
6
6
  if (typeof value !== 'string'
7
7
  || value.length < 1
@@ -7,7 +7,7 @@ import { isIP } from 'node:net';
7
7
  import { domainToASCII } from 'node:url';
8
8
  import { analyzeCredentialSurfaceProfile } from "./credential-surface-profile.mjs";
9
9
  import { analyzeClientBehavior } from "./client-behavior-profile.mjs";
10
- import { createObservation } from "./observation.mjs";
10
+ import { createObservation } from "../packages/evidence/observation.mjs";
11
11
  import { createPageFingerprints } from "./page-fingerprints.mjs";
12
12
  import { detectPageLanguageSignal } from "./page-language-signals.mjs";
13
13
  import { analyzePageRole } from "./page-role-profile.mjs";
@@ -15,6 +15,7 @@ import { analyzeCspMetaPolicies } from "./response-policy.mjs";
15
15
  import { analyzeStaticHtml } from "./static-html-analysis.mjs";
16
16
  import { analyzeStructuredDataIdentity } from "./structured-data-identity.mjs";
17
17
  import { analyzeWebsiteTechnology } from "./website-technology.mjs";
18
+ import { PAGE_PUBLICATION_LIMITATIONS, PAGE_PUBLICATION_METADATA_VERSION, } from "./homepage-metadata-contract.mjs";
18
19
  const MAX_TITLE_LENGTH = 200;
19
20
  const MAX_EXTERNAL_ASSET_HOSTS = 20;
20
21
  const MAX_IDENTITY_TAGS = 512;
@@ -34,8 +35,8 @@ const MAX_DOWNLOAD_FILE_TYPES = 20;
34
35
  const MAX_TRACKING_IDENTIFIERS = 30;
35
36
  const MAX_URLS_PER_TAG = 20;
36
37
  const PAGE_IDENTITY_VERSION = 3;
37
- const CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/g;
38
- const HAS_CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/;
38
+ const CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f-\u009f]|\p{Default_Ignorable_Code_Point}/gu;
39
+ const HAS_CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f-\u009f]|\p{Default_Ignorable_Code_Point}/u;
39
40
  const TITLE_RE = /<title[^>]*>([\s\S]*?)<\/title>/i;
40
41
  // A password input is the single strongest static-HTML tell that a page is
41
42
  // asking for credentials, regardless of what it claims to be.
@@ -618,6 +619,83 @@ function extractPageIdentity(html, domain, options = {}) {
618
619
  fingerprints,
619
620
  };
620
621
  }
622
+ function publicationSourceStatus(observed, malformed, truncated) {
623
+ if (truncated)
624
+ return 'partial';
625
+ if (malformed)
626
+ return 'malformed';
627
+ return observed ? 'observed' : 'not_observed';
628
+ }
629
+ function buildPagePublicationMetadata(analysis, sourceTruncated) {
630
+ const truncated = sourceTruncated || analysis.truncated;
631
+ const malformed = analysis.robots.malformed || analysis.twitterCard.malformed;
632
+ const incomplete = truncated || malformed;
633
+ const limitations = [
634
+ PAGE_PUBLICATION_LIMITATIONS.scope,
635
+ ];
636
+ if (sourceTruncated)
637
+ limitations.push(PAGE_PUBLICATION_LIMITATIONS.body);
638
+ if (analysis.truncated)
639
+ limitations.push(PAGE_PUBLICATION_LIMITATIONS.bounds);
640
+ if (malformed)
641
+ limitations.push(PAGE_PUBLICATION_LIMITATIONS.malformed);
642
+ const cardTypes = analysis.twitterCard.cardTypes;
643
+ const suppliedCardType = cardTypes.length === 1 ? cardTypes[0] : null;
644
+ const cardType = suppliedCardType && ['summary', 'summary_large_image', 'player', 'app', 'other'].includes(suppliedCardType)
645
+ ? suppliedCardType
646
+ : cardTypes.length > 1 ? 'other' : null;
647
+ const documentTruncated = sourceTruncated || analysis.documentTruncated;
648
+ const robotsStatus = publicationSourceStatus(analysis.robots.observed, analysis.robots.malformed, documentTruncated || analysis.robots.truncated);
649
+ const twitterStatus = publicationSourceStatus(analysis.twitterCard.observed, analysis.twitterCard.malformed, documentTruncated || analysis.twitterCard.truncated);
650
+ return {
651
+ version: PAGE_PUBLICATION_METADATA_VERSION,
652
+ status: incomplete ? 'partial' : 'success',
653
+ complete: !incomplete,
654
+ truncated,
655
+ limitations,
656
+ robots: {
657
+ status: robotsStatus,
658
+ complete: robotsStatus === 'observed' || robotsStatus === 'not_observed',
659
+ truncated: robotsStatus === 'partial',
660
+ directives: analysis.robots.directives,
661
+ recognizedDirectiveCount: analysis.robots.recognizedDirectiveCount,
662
+ unknownDirectiveCount: analysis.robots.unknownDirectiveCount,
663
+ conflicting: analysis.robots.conflicting,
664
+ },
665
+ twitterCard: {
666
+ status: twitterStatus,
667
+ complete: twitterStatus === 'observed' || twitterStatus === 'not_observed',
668
+ truncated: twitterStatus === 'partial',
669
+ cardType,
670
+ declarationCount: analysis.twitterCard.declarationCount,
671
+ titlePresent: analysis.twitterCard.titlePresent,
672
+ descriptionPresent: analysis.twitterCard.descriptionPresent,
673
+ imagePresent: analysis.twitterCard.imagePresent,
674
+ imageAltPresent: analysis.twitterCard.imageAltPresent,
675
+ sitePresent: analysis.twitterCard.sitePresent,
676
+ creatorPresent: analysis.twitterCard.creatorPresent,
677
+ playerPresent: analysis.twitterCard.playerPresent,
678
+ appPresent: analysis.twitterCard.appPresent,
679
+ },
680
+ headings: {
681
+ ...analysis.headings,
682
+ complete: !documentTruncated && !analysis.headings.truncated,
683
+ truncated: documentTruncated || analysis.headings.truncated,
684
+ },
685
+ images: {
686
+ ...analysis.images,
687
+ totalComplete: !documentTruncated && analysis.images.totalComplete,
688
+ classificationComplete: !documentTruncated && analysis.images.classificationComplete,
689
+ truncated: documentTruncated || analysis.images.truncated,
690
+ },
691
+ renderBlockingCandidates: {
692
+ ...analysis.renderBlockingCandidates,
693
+ complete: !documentTruncated && !analysis.renderBlockingCandidates.truncated,
694
+ truncated: documentTruncated || analysis.renderBlockingCandidates.truncated,
695
+ scope: 'explicit-head-static-v1',
696
+ },
697
+ };
698
+ }
621
699
  function extractHtmlSignals(html, domain, options = {}) {
622
700
  const pageIdentity = options.includePageIdentity === false ? null : extractPageIdentity(html, domain, options);
623
701
  const includeCredentialSurfaceProfile = pageIdentity && options.includeCredentialSurfaceProfile === true;
@@ -628,6 +706,12 @@ function extractHtmlSignals(html, domain, options = {}) {
628
706
  const htmlAnalysis = includeCredentialSurfaceProfile || includeStructuredDataIdentity || includeTechnologyProfile || includeDerivedPageProfiles
629
707
  ? analyzeStaticHtml(html, { baseUrl, includeVisibleText: true })
630
708
  : null;
709
+ const pageIdentityOutput = pageIdentity && htmlAnalysis && options.includePublicationMetadata !== false
710
+ ? {
711
+ ...pageIdentity,
712
+ publicationMetadata: buildPagePublicationMetadata(htmlAnalysis.publicationMetadata, options.sourceTruncated === true),
713
+ }
714
+ : pageIdentity;
631
715
  const pageLanguageSignal = detectPageLanguageSignal(html, pageIdentity?.documentLanguage, htmlAnalysis ?? undefined);
632
716
  return {
633
717
  pageTitle: extractPageTitle(html),
@@ -640,7 +724,7 @@ function extractHtmlSignals(html, domain, options = {}) {
640
724
  cspMetaPolicy: htmlAnalysis
641
725
  ? analyzeCspMetaPolicies(htmlAnalysis.cspMetaPolicies, htmlAnalysis.cspMetaLimitReached)
642
726
  : null,
643
- pageIdentity,
727
+ pageIdentity: pageIdentityOutput,
644
728
  credentialSurfaceProfile: includeCredentialSurfaceProfile && htmlAnalysis ? analyzeCredentialSurfaceProfile({
645
729
  htmlAnalysis,
646
730
  observedAt: options.observedAt,
@@ -675,4 +759,4 @@ function extractHtmlSignals(html, domain, options = {}) {
675
759
  }) : null,
676
760
  };
677
761
  }
678
- export { PAGE_IDENTITY_VERSION, MAX_IDENTITY_TAGS, MAX_FORMS, MAX_FORM_ACTION_ORIGINS, MAX_RESOURCE_TAGS, MAX_RESOURCE_ORIGINS, MAX_EMBEDDED_ORIGINS, MAX_CONTACT_DOMAINS, MAX_TRACKING_IDENTIFIERS, extractHtmlSignals, extractPageIdentity, };
762
+ export { PAGE_IDENTITY_VERSION, PAGE_PUBLICATION_METADATA_VERSION, MAX_IDENTITY_TAGS, MAX_FORMS, MAX_FORM_ACTION_ORIGINS, MAX_RESOURCE_TAGS, MAX_RESOURCE_ORIGINS, MAX_EMBEDDED_ORIGINS, MAX_CONTACT_DOMAINS, MAX_TRACKING_IDENTIFIERS, extractHtmlSignals, extractPageIdentity, };
@@ -2,3 +2,6 @@
2
2
  // browser-local projection. Both producers and consumers import these values
3
3
  // so valid retained evidence is not rejected by a stale projection.
4
4
  export const MAX_HTTP_EVIDENCE_REDIRECTS = 5;
5
+ export const MAX_HTTP_ATTEMPTS = 2;
6
+ export const MAX_HTTP_PROVENANCE_URL = 2_048;
7
+ export const MAX_HTTP_ERROR_LENGTH = 180;