@slicedearth/whoisleuth-cli 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +6 -0
- package/README.md +19 -15
- package/bin/whoisleuth.mjs +8 -1
- package/cli/archive-content-digest.mjs +14 -0
- package/cli/archive-inspect.mjs +20 -53
- package/cli/arguments.mjs +68 -6
- package/cli/artifact-validation/case-response.mjs +47 -41
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +12 -6
- package/cli/artifact-validation/signed-review.mjs +83 -61
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +90 -24
- package/cli/assurance-command-runner.mjs +39 -4
- package/cli/bagit-folder.mjs +57 -0
- package/cli/bagit-review.mjs +23 -0
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +39 -31
- package/cli/command-argument-grammar.mjs +4 -0
- package/cli/command-reference.mjs +145 -75
- package/cli/completion.mjs +298 -79
- package/cli/config-profile.mjs +7 -5
- package/cli/ct-event-intake.mjs +12 -12
- package/cli/discover.mjs +4 -3
- package/cli/discovery-observation-snapshot.mjs +44 -16
- package/cli/discovery-scan-command-runner.mjs +2 -0
- package/cli/discovery-scan.mjs +7 -4
- package/cli/discovery-workflow.mjs +1 -1
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +149 -102
- package/cli/errors.mjs +33 -1
- package/cli/evidence-command-runner.mjs +16 -5
- package/cli/evidence-signing.mjs +2 -2
- package/cli/external-observation-mapping.mjs +7 -8
- package/cli/formatters/terminal-lookup.mjs +18 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/inline-command-families.mjs +1 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +4 -8
- package/cli/investigation-artifacts.mjs +176 -0
- package/cli/investigation-checkpoint.mjs +10 -0
- package/cli/investigation-folder.mjs +114 -0
- package/cli/investigation-manifest.mjs +1 -112
- package/cli/investigation-package-review.mjs +103 -0
- package/cli/investigation-plan.mjs +32 -19
- package/cli/investigation-run.mjs +188 -33
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +17 -6
- package/cli/lookup-diff.mjs +37 -3
- package/cli/lookup-plan.mjs +11 -5
- package/cli/lookup-reconcile.mjs +10 -8
- package/cli/mail-review.mjs +66 -14
- package/cli/manual.mjs +1 -0
- package/cli/network-command-runner.mjs +2 -0
- package/cli/output-file.mjs +31 -5
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +2 -2
- package/cli/review-command-runner.mjs +45 -0
- package/cli/runner.mjs +55 -6
- package/cli/sharing-review.mjs +21 -34
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +74 -83
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-command-runner.mjs +68 -13
- package/docs/cli-reference.md +76 -30
- package/docs/cli.md +289 -25
- package/lib/abort.mjs +4 -1
- package/lib/availability.mjs +161 -112
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +43 -3
- package/lib/browser-library-profile.mjs +42 -51
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-search.mjs +11 -4
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +36 -23
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +7 -2
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +219 -118
- package/lib/domain-control-manifest.mjs +117 -57
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +88 -6
- package/lib/domain-posture.mjs +113 -26
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -592
- package/lib/evidence-report.mjs +3 -1
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +12 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +2335 -940
- package/lib/hostname.mjs +2 -38
- package/lib/html-signals.mjs +82 -85
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-diagnostics.mjs +24 -1
- package/lib/lookup-progress.mjs +30 -13
- package/lib/lookup-response-contract.mjs +1048 -0
- package/lib/lookup-response.mjs +7 -10
- package/lib/lookup-source-progress.mjs +17 -14
- package/lib/lookup.mjs +48 -27
- package/lib/observed-network-context.mjs +43 -27
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +69 -133
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash.mjs +43 -14
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-client.mjs +15 -7
- package/lib/rdap-normalization.mjs +33 -18
- package/lib/rdap-search-workbench.mjs +1 -1
- package/lib/rdap-transport.mjs +47 -20
- package/lib/rdap-types.mjs +2 -1
- package/lib/rdap.mjs +18 -3
- package/lib/registrable-domain.mjs +21 -1
- package/lib/registrar-standing-contract.mjs +1 -0
- package/lib/registry-capabilities.mjs +5 -1
- package/lib/registry-capability-catalogue.mjs +2 -1
- package/lib/registry-dates.mjs +2 -190
- package/lib/registry-insights.mjs +56 -18
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +17 -3
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +10 -7
- package/lib/service-binding-dns.mjs +8 -0
- package/lib/smtp-transport-review.mjs +27 -15
- package/lib/sslbl-certificates.generated.mjs +136 -135
- package/lib/static-html-analysis.mjs +117 -146
- package/lib/structured-data-identity.mjs +1 -0
- package/lib/technology-markup.mjs +100 -0
- package/lib/threat-intelligence-manifests.mjs +11 -139
- package/lib/threat-intelligence-runtime.mjs +24 -52
- package/lib/threat-intelligence-targets.mjs +6 -34
- package/lib/threat-intelligence-types.mjs +39 -0
- package/lib/typosquat-generator.mjs +19 -15
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +111 -71
- package/lib/whois-authority.mjs +14 -2
- package/lib/whois-chain.mjs +9 -2
- package/lib/whois-common-formats.mjs +5 -5
- package/lib/whois-contracts.mjs +2 -1
- package/lib/whois-dialects.mjs +2 -2
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-transport.mjs +23 -7
- package/package.json +2 -1
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-model.mjs +156 -62
- package/packages/cases/case-export-input.mjs +53 -0
- package/packages/cases/case-migration-model.mjs +59 -25
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-core.mjs +3 -2
- package/packages/cases/case-record-operations.mjs +89 -27
- package/packages/cases/case-record-projection.mjs +23 -20
- package/packages/cases/case-report.mjs +63 -52
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +41 -1129
- package/packages/cases/case-response-outcomes.mjs +317 -0
- package/packages/cases/case-response-packet-render.mjs +153 -0
- package/packages/cases/case-response-packet-types.mjs +1 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
- package/packages/cases/case-response-packet.mjs +38 -587
- package/packages/cases/case-response-records.mjs +94 -0
- package/packages/cases/case-response-review-inputs.mjs +371 -0
- package/packages/cases/case-response-values.mjs +85 -0
- package/packages/cases/case-review-package.mjs +45 -0
- package/packages/cases/case-selection.mjs +46 -0
- package/packages/cases/case-storage-model.mjs +25 -0
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
- package/packages/comparison/page-similarity.mjs +2 -1
- package/packages/comparison/relationship-evidence.mjs +85 -8
- package/packages/comparison/relationship-provenance.mjs +93 -0
- package/packages/contracts/analyst-interchange.mjs +216 -0
- package/packages/contracts/analyst-review-state-contract.mjs +11 -0
- package/packages/contracts/analyst-review-state.mjs +5 -4
- package/packages/contracts/case-portability.mjs +279 -116
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +1 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
- package/packages/contracts/domain-control-manifest.mjs +127 -73
- package/packages/contracts/domain-control-monitor.mjs +48 -45
- package/packages/contracts/domain-control-review.mjs +179 -121
- package/packages/contracts/domain-name.mjs +21 -0
- package/packages/contracts/external-observation-interchange.mjs +2 -1
- package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +16 -0
- package/packages/contracts/ip-address.mjs +34 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/offline-comparison.mjs +1 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/risk-calibration.mjs +0 -1
- package/packages/contracts/schema-compatibility.mjs +1 -1
- package/packages/contracts/schema-lifecycle.mjs +83 -153
- package/packages/contracts/selected-file-limits.mjs +6 -0
- package/packages/contracts/vulnerability-identifiers.mjs +5 -0
- package/packages/contracts/web-capture.mjs +10 -0
- package/packages/contracts/workspace-portability.mjs +160 -225
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/domain-control-runtime.mjs +155 -27
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +104 -0
- package/packages/evidence/observation.mjs +16 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/record-identity.mjs +72 -0
- package/packages/evidence/registry-dates.mjs +190 -0
- package/packages/evidence/retained-file.mjs +69 -0
- package/packages/evidence/review-age.mjs +14 -0
- package/packages/interchange/bagit.mjs +342 -0
- package/packages/interchange/bounded-zip-extraction.mjs +278 -0
- package/packages/interchange/crc32.mjs +17 -0
- package/packages/interchange/external-findings-import.mjs +89 -49
- package/packages/interchange/web-capture-import.mjs +382 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -1
- package/packages/investigation/investigation-bagit.mjs +14 -0
- package/packages/investigation/investigation-capsule.mjs +33 -5
- package/packages/investigation/investigation-manifest.mjs +200 -0
- package/packages/investigation/investigation-package-crypto.mjs +98 -0
- package/packages/investigation/investigation-package.mjs +200 -0
- package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
- package/packages/investigation/lookup-asset-graph.mjs +161 -141
- package/packages/investigation/lookup-claim-readiness.mjs +18 -0
- package/packages/investigation/lookup-investigation-brief.mjs +3 -3
- package/packages/investigation/lookup-task-guidance.mjs +58 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +158 -20
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +11 -5
- package/packages/workspace/bulk-session-model.mjs +95 -20
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-template-model.mjs +19 -4
- package/packages/workspace/page-baseline.mjs +18 -15
- package/packages/workspace/relationship-observation-model.mjs +41 -26
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +70 -30
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +85 -21
- package/third-party-notices.txt +29 -2
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// retained.
|
|
7
7
|
import { analyzeBrowserLibraries } from "./browser-library-profile.mjs";
|
|
8
8
|
import { createObservation } from "../packages/evidence/observation.mjs";
|
|
9
|
-
import {
|
|
9
|
+
import { attributeValue, createTechnologyMarkupContext, elementMarker, hasAttributes, navigationMarker, resourceMarker, } from "./technology-markup.mjs";
|
|
10
|
+
import { MAX_STATIC_HTML_CHARS, MAX_STATIC_HTML_TAGS as MAX_TECHNOLOGY_TAGS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
|
|
10
11
|
import { TECHNOLOGY_EVIDENCE_ROLE_ORDER, } from "./technology-evidence-role.mjs";
|
|
11
12
|
import { MAX_EVIDENCE_PER_TECHNOLOGY, MAX_TECHNOLOGY_EVIDENCE_DESCRIPTION_LENGTH, MAX_TECHNOLOGY_FINDINGS, TECHNOLOGY_PROFILE_VERSION, } from "./lookup-child-profile-contract.mjs";
|
|
12
13
|
const TECHNOLOGY_CATEGORIES = Object.freeze([
|
|
@@ -27,7 +28,6 @@ const TECHNOLOGY_EVIDENCE_SOURCES = Object.freeze([
|
|
|
27
28
|
'passive response header',
|
|
28
29
|
]);
|
|
29
30
|
const MAX_TECHNOLOGY_HTML_CHARS = MAX_STATIC_HTML_CHARS;
|
|
30
|
-
const MAX_TECHNOLOGY_TAG_LENGTH = MAX_TAG_LENGTH;
|
|
31
31
|
const MAX_RESOURCE_ORIGINS = 30;
|
|
32
32
|
const MAX_GENERATOR_INPUT = 160;
|
|
33
33
|
const MAX_SERVER_INPUT = 240;
|
|
@@ -55,29 +55,6 @@ function normalizedResourceHosts(value) {
|
|
|
55
55
|
}
|
|
56
56
|
return hosts;
|
|
57
57
|
}
|
|
58
|
-
function applicationMarkup(markup, resourceHosts, effectiveBaseUrl, documentOrigin) {
|
|
59
|
-
let resolutionBase;
|
|
60
|
-
try {
|
|
61
|
-
resolutionBase = new URL(typeof effectiveBaseUrl === 'string' ? effectiveBaseUrl : 'https://document.invalid/');
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
64
|
-
resolutionBase = new URL('https://document.invalid/');
|
|
65
|
-
}
|
|
66
|
-
const comparedOrigin = typeof documentOrigin === 'string' && /^https?:\/\//iu.test(documentOrigin)
|
|
67
|
-
? documentOrigin
|
|
68
|
-
: null;
|
|
69
|
-
return markup.replace(/\s(src|href|poster|data)="([^"]*)"/giu, (attribute, name, value) => {
|
|
70
|
-
try {
|
|
71
|
-
const parsed = new URL(value, resolutionBase);
|
|
72
|
-
const knownExternalHost = resourceHosts.has(parsed.hostname.toLowerCase());
|
|
73
|
-
const offOrigin = comparedOrigin ? parsed.origin !== comparedOrigin : knownExternalHost;
|
|
74
|
-
return knownExternalHost && offOrigin ? ` ${name.toLowerCase()}=""` : attribute;
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
return attribute;
|
|
78
|
-
}
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
58
|
const PASSIVE_TECHNOLOGY_HEADER_NAMES = Object.freeze([
|
|
82
59
|
'cf-ray',
|
|
83
60
|
'x-drupal-cache',
|
|
@@ -138,21 +115,14 @@ function generatorEvidence(pattern, description) {
|
|
|
138
115
|
matches: ({ generator }) => pattern.test(generator),
|
|
139
116
|
};
|
|
140
117
|
}
|
|
141
|
-
function htmlEvidence(
|
|
142
|
-
return {
|
|
143
|
-
source: 'static HTML',
|
|
144
|
-
description,
|
|
145
|
-
confidence,
|
|
146
|
-
matches: ({ html }) => markers.some((marker) => html.includes(marker)),
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
function resourcePathEvidence(markers, description, confidence = 'high') {
|
|
118
|
+
function htmlEvidence(markupRules, description, confidence = 'high') {
|
|
150
119
|
return {
|
|
151
120
|
source: 'static HTML',
|
|
152
121
|
description,
|
|
153
122
|
confidence,
|
|
154
|
-
|
|
155
|
-
|
|
123
|
+
markupRules,
|
|
124
|
+
matches: (context) => markupRules.some((rule) => rule.matches(context)),
|
|
125
|
+
roleFor: (context) => markupRules.some((rule) => rule.matches(context) && !rule.embedded?.(context))
|
|
156
126
|
? undefined
|
|
157
127
|
: 'embedded_dependency',
|
|
158
128
|
};
|
|
@@ -184,7 +154,7 @@ function serverEvidence(pattern, description, confidence = 'high') {
|
|
|
184
154
|
function responseHeaderEvidence(name, pattern, description, confidence = 'high') {
|
|
185
155
|
return {
|
|
186
156
|
source: 'passive response header',
|
|
187
|
-
description
|
|
157
|
+
description: `${name}: ${description}`,
|
|
188
158
|
confidence,
|
|
189
159
|
matches: ({ responseHeaders }) => {
|
|
190
160
|
if (!responseHeaders.has(name))
|
|
@@ -213,14 +183,20 @@ const TECHNOLOGY_SIGNATURES = [
|
|
|
213
183
|
id: 'wordpress', name: 'WordPress', category: 'content management',
|
|
214
184
|
evidence: [
|
|
215
185
|
generatorEvidence(/^wordpress(?:\s|$)/i, 'Generator metadata identifies WordPress.'),
|
|
216
|
-
|
|
186
|
+
htmlEvidence([
|
|
187
|
+
resourceMarker((url) => /\/wp-content\//iu.test(url.pathname), '/wp-content/fixture.css'),
|
|
188
|
+
resourceMarker((url) => /\/wp-includes\//iu.test(url.pathname), '/wp-includes/fixture.js'),
|
|
189
|
+
], 'Static resource paths use WordPress conventions.', 'medium'),
|
|
217
190
|
],
|
|
218
191
|
},
|
|
219
192
|
{
|
|
220
193
|
id: 'drupal', name: 'Drupal', category: 'content management',
|
|
221
194
|
evidence: [
|
|
222
195
|
generatorEvidence(/^drupal(?:\s|$)/i, 'Generator metadata identifies Drupal.'),
|
|
223
|
-
htmlEvidence([
|
|
196
|
+
htmlEvidence([
|
|
197
|
+
elementMarker((element) => hasAttributes(element, 'data-drupal-selector'), '<main data-drupal-selector="fixture"></main>'),
|
|
198
|
+
elementMarker((element) => hasAttributes(element, 'data-drupal-link-system-path'), '<main data-drupal-link-system-path="fixture"></main>'),
|
|
199
|
+
], 'Static markup contains Drupal-specific attributes.'),
|
|
224
200
|
responseHeaderEvidence('x-drupal-cache', null, 'A Drupal-specific cache response header was observed.', 'medium'),
|
|
225
201
|
],
|
|
226
202
|
},
|
|
@@ -232,7 +208,10 @@ const TECHNOLOGY_SIGNATURES = [
|
|
|
232
208
|
id: 'ghost', name: 'Ghost', category: 'content management',
|
|
233
209
|
evidence: [
|
|
234
210
|
generatorEvidence(/^ghost(?:\s|$)/i, 'Generator metadata identifies Ghost.'),
|
|
235
|
-
htmlEvidence([
|
|
211
|
+
htmlEvidence([
|
|
212
|
+
elementMarker((element) => hasAttributes(element, 'data-ghost-search'), '<main data-ghost-search></main>'),
|
|
213
|
+
resourceMarker((url) => /\/ghost\/api\/content\//iu.test(url.pathname), '/ghost/api/content/'),
|
|
214
|
+
], 'Static markup contains Ghost-specific integration markers.'),
|
|
236
215
|
],
|
|
237
216
|
},
|
|
238
217
|
{
|
|
@@ -249,7 +228,10 @@ const TECHNOLOGY_SIGNATURES = [
|
|
|
249
228
|
id: 'shopify', name: 'Shopify', category: 'commerce',
|
|
250
229
|
requiresNonResourceEvidence: true,
|
|
251
230
|
evidence: [
|
|
252
|
-
htmlEvidence([
|
|
231
|
+
htmlEvidence([
|
|
232
|
+
elementMarker((element) => attributeValue(element, 'class').split(/\s/u).includes('shopify-section')
|
|
233
|
+
|| /^shopify-section(?:-|$)/u.test(attributeValue(element, 'id')), '<section class="shopify-section"></section>'),
|
|
234
|
+
], 'Static markup contains Shopify-specific storefront markers.'),
|
|
253
235
|
resourceEvidence(['cdn.shopify.com'], 'A retained resource origin uses the Shopify content network.'),
|
|
254
236
|
responseHeaderEvidence('x-shopify-stage', null, 'A Shopify-specific platform response header was observed.', 'medium'),
|
|
255
237
|
responseHeaderEvidence('x-sorting-hat-podid', null, 'A Shopify-specific routing response header was observed.', 'medium'),
|
|
@@ -270,7 +252,10 @@ const TECHNOLOGY_SIGNATURES = [
|
|
|
270
252
|
{
|
|
271
253
|
id: 'adobe-commerce-magento', name: 'Adobe Commerce / Magento Open Source', category: 'commerce',
|
|
272
254
|
evidence: [
|
|
273
|
-
htmlEvidence([
|
|
255
|
+
htmlEvidence([
|
|
256
|
+
elementMarker((element) => hasAttributes(element, 'data-mage-init'), '<main data-mage-init="{}"></main>'),
|
|
257
|
+
elementMarker((element) => element.name === 'script' && attributeValue(element, 'type') === 'text/x-magento-init', '<script type="text/x-magento-init">{}</script>'),
|
|
258
|
+
], 'Static markup contains Commerce frontend initialisation markers.'),
|
|
274
259
|
],
|
|
275
260
|
},
|
|
276
261
|
{
|
|
@@ -278,26 +263,32 @@ const TECHNOLOGY_SIGNATURES = [
|
|
|
278
263
|
minimumEvidenceMatches: 2,
|
|
279
264
|
allowEmbeddedOnly: true,
|
|
280
265
|
evidence: [
|
|
281
|
-
|
|
266
|
+
htmlEvidence([
|
|
267
|
+
resourceMarker((url) => /(?:^|\/)stencil-utils(?:[./-]|$)/iu.test(url.pathname), '/assets/stencil-utils.js'),
|
|
268
|
+
resourceMarker((url) => /^cdn\d+\.bigcommerce\.com$/iu.test(url.hostname) && /^\/s-[^/]+\//iu.test(url.pathname), 'https://cdn11.bigcommerce.com/s-fixture/theme.css'),
|
|
269
|
+
], 'Static markup contains BigCommerce storefront asset markers.', 'medium'),
|
|
282
270
|
resourcePatternEvidence(/^cdn\d+\.bigcommerce\.com$/i, 'A retained resource origin uses BigCommerce storefront delivery infrastructure.'),
|
|
283
271
|
],
|
|
284
272
|
},
|
|
285
273
|
{
|
|
286
274
|
id: 'woocommerce', name: 'WooCommerce', category: 'commerce',
|
|
287
275
|
evidence: [
|
|
288
|
-
htmlEvidence(['/wp-content/plugins/woocommerce/'], 'Static resource paths identify the WooCommerce plugin.'),
|
|
276
|
+
htmlEvidence([resourceMarker((url) => /\/wp-content\/plugins\/woocommerce\//iu.test(url.pathname), '/wp-content/plugins/woocommerce/fixture.css')], 'Static resource paths identify the WooCommerce plugin.'),
|
|
289
277
|
],
|
|
290
278
|
},
|
|
291
279
|
{
|
|
292
280
|
id: 'opencart', name: 'OpenCart', category: 'commerce',
|
|
293
281
|
evidence: [
|
|
294
|
-
htmlEvidence([
|
|
282
|
+
htmlEvidence([
|
|
283
|
+
navigationMarker((url) => /\/index\.php$/iu.test(url.pathname) && url.searchParams.getAll('route').length === 1 && url.searchParams.get('route')?.toLowerCase() === 'common/home', 'index.php?route=common/home'),
|
|
284
|
+
resourceMarker((url) => /\/image\/catalog\/opencart-logo\.png$/iu.test(url.pathname), '/image/catalog/opencart-logo.png'),
|
|
285
|
+
], 'Static markup contains OpenCart routing or default asset conventions.'),
|
|
295
286
|
],
|
|
296
287
|
},
|
|
297
288
|
{
|
|
298
289
|
id: 'prestashop', name: 'PrestaShop', category: 'commerce',
|
|
299
290
|
evidence: [
|
|
300
|
-
htmlEvidence(['/modules/
|
|
291
|
+
htmlEvidence([resourceMarker((url) => /\/modules\/ps_[^/]+\//iu.test(url.pathname), '/modules/ps_fixture/fixture.css')], 'Static resource paths use PrestaShop module conventions.'),
|
|
301
292
|
],
|
|
302
293
|
},
|
|
303
294
|
{
|
|
@@ -305,24 +296,29 @@ const TECHNOLOGY_SIGNATURES = [
|
|
|
305
296
|
requiresNonResourceEvidence: true,
|
|
306
297
|
evidence: [
|
|
307
298
|
generatorEvidence(/^wix(?:\.com)?(?:\s|$)/i, 'Generator metadata identifies Wix.'),
|
|
308
|
-
htmlEvidence(['data-mesh-id='], 'Static markup contains a Wix-specific document attribute.', 'medium'),
|
|
299
|
+
htmlEvidence([elementMarker((element) => hasAttributes(element, 'data-mesh-id'), '<main data-mesh-id="fixture"></main>')], 'Static markup contains a Wix-specific document attribute.', 'medium'),
|
|
309
300
|
resourceEvidence(['static.parastorage.com', 'wixstatic.com'], 'A retained resource origin uses Wix delivery infrastructure.'),
|
|
310
301
|
],
|
|
311
302
|
},
|
|
312
303
|
{
|
|
313
304
|
id: 'squarespace', name: 'Squarespace', category: 'site builder',
|
|
314
305
|
requiresNonResourceEvidence: true,
|
|
306
|
+
allowEmbeddedOnly: true,
|
|
315
307
|
evidence: [
|
|
316
308
|
generatorEvidence(/^squarespace(?:\s|$)/i, 'Generator metadata identifies Squarespace.'),
|
|
317
|
-
htmlEvidence([
|
|
318
|
-
|
|
309
|
+
htmlEvidence([
|
|
310
|
+
resourceMarker((url) => url.hostname === 'assets.squarespace.com' && /^\/universal\/scripts-compressed\/[a-z0-9][a-z0-9.-]*\.js$/iu.test(url.pathname), 'https://assets.squarespace.com/universal/scripts-compressed/fixture.js'),
|
|
311
|
+
], 'Static resource paths use Squarespace platform bundle conventions.', 'medium'),
|
|
319
312
|
],
|
|
320
313
|
},
|
|
321
314
|
{
|
|
322
315
|
id: 'webflow', name: 'Webflow', category: 'site builder',
|
|
323
316
|
evidence: [
|
|
324
317
|
generatorEvidence(/^webflow(?:\s|$)/i, 'Generator metadata identifies Webflow.'),
|
|
325
|
-
htmlEvidence([
|
|
318
|
+
htmlEvidence([
|
|
319
|
+
elementMarker((element) => hasAttributes(element, 'data-wf-page'), '<main data-wf-page="fixture"></main>'),
|
|
320
|
+
elementMarker((element) => hasAttributes(element, 'data-wf-site'), '<main data-wf-site="fixture"></main>'),
|
|
321
|
+
], 'Static markup contains Webflow-specific document attributes.'),
|
|
326
322
|
],
|
|
327
323
|
},
|
|
328
324
|
{
|
|
@@ -330,7 +326,7 @@ const TECHNOLOGY_SIGNATURES = [
|
|
|
330
326
|
requiresNonResourceEvidence: true,
|
|
331
327
|
evidence: [
|
|
332
328
|
generatorEvidence(/^framer(?:\s|$)/i, 'Generator metadata identifies Framer.'),
|
|
333
|
-
htmlEvidence(['data-framer-name='], 'Static markup contains Framer-specific component attributes.'),
|
|
329
|
+
htmlEvidence([elementMarker((element) => hasAttributes(element, 'data-framer-name'), '<main data-framer-name="fixture"></main>')], 'Static markup contains Framer-specific component attributes.'),
|
|
334
330
|
resourceEvidence(['framerusercontent.com'], 'A retained resource origin uses Framer delivery infrastructure.'),
|
|
335
331
|
],
|
|
336
332
|
},
|
|
@@ -338,42 +334,57 @@ const TECHNOLOGY_SIGNATURES = [
|
|
|
338
334
|
id: 'weebly', name: 'Weebly', category: 'site builder',
|
|
339
335
|
requiresNonResourceEvidence: true,
|
|
340
336
|
evidence: [
|
|
341
|
-
htmlEvidence(['
|
|
337
|
+
htmlEvidence([elementMarker((element) => element.name === 'link' && (attributeValue(element, 'id') === 'wsite-base-style' || attributeValue(element, 'title') === 'wsite-theme-css'), '<link id="wsite-base-style" href="/fixture.css">')], 'Static markup contains Weebly-specific theme attributes.'),
|
|
342
338
|
resourceEvidence(['editmysite.com'], 'A retained resource origin uses Weebly delivery infrastructure.'),
|
|
343
339
|
],
|
|
344
340
|
},
|
|
345
341
|
{
|
|
346
342
|
id: 'angular', name: 'Angular', category: 'web framework',
|
|
347
|
-
evidence: [htmlEvidence([' ng-version='], 'Static markup contains Angular version metadata.')],
|
|
343
|
+
evidence: [htmlEvidence([elementMarker((element) => hasAttributes(element, 'ng-version'), '<main ng-version="fixture"></main>')], 'Static markup contains Angular version metadata.')],
|
|
348
344
|
},
|
|
349
345
|
{
|
|
350
346
|
id: 'aspnet-web-forms', name: 'ASP.NET Web Forms', category: 'web framework',
|
|
351
|
-
evidence: [htmlEvidence([' name
|
|
347
|
+
evidence: [htmlEvidence([elementMarker((element) => element.name === 'input' && (attributeValue(element, 'name') === '__viewstate' || attributeValue(element, 'id') === '__viewstate'), '<input name="__VIEWSTATE">')], 'Static form markup contains the ASP.NET Web Forms view-state field.')],
|
|
352
348
|
},
|
|
353
349
|
{
|
|
354
350
|
id: 'nextjs', name: 'Next.js', category: 'web framework',
|
|
355
|
-
evidence: [htmlEvidence([
|
|
351
|
+
evidence: [htmlEvidence([
|
|
352
|
+
elementMarker((element) => element.name === 'script' && attributeValue(element, 'id') === '__next_data__', '<script id="__NEXT_DATA__"></script>'),
|
|
353
|
+
resourceMarker((url) => /\/_next\/static\//iu.test(url.pathname), '/_next/static/fixture.js'),
|
|
354
|
+
], 'Static markup contains Next.js bootstrap or asset markers.')],
|
|
356
355
|
},
|
|
357
356
|
{
|
|
358
357
|
id: 'nuxt', name: 'Nuxt', category: 'web framework',
|
|
359
|
-
evidence: [htmlEvidence([
|
|
358
|
+
evidence: [htmlEvidence([
|
|
359
|
+
elementMarker((element) => attributeValue(element, 'id') === '__nuxt', '<main id="__nuxt"></main>'),
|
|
360
|
+
resourceMarker((url) => /\/_nuxt\//iu.test(url.pathname), '/_nuxt/fixture.js'),
|
|
361
|
+
], 'Static markup contains Nuxt bootstrap or asset markers.')],
|
|
360
362
|
},
|
|
361
363
|
{
|
|
362
364
|
id: 'gatsby', name: 'Gatsby', category: 'web framework',
|
|
363
|
-
evidence: [htmlEvidence([
|
|
365
|
+
evidence: [htmlEvidence([
|
|
366
|
+
elementMarker((element) => attributeValue(element, 'id') === '___gatsby', '<main id="___gatsby"></main>'),
|
|
367
|
+
resourceMarker((url) => /\/page-data\/app-data\.json$/iu.test(url.pathname), '/page-data/app-data.json'),
|
|
368
|
+
], 'Static markup contains Gatsby bootstrap or page-data markers.')],
|
|
364
369
|
},
|
|
365
370
|
{
|
|
366
371
|
id: 'sveltekit', name: 'SvelteKit', category: 'web framework',
|
|
367
372
|
evidence: [
|
|
368
|
-
htmlEvidence([
|
|
369
|
-
|
|
373
|
+
htmlEvidence([
|
|
374
|
+
elementMarker((element) => hasAttributes(element, 'data-sveltekit-preload-data'), '<a data-sveltekit-preload-data="hover"></a>'),
|
|
375
|
+
elementMarker((element) => hasAttributes(element, 'data-sveltekit-reload'), '<a data-sveltekit-reload></a>'),
|
|
376
|
+
], 'Static markup contains SvelteKit-specific navigation attributes.'),
|
|
377
|
+
htmlEvidence([resourceMarker((url) => /\/_app\/immutable\//iu.test(url.pathname), '/_app/immutable/fixture.css')], 'Static asset paths use SvelteKit build conventions.', 'medium'),
|
|
370
378
|
],
|
|
371
379
|
},
|
|
372
380
|
{
|
|
373
381
|
id: 'astro', name: 'Astro', category: 'web framework',
|
|
374
382
|
evidence: [
|
|
375
|
-
htmlEvidence([
|
|
376
|
-
|
|
383
|
+
htmlEvidence([
|
|
384
|
+
elementMarker((element) => element.name === 'astro-island', '<astro-island></astro-island>'),
|
|
385
|
+
elementMarker((element) => element.name === 'astro-slot', '<astro-slot></astro-slot>'),
|
|
386
|
+
], 'Static markup contains Astro component-island elements.'),
|
|
387
|
+
htmlEvidence([resourceMarker((url) => /\/_astro\//iu.test(url.pathname), '/_astro/fixture.css')], 'Static asset paths use Astro build conventions.', 'medium'),
|
|
377
388
|
],
|
|
378
389
|
},
|
|
379
390
|
{
|
|
@@ -461,28 +472,57 @@ const TECHNOLOGY_SIGNATURE_CATALOGUE = Object.freeze(TECHNOLOGY_SIGNATURES.map((
|
|
|
461
472
|
confidence: evidence.confidence,
|
|
462
473
|
}))),
|
|
463
474
|
})));
|
|
464
|
-
function
|
|
475
|
+
function technologyHtmlAnalysis(input) {
|
|
476
|
+
return input.htmlAnalysis ?? analyzeStaticHtml(input.html, {
|
|
477
|
+
...(typeof input.documentOrigin === 'string' ? { baseUrl: input.documentOrigin } : {}),
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
// These are fixed semantic reconstructions, not copies of observed markup.
|
|
481
|
+
// Keeping them beside the predicates removes a second keyword catalogue from
|
|
482
|
+
// the contribution tools. Independent structural fixtures test the predicates.
|
|
483
|
+
function minimiseTechnologyMarkup(input) {
|
|
484
|
+
const analysis = technologyHtmlAnalysis(input);
|
|
485
|
+
if (analysis.inputLimitReached || analysis.tagLimitReached) {
|
|
486
|
+
throw new TypeError('Technology review markup exceeded the complete structural-analysis boundary.');
|
|
487
|
+
}
|
|
488
|
+
const context = createTechnologyMarkupContext(analysis, input.effectiveBaseUrl, input.documentOrigin);
|
|
489
|
+
const fragments = TECHNOLOGY_SIGNATURES.flatMap((signature) => signature.evidence.flatMap((evidence) => ((evidence.markupRules ?? []).flatMap((rule) => rule.matches(context)
|
|
490
|
+
? [rule.embedded?.(context) ? rule.embeddedReviewMarkup : rule.reviewMarkup]
|
|
491
|
+
: []))));
|
|
492
|
+
return [...new Set(fragments)].join('');
|
|
493
|
+
}
|
|
494
|
+
function reconstructTechnologyHtmlEvidence(id, description, role) {
|
|
495
|
+
const rules = TECHNOLOGY_SIGNATURES.find((signature) => signature.id === id)?.evidence
|
|
496
|
+
.find((evidence) => evidence.source === 'static HTML' && evidence.description === description)?.markupRules;
|
|
497
|
+
const markup = role === 'embedded_dependency'
|
|
498
|
+
? rules?.find((rule) => rule.embeddedReviewMarkup)?.embeddedReviewMarkup
|
|
499
|
+
: rules?.[0]?.reviewMarkup;
|
|
500
|
+
if (!markup)
|
|
501
|
+
throw new TypeError('The retained static clue has no current structural reconstruction.');
|
|
502
|
+
return markup;
|
|
503
|
+
}
|
|
504
|
+
async function analyzeWebsiteTechnology(input = {}) {
|
|
505
|
+
input.signal?.throwIfAborted();
|
|
465
506
|
// Existing direct callers pass minimised, already-derived page evidence rather
|
|
466
507
|
// than the page body. The real collector declares false for header-only
|
|
467
508
|
// responses, while an explicit malformed declaration also fails closed.
|
|
468
509
|
const htmlAvailable = input.htmlAvailable === undefined
|
|
469
510
|
? true
|
|
470
511
|
: input.htmlAvailable === true;
|
|
471
|
-
const htmlAnalysis = input
|
|
472
|
-
const browserLibraryProfile = htmlAvailable ? analyzeBrowserLibraries({
|
|
512
|
+
const htmlAnalysis = htmlAvailable ? technologyHtmlAnalysis(input) : analyzeStaticHtml('');
|
|
513
|
+
const browserLibraryProfile = htmlAvailable ? await analyzeBrowserLibraries({
|
|
473
514
|
htmlAnalysis,
|
|
474
515
|
observedAt: input.observedAt,
|
|
475
516
|
sourceTruncated: input.sourceTruncated,
|
|
517
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
476
518
|
}) : null;
|
|
477
519
|
const context = {
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
generator: boundedLowercase(input.generator, MAX_GENERATOR_INPUT),
|
|
520
|
+
...createTechnologyMarkupContext(htmlAnalysis, input.effectiveBaseUrl, input.documentOrigin),
|
|
521
|
+
generator: htmlAvailable ? boundedLowercase(input.generator, MAX_GENERATOR_INPUT) : '',
|
|
481
522
|
httpServer: boundedLowercase(input.httpServer, MAX_SERVER_INPUT),
|
|
482
|
-
resourceHosts: normalizedResourceHosts(input.resourceOrigins),
|
|
523
|
+
resourceHosts: htmlAvailable ? normalizedResourceHosts(input.resourceOrigins) : new Set(),
|
|
483
524
|
responseHeaders: normalizedResponseHeaders(input.responseHeaders),
|
|
484
525
|
};
|
|
485
|
-
context.applicationHtml = applicationMarkup(context.html, context.resourceHosts, input.effectiveBaseUrl, input.documentOrigin);
|
|
486
526
|
const findings = [];
|
|
487
527
|
for (const signature of TECHNOLOGY_SIGNATURES) {
|
|
488
528
|
const matched = signature.evidence
|
|
@@ -534,7 +574,7 @@ function analyzeWebsiteTechnology(input = {}) {
|
|
|
534
574
|
if (htmlAnalysis.inputLimitReached)
|
|
535
575
|
limitations.push(`Only the first ${MAX_TECHNOLOGY_HTML_CHARS} HTML characters were evaluated.`);
|
|
536
576
|
if (htmlAnalysis.tagLimitReached)
|
|
537
|
-
limitations.push(
|
|
577
|
+
limitations.push('Static HTML construction or attribute projection reached a bound; later technology indicators may be missing.');
|
|
538
578
|
if (findingLimitReached)
|
|
539
579
|
limitations.push(`Only the first ${MAX_TECHNOLOGY_FINDINGS} technology findings were retained.`);
|
|
540
580
|
return {
|
|
@@ -561,4 +601,4 @@ function analyzeWebsiteTechnology(input = {}) {
|
|
|
561
601
|
browserLibraryProfile,
|
|
562
602
|
};
|
|
563
603
|
}
|
|
564
|
-
export { MAX_EVIDENCE_PER_TECHNOLOGY, MAX_TECHNOLOGY_EVIDENCE_DESCRIPTION_LENGTH, MAX_TECHNOLOGY_FINDINGS, MAX_TECHNOLOGY_HTML_CHARS, MAX_TECHNOLOGY_TAGS, PASSIVE_TECHNOLOGY_HEADER_NAMES, TECHNOLOGY_CATEGORIES, TECHNOLOGY_EVIDENCE_SOURCES, TECHNOLOGY_PROFILE_VERSION, TECHNOLOGY_SIGNATURE_CATALOGUE, analyzeWebsiteTechnology, captureTechnologyResponseHeaders, };
|
|
604
|
+
export { MAX_EVIDENCE_PER_TECHNOLOGY, MAX_TECHNOLOGY_EVIDENCE_DESCRIPTION_LENGTH, MAX_TECHNOLOGY_FINDINGS, MAX_TECHNOLOGY_HTML_CHARS, MAX_TECHNOLOGY_TAGS, PASSIVE_TECHNOLOGY_HEADER_NAMES, TECHNOLOGY_CATEGORIES, TECHNOLOGY_EVIDENCE_SOURCES, TECHNOLOGY_PROFILE_VERSION, TECHNOLOGY_SIGNATURE_CATALOGUE, analyzeWebsiteTechnology, captureTechnologyResponseHeaders, minimiseTechnologyMarkup, reconstructTechnologyHtmlEvidence, };
|
package/lib/whois-authority.mjs
CHANGED
|
@@ -29,7 +29,10 @@ const LINE_NOT_FOUND_PATTERNS = Object.freeze([
|
|
|
29
29
|
const NZ_NOT_FOUND_RE = /^[ \t]*query_status[ \t]*:[ \t]*220(?:\s|$)/im;
|
|
30
30
|
const NZ_POSITIVE_RE = /^[ \t]*query_status[ \t]*:[ \t]*(?:200|210)(?:\s|$)/im;
|
|
31
31
|
const NZ_TEMPORARY_FAILURE_RE = /^[ \t]*query_status[ \t]*:[ \t]*4\d{2}(?:\s|$)/im;
|
|
32
|
-
|
|
32
|
+
// A refusal describes this response; a footer describing permitted query
|
|
33
|
+
// frequency does not. Keep explicit refusals ahead of echoed domain fields.
|
|
34
|
+
const RATE_LIMIT_LINE_RE = /^[ \t]*(?:[%#*;>-]+[ \t]*)?(?:(?:error|status)[ \t:.-]+)?(?:whois[ \t]+limit[ \t]+exceeded|query[ \t]+(?:rate[ \t-]*)?limit[ \t]+exceeded|(?:request|query)[ \t]+limit[ \t]+(?:exceeded|reached)|rate[ \t-]*limit(?:ed|[ \t]+(?:exceeded|reached))|too[ \t]+many[ \t]+(?:requests|queries)|quota[ \t]+exceeded|number[ \t]+of[^\r\n]{0,120}[ \t]+exceeded|(?:requests?|queries?)[ \t]+(?:are[ \t]+)?throttled|throttled|(?:service[ \t]+)?temporarily[ \t]+unavailable)\b[^\r\n]{0,240}$/im;
|
|
35
|
+
const RETRY_LINE_RE = /^[ \t]*(?:[%#*;>-]+[ \t]*)?(?:(?:error|status)[ \t:.-]+)?(?:rate[ \t-]*limit|(?:please[ \t]+)?try[ \t]+again[ \t]+later|please[ \t]+wait(?:[ \t]+\d{1,6}[ \t]+(?:seconds?|minutes?|hours?))?(?:[ \t]+(?:and[ \t]+)?(?:retry|try[ \t]+again)(?:[ \t]+later)?)?)[.!]?[ \t]*$/im;
|
|
33
36
|
const POSITIVE_REGISTRATION_RE = /^[ \t*]*(?:Domain(?:[ \t]+Name)?|domainname|Registrar|Registrar WHOIS Server|Creation Date|Created(?: On)?|Registry Expiry Date|Registered(?: On)?|Name Server|nserver|Sponsoring Registrar)[ \t.]*:[ \t]*\S/im;
|
|
34
37
|
const POSITIVE_BRACKET_RE = /\[(?:Domain Name|Registrant|Name Server)\][ \t]*\S/i;
|
|
35
38
|
function hasSectionedRegistrationEvidence(text) {
|
|
@@ -53,7 +56,7 @@ function classifyHopEvidence(hop, index) {
|
|
|
53
56
|
const text = hop.response || '';
|
|
54
57
|
if (!text.trim())
|
|
55
58
|
return 'inconclusive';
|
|
56
|
-
if (RATE_LIMIT_LINE_RE.test(text))
|
|
59
|
+
if (RATE_LIMIT_LINE_RE.test(text) || RETRY_LINE_RE.test(text))
|
|
57
60
|
return 'rate_limited';
|
|
58
61
|
if (NZ_TEMPORARY_FAILURE_RE.test(text))
|
|
59
62
|
return 'rate_limited';
|
|
@@ -131,3 +134,12 @@ export function analyzeWhoisChainAuthority(chain) {
|
|
|
131
134
|
: 'partial',
|
|
132
135
|
};
|
|
133
136
|
}
|
|
137
|
+
/** Collection status shared by final and incremental Lookup presentation. */
|
|
138
|
+
export function whoisCollectionStatus(chain) {
|
|
139
|
+
const source = normalizeWhoisChain(chain);
|
|
140
|
+
if (!source.length || source[0]?.error)
|
|
141
|
+
return 'error';
|
|
142
|
+
if (source.length === 1)
|
|
143
|
+
return 'unsupported';
|
|
144
|
+
return analyzeWhoisChainAuthority(source).chainStatus;
|
|
145
|
+
}
|
package/lib/whois-chain.mjs
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
// bounded referrals.
|
|
4
4
|
import { domainToUnicode } from 'node:url';
|
|
5
5
|
import { cached } from "./lookup-cache.mjs";
|
|
6
|
+
import { MAX_WHOIS_QUERY_HOPS } from "./whois-contracts.mjs";
|
|
6
7
|
import { registryCapabilityFor, registryServiceAdmissionFor, } from "./registry-capabilities.mjs";
|
|
7
8
|
import { whoisQuery } from "./whois-transport.mjs";
|
|
8
9
|
const IANA_WHOIS = 'whois.iana.org';
|
|
9
10
|
const WHOIS_HOP_DEADLINE_MS = 12_000;
|
|
10
11
|
const WHOIS_CHAIN_DEADLINE_MS = 25_000;
|
|
11
|
-
const MAX_WHOIS_QUERY_HOPS = 6;
|
|
12
12
|
function incompleteReferralHop(server, reason) {
|
|
13
13
|
return {
|
|
14
14
|
server,
|
|
@@ -58,6 +58,7 @@ function extractReferral(whoisText) {
|
|
|
58
58
|
return null;
|
|
59
59
|
}
|
|
60
60
|
export async function buildWhoisChainUncached(query, options = {}) {
|
|
61
|
+
options.signal?.throwIfAborted();
|
|
61
62
|
const admission = registryServiceAdmissionFor(query, 'whois');
|
|
62
63
|
if (admission && !admission.allowed) {
|
|
63
64
|
return [{
|
|
@@ -77,6 +78,7 @@ export async function buildWhoisChainUncached(query, options = {}) {
|
|
|
77
78
|
let currentServer = IANA_WHOIS;
|
|
78
79
|
const startedAt = now();
|
|
79
80
|
for (let hop = 0; hop < MAX_WHOIS_QUERY_HOPS; hop += 1) {
|
|
81
|
+
options.signal?.throwIfAborted();
|
|
80
82
|
if (visited.has(currentServer.toLowerCase())) {
|
|
81
83
|
chain.push(incompleteReferralHop(currentServer, 'referral_loop'));
|
|
82
84
|
break;
|
|
@@ -101,12 +103,15 @@ export async function buildWhoisChainUncached(query, options = {}) {
|
|
|
101
103
|
responseText = await queryWhois(currentServer, transport.query, {
|
|
102
104
|
timeoutMs: Math.min(10_000, remainingMs),
|
|
103
105
|
totalDeadlineMs: Math.min(WHOIS_HOP_DEADLINE_MS, remainingMs),
|
|
106
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
104
107
|
onAddressSelected: (selected) => {
|
|
105
108
|
address = selected;
|
|
106
109
|
},
|
|
107
110
|
});
|
|
111
|
+
options.signal?.throwIfAborted();
|
|
108
112
|
}
|
|
109
113
|
catch (cause) {
|
|
114
|
+
options.signal?.throwIfAborted();
|
|
110
115
|
chain.push({
|
|
111
116
|
server: currentServer,
|
|
112
117
|
queriedAt,
|
|
@@ -140,6 +145,8 @@ export async function buildWhoisChainUncached(query, options = {}) {
|
|
|
140
145
|
}
|
|
141
146
|
return chain;
|
|
142
147
|
}
|
|
143
|
-
export async function buildWhoisChain(query) {
|
|
148
|
+
export async function buildWhoisChain(query, options = {}) {
|
|
149
|
+
if (options.signal)
|
|
150
|
+
return buildWhoisChainUncached(query, options);
|
|
144
151
|
return cached(`whois:${query.toLowerCase()}`, () => buildWhoisChainUncached(query));
|
|
145
152
|
}
|
|
@@ -176,7 +176,7 @@ function applyWhoisCommonFormats(text, isRootHop, dialects, context) {
|
|
|
176
176
|
}) === 'capped')
|
|
177
177
|
break;
|
|
178
178
|
}
|
|
179
|
-
for (const m of text.matchAll(/\[Name Server\][ \t]*([
|
|
179
|
+
for (const m of text.matchAll(/\[Name Server\][ \t]*([^\s]+)/gi)) {
|
|
180
180
|
if (addBoundedWhoisSetValue(nameservers, m[1], {
|
|
181
181
|
maxEntries: MAX_WHOIS_NAMESERVERS, maxLength: 253,
|
|
182
182
|
field: 'nameservers', truncatedFields,
|
|
@@ -188,16 +188,16 @@ function applyWhoisCommonFormats(text, isRootHop, dialects, context) {
|
|
|
188
188
|
// on every hop; "nserver:"/"Host Name:"/"DNS:" are real per-domain
|
|
189
189
|
// labels on some registries (e.g. .ru, .kr, .mx) but only once we're
|
|
190
190
|
// past the root hop, for the same reason as the field patterns above.
|
|
191
|
-
const nsLinePatterns = [/^[ \t*]*Name Server[ \t.]*:[ \t]*([
|
|
191
|
+
const nsLinePatterns = [/^[ \t*]*Name Server[ \t.]*:[ \t]*([^\s]+)/gim];
|
|
192
192
|
if (!isRootHop) {
|
|
193
|
-
nsLinePatterns.push(/^[ \t*]*nserver[ \t.]*:[ \t]*([
|
|
193
|
+
nsLinePatterns.push(/^[ \t*]*nserver[ \t.]*:[ \t]*([^\s]+)/gim, /^[ \t*]*Nameserver[ \t.]*:[ \t]*([^\s]+)/gim, /^[ \t*]*Host Name[ \t.]*:[ \t]*([^\s]+)/gim, /^[ \t]*ns_name_\d{2}[ \t]*:[ \t]*([^\s]+)/gim);
|
|
194
194
|
// Punktum dk uses `DNS: example.dk` for the queried domain, then
|
|
195
195
|
// `Hostname:` inside its nameserver section. Other supported
|
|
196
196
|
// registries use `DNS:` for an actual nameserver, so switch aliases
|
|
197
197
|
// only when the full .dk marker set is present.
|
|
198
198
|
nsLinePatterns.push(isPunktum
|
|
199
|
-
? /^[ \t*]*Hostname[ \t.]*:[ \t]*([
|
|
200
|
-
: /^[ \t*]*DNS[ \t.]*:[ \t]*([
|
|
199
|
+
? /^[ \t*]*Hostname[ \t.]*:[ \t]*([^\s]+)/gim
|
|
200
|
+
: /^[ \t*]*DNS[ \t.]*:[ \t]*([^\s]+)/gim);
|
|
201
201
|
}
|
|
202
202
|
for (const re of nsLinePatterns) {
|
|
203
203
|
for (const m of text.matchAll(re)) {
|
package/lib/whois-contracts.mjs
CHANGED
package/lib/whois-dialects.mjs
CHANGED
|
@@ -263,8 +263,8 @@ function applyWhoisDialects(text, isRootHop, context) {
|
|
|
263
263
|
assignBoundedWhoisMatch(text, fields, 'updatedDate', /^[ \t]*Last modified[ \t.]*:[ \t]*(.+)$/im, truncatedFields);
|
|
264
264
|
assignBoundedWhoisMatch(text, fields, 'registrar', /^[ \t]*Current Registar[ \t]*:[ \t]*(.+)$/im, truncatedFields);
|
|
265
265
|
for (const pattern of [
|
|
266
|
-
/^[ \t]*Primary server[ \t.]*:[ \t]*([
|
|
267
|
-
/^[ \t]*Secondary server[ \t.]*:[ \t]*([
|
|
266
|
+
/^[ \t]*Primary server[ \t.]*:[ \t]*([^\s]+)/gim,
|
|
267
|
+
/^[ \t]*Secondary server[ \t.]*:[ \t]*([^\s]+)/gim,
|
|
268
268
|
]) {
|
|
269
269
|
for (const match of text.matchAll(pattern)) {
|
|
270
270
|
const result = addBoundedWhoisSetValue(nameservers, match[1], {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Bounded primitives and generic field patterns shared by the WHOIS parser.
|
|
2
2
|
import { boundedWhoisValue, whoisFieldLimit, } from "./whois-values.mjs";
|
|
3
|
+
import { MAX_WHOIS_STATUSES } from "./whois-contracts.mjs";
|
|
3
4
|
const MAX_WHOIS_NAMESERVERS = 200;
|
|
4
|
-
const MAX_WHOIS_STATUSES = 100;
|
|
5
5
|
const WHOIS_DOMAIN_RE = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
// WHOIS response parsing (merges the referral chain into readable fields)
|
|
@@ -21,6 +21,13 @@ function assignBoundedWhoisMatch(text, fields, key, pattern, truncatedFields) {
|
|
|
21
21
|
}
|
|
22
22
|
function addBoundedWhoisSetValue(set, rawValue, { maxEntries, maxLength, field, truncatedFields, }) {
|
|
23
23
|
const bounded = boundedWhoisValue(rawValue, maxLength);
|
|
24
|
+
if (bounded.truncated)
|
|
25
|
+
truncatedFields.add(field);
|
|
26
|
+
if (field === 'nameservers' && (bounded.truncated || !bounded.value
|
|
27
|
+
|| !WHOIS_DOMAIN_RE.test(bounded.value.replace(/\.$/u, '')))) {
|
|
28
|
+
truncatedFields.add(field);
|
|
29
|
+
return 'ignored';
|
|
30
|
+
}
|
|
24
31
|
if (!bounded.value || set.has(bounded.value))
|
|
25
32
|
return 'ignored';
|
|
26
33
|
if (set.size >= maxEntries) {
|
package/lib/whois-transport.mjs
CHANGED
|
@@ -16,38 +16,45 @@ function errorMessage(value, fallback) {
|
|
|
16
16
|
// A referral hostname is upstream-controlled, so resolve and validate its
|
|
17
17
|
// addresses before connecting. Connect to the selected address directly to
|
|
18
18
|
// prevent a second DNS lookup from bypassing the public-address decision.
|
|
19
|
-
function queryWhoisAddress(address, server, query, { port = 43, timeoutMs = 10000, totalDeadlineMs = WHOIS_HOP_DEADLINE_MS, createConnection = net.createConnection, } = {}) {
|
|
19
|
+
function queryWhoisAddress(address, server, query, { port = 43, timeoutMs = 10000, totalDeadlineMs = WHOIS_HOP_DEADLINE_MS, createConnection = net.createConnection, signal, } = {}) {
|
|
20
20
|
return new Promise((resolve, reject) => {
|
|
21
|
+
signal?.throwIfAborted();
|
|
21
22
|
const socket = createConnection({ host: address, port }, () => {
|
|
22
|
-
|
|
23
|
+
if (!signal?.aborted)
|
|
24
|
+
socket.write(query + '\r\n');
|
|
23
25
|
});
|
|
24
26
|
const chunks = [];
|
|
25
27
|
let totalBytes = 0;
|
|
26
28
|
let settled = false;
|
|
27
29
|
const deadline = setTimeout(() => {
|
|
28
|
-
|
|
30
|
+
settle(reject, new Error(`WHOIS request to ${server} exceeded the total time limit`));
|
|
29
31
|
socket.destroy();
|
|
30
|
-
reject(new Error(`WHOIS request to ${server} exceeded the total time limit`));
|
|
31
32
|
}, totalDeadlineMs);
|
|
32
33
|
function settle(fn, value) {
|
|
33
34
|
if (settled)
|
|
34
35
|
return;
|
|
35
36
|
settled = true;
|
|
36
37
|
clearTimeout(deadline);
|
|
38
|
+
signal?.removeEventListener('abort', abort);
|
|
37
39
|
fn(value);
|
|
38
40
|
}
|
|
41
|
+
const abort = () => { settle(reject, signal.reason); socket.destroy(); };
|
|
39
42
|
socket.setTimeout(Math.min(timeoutMs, totalDeadlineMs));
|
|
40
43
|
socket.on('data', (chunk) => {
|
|
44
|
+
if (settled)
|
|
45
|
+
return;
|
|
41
46
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
42
47
|
totalBytes += buffer.length;
|
|
43
48
|
if (totalBytes > MAX_WHOIS_BYTES) {
|
|
44
|
-
socket.destroy();
|
|
45
49
|
settle(reject, new Error(`WHOIS response from ${server} exceeded ${MAX_WHOIS_BYTES} bytes`));
|
|
50
|
+
socket.destroy();
|
|
46
51
|
return;
|
|
47
52
|
}
|
|
48
53
|
chunks.push(buffer);
|
|
49
54
|
});
|
|
50
55
|
const settleResponse = () => {
|
|
56
|
+
if (settled)
|
|
57
|
+
return;
|
|
51
58
|
try {
|
|
52
59
|
const responseText = new TextDecoder('utf-8', { fatal: true })
|
|
53
60
|
.decode(Buffer.concat(chunks, totalBytes));
|
|
@@ -60,13 +67,17 @@ function queryWhoisAddress(address, server, query, { port = 43, timeoutMs = 1000
|
|
|
60
67
|
socket.on('end', settleResponse);
|
|
61
68
|
socket.on('close', settleResponse);
|
|
62
69
|
socket.on('timeout', () => {
|
|
63
|
-
socket.destroy();
|
|
64
70
|
settle(reject, new Error(`WHOIS request to ${server} timed out`));
|
|
71
|
+
socket.destroy();
|
|
65
72
|
});
|
|
66
73
|
socket.on('error', (error) => settle(reject, error));
|
|
74
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
75
|
+
if (signal?.aborted)
|
|
76
|
+
abort();
|
|
67
77
|
});
|
|
68
78
|
}
|
|
69
|
-
async function whoisQuery(server, query, { port = 43, timeoutMs = 10000, totalDeadlineMs = WHOIS_HOP_DEADLINE_MS, resolveAddresses = resolvePublicAddresses, queryAddress = queryWhoisAddress, now = Date.now, onAddressSelected = (_address) => { }, } = {}) {
|
|
79
|
+
async function whoisQuery(server, query, { port = 43, timeoutMs = 10000, totalDeadlineMs = WHOIS_HOP_DEADLINE_MS, resolveAddresses = resolvePublicAddresses, queryAddress = queryWhoisAddress, now = Date.now, onAddressSelected = (_address) => { }, signal, } = {}) {
|
|
80
|
+
signal?.throwIfAborted();
|
|
70
81
|
const startedAt = now();
|
|
71
82
|
let resolutionTimer;
|
|
72
83
|
const records = await Promise.race([
|
|
@@ -75,10 +86,12 @@ async function whoisQuery(server, query, { port = 43, timeoutMs = 10000, totalDe
|
|
|
75
86
|
resolutionTimer = setTimeout(() => reject(new Error(`WHOIS request to ${server} timed out during DNS resolution`)), totalDeadlineMs);
|
|
76
87
|
}),
|
|
77
88
|
]).finally(() => clearTimeout(resolutionTimer));
|
|
89
|
+
signal?.throwIfAborted();
|
|
78
90
|
const candidates = records.slice(0, MAX_WHOIS_ADDRESSES);
|
|
79
91
|
const failures = [];
|
|
80
92
|
let attempts = 0;
|
|
81
93
|
for (const { address } of candidates) {
|
|
94
|
+
signal?.throwIfAborted();
|
|
82
95
|
const remainingMs = totalDeadlineMs - (now() - startedAt);
|
|
83
96
|
if (remainingMs <= 0)
|
|
84
97
|
break;
|
|
@@ -88,7 +101,9 @@ async function whoisQuery(server, query, { port = 43, timeoutMs = 10000, totalDe
|
|
|
88
101
|
port,
|
|
89
102
|
timeoutMs: Math.min(timeoutMs, remainingMs),
|
|
90
103
|
totalDeadlineMs: remainingMs,
|
|
104
|
+
...(signal ? { signal } : {}),
|
|
91
105
|
});
|
|
106
|
+
signal?.throwIfAborted();
|
|
92
107
|
try {
|
|
93
108
|
onAddressSelected(address);
|
|
94
109
|
}
|
|
@@ -98,6 +113,7 @@ async function whoisQuery(server, query, { port = 43, timeoutMs = 10000, totalDe
|
|
|
98
113
|
return response;
|
|
99
114
|
}
|
|
100
115
|
catch (error) {
|
|
116
|
+
signal?.throwIfAborted();
|
|
101
117
|
failures.push(errorMessage(error, 'connection failed').slice(0, 200));
|
|
102
118
|
}
|
|
103
119
|
}
|