@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
package/lib/hostname.mjs
CHANGED
|
@@ -1,38 +1,2 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
// whether a syntactically valid hostname must also be registrable.
|
|
4
|
-
const ASCII_LABEL_RE = /^[A-Za-z0-9-]+$/u;
|
|
5
|
-
function isValidAsciiHostname(value, options = {}) {
|
|
6
|
-
if (typeof value !== 'string'
|
|
7
|
-
|| value.length < 1
|
|
8
|
-
|| value.length > 253
|
|
9
|
-
|| (options.requireLowercase === true && value !== value.toLowerCase()))
|
|
10
|
-
return false;
|
|
11
|
-
const labels = value.split('.');
|
|
12
|
-
if (options.requireDot !== false && labels.length < 2)
|
|
13
|
-
return false;
|
|
14
|
-
for (const label of labels) {
|
|
15
|
-
if (!label
|
|
16
|
-
|| label.length > 63
|
|
17
|
-
|| label.startsWith('-')
|
|
18
|
-
|| label.endsWith('-')
|
|
19
|
-
|| !ASCII_LABEL_RE.test(label))
|
|
20
|
-
return false;
|
|
21
|
-
}
|
|
22
|
-
return true;
|
|
23
|
-
}
|
|
24
|
-
function isIpv4Literal(value) {
|
|
25
|
-
const parts = value.split('.');
|
|
26
|
-
return parts.length === 4 && parts.every((part) => {
|
|
27
|
-
if (!/^(?:0|[1-9]\d{0,2})$/u.test(part))
|
|
28
|
-
return false;
|
|
29
|
-
return Number(part) <= 255;
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
// Domain-only records must not accept an IPv4 literal merely because its
|
|
33
|
-
// dotted decimal labels also satisfy hostname syntax. IPv6 literals already
|
|
34
|
-
// fail the ASCII hostname grammar because they contain colons.
|
|
35
|
-
function isValidAsciiDomainName(value, options = {}) {
|
|
36
|
-
return isValidAsciiHostname(value, options) && !isIpv4Literal(value);
|
|
37
|
-
}
|
|
38
|
-
export { isValidAsciiDomainName, isValidAsciiHostname };
|
|
1
|
+
// Preserve the existing public import while sharing one dependency-neutral grammar.
|
|
2
|
+
export { isValidAsciiDomainName, isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
|
package/lib/html-signals.mjs
CHANGED
|
@@ -43,13 +43,8 @@ const HAS_CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f-\u009f]|\p{Default_Ignora
|
|
|
43
43
|
// scoped to resource tags, not every <a href> - an outbound link to the
|
|
44
44
|
// real site is normal on all sorts of pages; a resource pulled live from it
|
|
45
45
|
// during page load is not.
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const IDENTITY_TAG_RE = /<(html|link|meta|form)\b[^>]{0,4096}>/gi;
|
|
49
|
-
const OVERSIZED_IDENTITY_TAG_RE = /<(?:html|link|meta|form)\b[^>]{4097}/i;
|
|
50
|
-
const RELATIONSHIP_TAG_RE = /<(a|img|script|link|iframe|frame|source|video|audio|object|embed)\b[^>]{0,4096}>/gi;
|
|
51
|
-
const OVERSIZED_RELATIONSHIP_TAG_RE = /<(?:a|img|script|link|iframe|frame|source|video|audio|object|embed)\b[^>]{4097}/i;
|
|
52
|
-
const ATTRIBUTE_RE = /([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
46
|
+
const IDENTITY_TAGS = new Set(['html', 'link', 'meta', 'form']);
|
|
47
|
+
const RELATIONSHIP_TAGS = new Set(['a', 'img', 'script', 'link', 'iframe', 'frame', 'source', 'video', 'audio', 'object', 'embed']);
|
|
53
48
|
const LANGUAGE_TAG_RE = /^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/i;
|
|
54
49
|
const HOSTNAME_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;
|
|
55
50
|
const RESOURCE_LINK_RELS = new Set(['stylesheet', 'icon', 'preload', 'prefetch', 'modulepreload', 'manifest']);
|
|
@@ -57,9 +52,6 @@ const RISKY_DOWNLOAD_EXTENSIONS = new Set([
|
|
|
57
52
|
'7z', 'apk', 'bat', 'cmd', 'dmg', 'docm', 'exe', 'img', 'iso', 'jar', 'js',
|
|
58
53
|
'msi', 'pkg', 'ps1', 'rar', 'scr', 'vbs', 'xlsm', 'zip',
|
|
59
54
|
]);
|
|
60
|
-
const HTML_COMMENT_RE = /<!--[\s\S]*?(?:-->|$)/g;
|
|
61
|
-
const RAW_TEXT_BLOCK_RE = /<(script|style|textarea|template)\b([^>]*)>[\s\S]*?<\/\1\s*>/gi;
|
|
62
|
-
const NON_EXECUTING_RAW_TEXT_BLOCK_RE = /<(style|textarea|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
|
|
63
55
|
function stripWwwPrefix(host) {
|
|
64
56
|
return host.toLowerCase().replace(/^www\./, '');
|
|
65
57
|
}
|
|
@@ -72,24 +64,19 @@ function boundedHtmlText(value, maxLength, ellipsis = false) {
|
|
|
72
64
|
return null;
|
|
73
65
|
if (text.length <= maxLength)
|
|
74
66
|
return text;
|
|
75
|
-
return ellipsis ? `${text.slice(0, maxLength)}…` : text.slice(0, maxLength);
|
|
67
|
+
return ellipsis ? `${text.slice(0, Math.max(0, maxLength - 1))}…` : text.slice(0, maxLength);
|
|
76
68
|
}
|
|
77
|
-
function extractExternalAssetHosts(
|
|
69
|
+
function extractExternalAssetHosts(analysis, ownDomain, documentUrl) {
|
|
78
70
|
const ownHost = stripWwwPrefix(ownDomain);
|
|
79
71
|
const hosts = new Set();
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
while ((match = ASSET_TAG_RE.exec(html))) {
|
|
83
|
-
const resourceUrl = match[1];
|
|
84
|
-
if (!resourceUrl)
|
|
72
|
+
for (const element of analysis.elements) {
|
|
73
|
+
if (!element.html || !['img', 'script', 'link'].includes(element.name))
|
|
85
74
|
continue;
|
|
86
|
-
const
|
|
87
|
-
|
|
75
|
+
const attribute = element.attributes.find((attribute) => attribute.name === (element.name === 'link' ? 'href' : 'src'));
|
|
76
|
+
const resource = normalizeIdentityUrl(attribute?.value, analysis.effectiveBaseUrl ?? documentUrl);
|
|
77
|
+
if (!resource)
|
|
88
78
|
continue;
|
|
89
|
-
const
|
|
90
|
-
if (!matchedHost || HAS_CONTROL_CHARACTER_RE.test(matchedHost))
|
|
91
|
-
continue;
|
|
92
|
-
const host = stripWwwPrefix(matchedHost);
|
|
79
|
+
const host = stripWwwPrefix(new URL(resource.url).hostname);
|
|
93
80
|
if (host && host !== ownHost)
|
|
94
81
|
hosts.add(host);
|
|
95
82
|
if (hosts.size >= MAX_EXTERNAL_ASSET_HOSTS)
|
|
@@ -97,32 +84,6 @@ function extractExternalAssetHosts(html, ownDomain) {
|
|
|
97
84
|
}
|
|
98
85
|
return [...hosts];
|
|
99
86
|
}
|
|
100
|
-
function parseAttributes(tag) {
|
|
101
|
-
const start = tag.search(/\s/);
|
|
102
|
-
if (start === -1)
|
|
103
|
-
return new Map();
|
|
104
|
-
const attributes = new Map();
|
|
105
|
-
ATTRIBUTE_RE.lastIndex = 0;
|
|
106
|
-
let match;
|
|
107
|
-
while ((match = ATTRIBUTE_RE.exec(tag.slice(start)))) {
|
|
108
|
-
const name = (match[1] ?? '').toLowerCase();
|
|
109
|
-
if (!name)
|
|
110
|
-
continue;
|
|
111
|
-
if (!attributes.has(name))
|
|
112
|
-
attributes.set(name, match[2] ?? match[3] ?? match[4] ?? '');
|
|
113
|
-
}
|
|
114
|
-
return attributes;
|
|
115
|
-
}
|
|
116
|
-
function markupForTagParsing(html) {
|
|
117
|
-
return html
|
|
118
|
-
.replace(HTML_COMMENT_RE, ' ')
|
|
119
|
-
.replace(RAW_TEXT_BLOCK_RE, '<$1$2>');
|
|
120
|
-
}
|
|
121
|
-
function markupForTrackingIdentifiers(html) {
|
|
122
|
-
return html
|
|
123
|
-
.replace(HTML_COMMENT_RE, ' ')
|
|
124
|
-
.replace(NON_EXECUTING_RAW_TEXT_BLOCK_RE, ' ');
|
|
125
|
-
}
|
|
126
87
|
function normalizeLanguage(value) {
|
|
127
88
|
if (typeof value !== 'string' || value.length > 35 || HAS_CONTROL_CHARACTER_RE.test(value))
|
|
128
89
|
return null;
|
|
@@ -295,10 +256,9 @@ function trackingIdentifiers(html) {
|
|
|
295
256
|
truncated,
|
|
296
257
|
};
|
|
297
258
|
}
|
|
298
|
-
function extractPageRelationships(
|
|
259
|
+
function extractPageRelationships(analysis, domain, options = {}) {
|
|
299
260
|
const documentUrl = resolvedBaseUrl(domain, options.baseUrl);
|
|
300
261
|
const baseUrl = resolvedBaseUrl(domain, options.effectiveBaseUrl ?? documentUrl);
|
|
301
|
-
const markup = typeof options.tagMarkup === 'string' ? options.tagMarkup : markupForTagParsing(html);
|
|
302
262
|
const baseOrigin = options.documentOrigin ?? new URL(documentUrl).origin;
|
|
303
263
|
const resourceKeys = new Set();
|
|
304
264
|
const resourceOrigins = new Set();
|
|
@@ -312,25 +272,26 @@ function extractPageRelationships(html, domain, options = {}) {
|
|
|
312
272
|
let riskyDownloadCount = 0;
|
|
313
273
|
let tagsExamined = 0;
|
|
314
274
|
let discardedUrls = 0;
|
|
315
|
-
let tagLimitReached =
|
|
275
|
+
let tagLimitReached = analysis.inputLimitReached || analysis.tagLimitReached;
|
|
316
276
|
let resourceOriginLimitReached = false;
|
|
317
277
|
let embeddedOriginLimitReached = false;
|
|
318
278
|
let contactDomainLimitReached = false;
|
|
319
279
|
let downloadOriginLimitReached = false;
|
|
320
280
|
let downloadFileTypeLimitReached = false;
|
|
321
281
|
let perTagLimitReached = false;
|
|
322
|
-
let
|
|
323
|
-
|
|
324
|
-
|
|
282
|
+
let resourceCountLimitReached = false;
|
|
283
|
+
for (const element of analysis.elements) {
|
|
284
|
+
if (!element.html || !RELATIONSHIP_TAGS.has(element.name))
|
|
285
|
+
continue;
|
|
325
286
|
if (tagsExamined >= MAX_RESOURCE_TAGS) {
|
|
326
287
|
tagLimitReached = true;
|
|
327
288
|
break;
|
|
328
289
|
}
|
|
329
290
|
tagsExamined += 1;
|
|
330
|
-
const tagName =
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
291
|
+
const tagName = element.name;
|
|
292
|
+
const attributes = new Map(element.attributes.map(({ name, value }) => [name, value]));
|
|
293
|
+
if (element.attributesTruncated)
|
|
294
|
+
tagLimitReached = true;
|
|
334
295
|
if (tagName === 'a') {
|
|
335
296
|
const href = attributes.get('href');
|
|
336
297
|
const contacts = mailtoDomains(href);
|
|
@@ -378,6 +339,10 @@ function extractPageRelationships(html, domain, options = {}) {
|
|
|
378
339
|
}
|
|
379
340
|
const key = `${reference.type}:${normalized.url}`;
|
|
380
341
|
if (!resourceKeys.has(key)) {
|
|
342
|
+
if (resourceKeys.size >= MAX_RESOURCE_TAGS) {
|
|
343
|
+
resourceCountLimitReached = true;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
381
346
|
resourceKeys.add(key);
|
|
382
347
|
byType[reference.type] += 1;
|
|
383
348
|
}
|
|
@@ -389,10 +354,15 @@ function extractPageRelationships(html, domain, options = {}) {
|
|
|
389
354
|
embeddedOriginLimitReached = true;
|
|
390
355
|
}
|
|
391
356
|
}
|
|
392
|
-
const tracking = trackingIdentifiers(
|
|
357
|
+
const tracking = trackingIdentifiers([
|
|
358
|
+
...analysis.elements.filter((element) => element.html).flatMap((element) => element.attributes.map((attribute) => attribute.value)),
|
|
359
|
+
...analysis.scripts.map((script) => script.inlineContent),
|
|
360
|
+
].join('\n'));
|
|
361
|
+
if (analysis.scriptLimitReached || analysis.inlineLimitReached)
|
|
362
|
+
tracking.truncated = true;
|
|
393
363
|
const truncated = tagLimitReached || resourceOriginLimitReached || embeddedOriginLimitReached
|
|
394
364
|
|| contactDomainLimitReached || downloadOriginLimitReached || downloadFileTypeLimitReached
|
|
395
|
-
|| perTagLimitReached || tracking.truncated;
|
|
365
|
+
|| perTagLimitReached || resourceCountLimitReached || tracking.truncated;
|
|
396
366
|
const limitations = [];
|
|
397
367
|
if (tagLimitReached)
|
|
398
368
|
limitations.push(`Resource parsing reached the ${MAX_RESOURCE_TAGS}-tag or ${MAX_IDENTITY_TAG_LENGTH}-character tag limit.`);
|
|
@@ -408,6 +378,8 @@ function extractPageRelationships(html, domain, options = {}) {
|
|
|
408
378
|
limitations.push(`Only the first ${MAX_DOWNLOAD_FILE_TYPES} risky download file types were retained.`);
|
|
409
379
|
if (perTagLimitReached)
|
|
410
380
|
limitations.push(`Some srcset URL candidates could not be safely enumerated within the ${MAX_URLS_PER_TAG}-candidate per-tag boundary.`);
|
|
381
|
+
if (resourceCountLimitReached)
|
|
382
|
+
limitations.push(`Only the first ${MAX_RESOURCE_TAGS} distinct resource references were examined.`);
|
|
411
383
|
if (tracking.truncated)
|
|
412
384
|
limitations.push(`Only the first ${MAX_TRACKING_IDENTIFIERS} tracking identifiers were retained.`);
|
|
413
385
|
return {
|
|
@@ -415,7 +387,7 @@ function extractPageRelationships(html, domain, options = {}) {
|
|
|
415
387
|
count: resourceKeys.size,
|
|
416
388
|
byType,
|
|
417
389
|
externalOrigins: [...resourceOrigins].sort(),
|
|
418
|
-
truncated: tagLimitReached || resourceOriginLimitReached || perTagLimitReached,
|
|
390
|
+
truncated: tagLimitReached || resourceOriginLimitReached || perTagLimitReached || resourceCountLimitReached,
|
|
419
391
|
},
|
|
420
392
|
embeddedOrigins: [...embeddedOrigins].sort(),
|
|
421
393
|
contactDomains: [...contactDomains].sort(),
|
|
@@ -445,12 +417,9 @@ function metaRefreshTarget(content) {
|
|
|
445
417
|
}
|
|
446
418
|
function extractPageIdentity(html, domain, options = {}) {
|
|
447
419
|
const documentUrl = resolvedBaseUrl(domain, options.baseUrl);
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
const baseUrl = resolvedBaseUrl(domain, options.effectiveBaseUrl ?? baseAnalysis?.effectiveBaseUrl ?? documentUrl);
|
|
452
|
-
const baseHrefState = options.baseHrefState ?? baseAnalysis?.baseHrefState ?? 'absent';
|
|
453
|
-
const tagMarkup = markupForTagParsing(html);
|
|
420
|
+
const analysis = options.htmlAnalysis ?? analyzeStaticHtml(html, { baseUrl: documentUrl, includeVisibleText: true });
|
|
421
|
+
const baseUrl = resolvedBaseUrl(domain, options.effectiveBaseUrl ?? analysis.effectiveBaseUrl ?? documentUrl);
|
|
422
|
+
const baseHrefState = options.baseHrefState ?? analysis.baseHrefState;
|
|
454
423
|
const parsedDocument = new URL(documentUrl);
|
|
455
424
|
const baseOrigin = options.documentOrigin ?? parsedDocument.origin;
|
|
456
425
|
const baseUsesHttps = parsedDocument.protocol === 'https:';
|
|
@@ -469,19 +438,19 @@ function extractPageIdentity(html, domain, options = {}) {
|
|
|
469
438
|
let discardedUrls = 0;
|
|
470
439
|
let formLimitReached = false;
|
|
471
440
|
let originLimitReached = false;
|
|
472
|
-
let tagLimitReached =
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
441
|
+
let tagLimitReached = analysis.inputLimitReached || analysis.tagLimitReached;
|
|
442
|
+
for (const element of analysis.elements) {
|
|
443
|
+
if (!element.html || !IDENTITY_TAGS.has(element.name))
|
|
444
|
+
continue;
|
|
476
445
|
if (tagsExamined >= MAX_IDENTITY_TAGS) {
|
|
477
446
|
tagLimitReached = true;
|
|
478
447
|
break;
|
|
479
448
|
}
|
|
480
449
|
tagsExamined += 1;
|
|
481
|
-
const tagName =
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
450
|
+
const tagName = element.name;
|
|
451
|
+
const attributes = new Map(element.attributes.map(({ name, value }) => [name, value]));
|
|
452
|
+
if (element.attributesTruncated)
|
|
453
|
+
tagLimitReached = true;
|
|
485
454
|
if (tagName === 'html' && documentLanguage === null) {
|
|
486
455
|
documentLanguage = normalizeLanguage(attributes.get('lang'));
|
|
487
456
|
continue;
|
|
@@ -551,12 +520,10 @@ function extractPageIdentity(html, domain, options = {}) {
|
|
|
551
520
|
const queryOmitted = [canonical, metaRefresh, openGraphUrl].some((item) => item?.queryOmitted);
|
|
552
521
|
const pathTruncated = [canonical, metaRefresh, openGraphUrl].some((item) => item?.pathTruncated);
|
|
553
522
|
const sourceTruncated = options.sourceTruncated === true;
|
|
554
|
-
const relationships = extractPageRelationships(
|
|
523
|
+
const relationships = extractPageRelationships(analysis, domain, { baseUrl: documentUrl, effectiveBaseUrl: baseUrl, documentOrigin: baseOrigin });
|
|
555
524
|
const fingerprints = createPageFingerprints(html, {
|
|
556
|
-
// Retained fingerprint algorithms pre-date document-base handling. Keep
|
|
557
|
-
// their response-URL normalization stable until a versioned fingerprint
|
|
558
|
-
// migration is intentionally introduced.
|
|
559
525
|
baseUrl: documentUrl,
|
|
526
|
+
htmlAnalysis: analysis,
|
|
560
527
|
exactBodyHash: options.exactBodyHash,
|
|
561
528
|
sourceTruncated,
|
|
562
529
|
resources: relationships.resources,
|
|
@@ -696,9 +663,34 @@ function buildPagePublicationMetadata(analysis, sourceTruncated) {
|
|
|
696
663
|
},
|
|
697
664
|
};
|
|
698
665
|
}
|
|
699
|
-
function
|
|
666
|
+
function domainSaleLandingPage(analysis, domain) {
|
|
667
|
+
if (analysis.inputLimitReached || analysis.tagLimitReached || analysis.visibleTextLimitReached)
|
|
668
|
+
return false;
|
|
669
|
+
const title = analysis.title?.trim().toLowerCase() ?? '';
|
|
670
|
+
const content = analysis.visibleText.trim().toLowerCase();
|
|
671
|
+
const saleSuffix = /^\s+(?:(?:is|may be)\s+)?(?:for sale|available for purchase|available for lease)(?:[.!?:\s]|$)/u;
|
|
672
|
+
const explicitSale = (value) => {
|
|
673
|
+
const genericSubject = /^(?:(?:this|the) domain(?: name)?|domain(?: name)?)/u.exec(value)?.[0];
|
|
674
|
+
return (genericSubject !== undefined && saleSuffix.test(value.slice(genericSubject.length)))
|
|
675
|
+
|| (value.startsWith(domain) && saleSuffix.test(value.slice(domain.length)));
|
|
676
|
+
};
|
|
677
|
+
const explicitPurchase = /^(?:buy|purchase|own|bid on|inquire about) (?:this|the) domain(?: name)?(?:[.!?:\s]|$)/u;
|
|
678
|
+
// A short, directly worded landing page is sufficient. Long articles need
|
|
679
|
+
// corroborating page-title and transaction-control evidence; a generic
|
|
680
|
+
// commerce CTA or quoted example cannot classify the domain for sale.
|
|
681
|
+
if (content.length <= 240 && (explicitSale(content) || explicitPurchase.test(content)))
|
|
682
|
+
return true;
|
|
683
|
+
if (!explicitSale(title) && !explicitPurchase.test(title))
|
|
684
|
+
return false;
|
|
685
|
+
if (!/(?:this domain(?: name)? (?:is )?for sale|buy this domain|purchase this domain|domain name for sale)/u.test(content))
|
|
686
|
+
return false;
|
|
687
|
+
return analysis.elements.some((element) => element.html
|
|
688
|
+
&& ['form', 'button', 'a'].includes(element.name));
|
|
689
|
+
}
|
|
690
|
+
async function extractHtmlSignals(html, domain, options = {}) {
|
|
691
|
+
options.signal?.throwIfAborted();
|
|
700
692
|
const documentUrl = resolvedBaseUrl(domain, options.baseUrl);
|
|
701
|
-
const htmlAnalysis = analyzeStaticHtml(html, { baseUrl: documentUrl, includeVisibleText: true });
|
|
693
|
+
const htmlAnalysis = options.htmlAnalysis ?? analyzeStaticHtml(html, { baseUrl: documentUrl, includeVisibleText: true });
|
|
702
694
|
const effectiveBaseUrl = htmlAnalysis.effectiveBaseUrl ?? documentUrl;
|
|
703
695
|
const documentOrigin = new URL(documentUrl).origin;
|
|
704
696
|
const pageIdentity = options.includePageIdentity === false ? null : extractPageIdentity(html, domain, {
|
|
@@ -707,6 +699,7 @@ function extractHtmlSignals(html, domain, options = {}) {
|
|
|
707
699
|
effectiveBaseUrl,
|
|
708
700
|
documentOrigin,
|
|
709
701
|
baseHrefState: htmlAnalysis.baseHrefState,
|
|
702
|
+
htmlAnalysis,
|
|
710
703
|
});
|
|
711
704
|
const includeCredentialSurfaceProfile = pageIdentity && options.includeCredentialSurfaceProfile === true;
|
|
712
705
|
const includeStructuredDataIdentity = pageIdentity && options.includeStructuredDataIdentity !== false;
|
|
@@ -719,14 +712,17 @@ function extractHtmlSignals(html, domain, options = {}) {
|
|
|
719
712
|
}
|
|
720
713
|
: pageIdentity;
|
|
721
714
|
const pageLanguageSignal = detectPageLanguageSignal(html, pageIdentity?.documentLanguage, htmlAnalysis);
|
|
715
|
+
const domainSaleSignal = options.sourceTruncated !== true && domainSaleLandingPage(htmlAnalysis, domain)
|
|
716
|
+
? 'explicit domain-sale landing-page content' : null;
|
|
722
717
|
return {
|
|
718
|
+
domainSaleSignal,
|
|
723
719
|
pageTitle: htmlAnalysis.title,
|
|
724
720
|
hasPasswordField: htmlAnalysis.forms.categories.password > 0,
|
|
725
721
|
phishingLanguageMatch: pageLanguageSignal?.label ?? null,
|
|
726
722
|
hasExternalFormAction: pageIdentity
|
|
727
723
|
? pageIdentity.forms.externalActionOrigins.length > 0
|
|
728
724
|
: null,
|
|
729
|
-
externalAssetHosts: extractExternalAssetHosts(
|
|
725
|
+
externalAssetHosts: extractExternalAssetHosts(htmlAnalysis, domain, documentUrl),
|
|
730
726
|
cspMetaPolicy: analyzeCspMetaPolicies(htmlAnalysis.cspMetaPolicies, htmlAnalysis.cspMetaLimitReached),
|
|
731
727
|
pageIdentity: pageIdentityOutput,
|
|
732
728
|
credentialSurfaceProfile: includeCredentialSurfaceProfile && htmlAnalysis ? analyzeCredentialSurfaceProfile({
|
|
@@ -740,9 +736,10 @@ function extractHtmlSignals(html, domain, options = {}) {
|
|
|
740
736
|
observedAt: options.observedAt,
|
|
741
737
|
sourceTruncated: options.sourceTruncated,
|
|
742
738
|
}) : null,
|
|
743
|
-
technologyProfile: includeTechnologyProfile && htmlAnalysis ? analyzeWebsiteTechnology({
|
|
739
|
+
technologyProfile: includeTechnologyProfile && htmlAnalysis ? await analyzeWebsiteTechnology({
|
|
744
740
|
htmlAnalysis,
|
|
745
741
|
generator: pageIdentity.generator,
|
|
742
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
746
743
|
httpServer: options.httpServer,
|
|
747
744
|
responseHeaders: options.responseHeaders,
|
|
748
745
|
resourceOrigins: pageIdentity.resources.externalOrigins,
|
|
@@ -754,7 +751,7 @@ function extractHtmlSignals(html, domain, options = {}) {
|
|
|
754
751
|
pageRoleProfile: includeDerivedPageProfiles && htmlAnalysis ? analyzePageRole({
|
|
755
752
|
htmlAnalysis,
|
|
756
753
|
pageTitle: htmlAnalysis.title,
|
|
757
|
-
activityStatus: options.activityStatus,
|
|
754
|
+
activityStatus: domainSaleSignal ? 'parked' : options.activityStatus,
|
|
758
755
|
observedAt: options.observedAt,
|
|
759
756
|
sourceTruncated: options.sourceTruncated,
|
|
760
757
|
}) : null,
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Browser-safe bounds for the rich HTTP evidence contract and its compact
|
|
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
|
+
import { MAX_OUTBOUND_HTTP_URL_CHARACTERS } from "../packages/contracts/http-url.mjs";
|
|
4
5
|
export const MAX_HTTP_EVIDENCE_REDIRECTS = 5;
|
|
5
6
|
export const MAX_HTTP_ATTEMPTS = 2;
|
|
6
|
-
export const MAX_HTTP_PROVENANCE_URL =
|
|
7
|
+
export const MAX_HTTP_PROVENANCE_URL = MAX_OUTBOUND_HTTP_URL_CHARACTERS;
|
|
7
8
|
export const MAX_HTTP_ERROR_LENGTH = 180;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// converts the shared safe-fetch trace and selected response metadata into an
|
|
4
4
|
// additive evidence object suitable for API responses and exports.
|
|
5
5
|
import { createObservation } from "../packages/evidence/observation.mjs";
|
|
6
|
+
import { MAX_OUTBOUND_HTTP_URL_CHARACTERS } from "../packages/contracts/http-url.mjs";
|
|
6
7
|
import { MAX_HTTP_ATTEMPTS, MAX_HTTP_ERROR_LENGTH, MAX_HTTP_EVIDENCE_REDIRECTS, MAX_HTTP_PROVENANCE_URL, } from "./http-evidence-bounds.mjs";
|
|
7
8
|
import { HTTP_DELIVERY_LIMITATIONS, HTTP_DELIVERY_METADATA_VERSION, MAX_HTTP_CACHE_SECONDS, MAX_HTTP_DELIVERY_HEADER_BYTES, MAX_HTTP_DELIVERY_TOKENS, } from "./homepage-metadata-contract.mjs";
|
|
8
9
|
const MAX_HTTP_REDIRECTS = MAX_HTTP_EVIDENCE_REDIRECTS;
|
|
@@ -25,7 +26,7 @@ function boundedString(value, maxLength) {
|
|
|
25
26
|
return normalized ? normalized.slice(0, maxLength) : null;
|
|
26
27
|
}
|
|
27
28
|
function normalizeProvenanceUrl(value) {
|
|
28
|
-
if (typeof value !== 'string' || value.length === 0 || value.length >
|
|
29
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_OUTBOUND_HTTP_URL_CHARACTERS)
|
|
29
30
|
return null;
|
|
30
31
|
try {
|
|
31
32
|
const parsed = new URL(value);
|
|
@@ -165,14 +166,14 @@ function headerValidity(header, validator) {
|
|
|
165
166
|
return { present: true, valid: null };
|
|
166
167
|
return { present: true, valid: header.state === 'observed' && Boolean(header.value && validator(header.value)) };
|
|
167
168
|
}
|
|
168
|
-
function validHttpDate(value) {
|
|
169
|
+
function validHttpDate(value, observedAt) {
|
|
169
170
|
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
170
171
|
const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
171
172
|
const longWeekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
172
|
-
const time = '([01]\\d|2[0-3]):([0-5]\\d):([0-5]\\d)';
|
|
173
|
+
const time = '([01]\\d|2[0-3]):([0-5]\\d):([0-5]\\d|60)';
|
|
173
174
|
const imfFixdate = value.match(new RegExp(`^(${weekdays.join('|')}), (0[1-9]|[12]\\d|3[01]) (${months.join('|')}) (\\d{4}) ${time} GMT$`, 'u'));
|
|
174
175
|
const rfc850 = value.match(new RegExp(`^(${longWeekdays.join('|')}), (0[1-9]|[12]\\d|3[01])-(${months.join('|')})-(\\d{2}) ${time} GMT$`, 'u'));
|
|
175
|
-
const asctime = value.match(new RegExp(`^(${weekdays.join('|')}) (${months.join('|')}) (
|
|
176
|
+
const asctime = value.match(new RegExp(`^(${weekdays.join('|')}) (${months.join('|')}) ( [1-9]|0[1-9]|[12]\\d|3[01]) ${time} (\\d{4})$`, 'u'));
|
|
176
177
|
let weekday;
|
|
177
178
|
let day;
|
|
178
179
|
let month;
|
|
@@ -196,10 +197,19 @@ function validHttpDate(value) {
|
|
|
196
197
|
day = Number(rfc850[2]);
|
|
197
198
|
month = months.indexOf(rfc850[3]);
|
|
198
199
|
const shortYear = Number(rfc850[4]);
|
|
199
|
-
|
|
200
|
+
// RFC 9110's rolling window is relative to receipt, not a later review
|
|
201
|
+
// or the host's timezone. The observation supplies that stable reference.
|
|
202
|
+
const futureBoundary = new Date(observedAt);
|
|
203
|
+
futureBoundary.setUTCFullYear(futureBoundary.getUTCFullYear() + 50);
|
|
204
|
+
year = Math.floor(futureBoundary.getUTCFullYear() / 100) * 100 + shortYear;
|
|
200
205
|
hour = Number(rfc850[5]);
|
|
201
206
|
minute = Number(rfc850[6]);
|
|
202
207
|
second = Number(rfc850[7]);
|
|
208
|
+
const candidate = new Date(0);
|
|
209
|
+
candidate.setUTCFullYear(year, month, day);
|
|
210
|
+
candidate.setUTCHours(hour, minute, Math.min(second, 59), 0);
|
|
211
|
+
if (candidate.getTime() > futureBoundary.getTime())
|
|
212
|
+
year -= 100;
|
|
203
213
|
expectedWeekdays = longWeekdays;
|
|
204
214
|
}
|
|
205
215
|
else if (asctime) {
|
|
@@ -218,13 +228,15 @@ function validHttpDate(value) {
|
|
|
218
228
|
return false;
|
|
219
229
|
const parsed = new Date(0);
|
|
220
230
|
parsed.setUTCFullYear(year, month, day);
|
|
221
|
-
|
|
231
|
+
// A leap-second spelling must not roll into the following calendar day
|
|
232
|
+
// while validating the declared date and weekday.
|
|
233
|
+
parsed.setUTCHours(hour, minute, Math.min(second, 59), 0);
|
|
222
234
|
return parsed.getUTCFullYear() === year
|
|
223
235
|
&& parsed.getUTCMonth() === month
|
|
224
236
|
&& parsed.getUTCDate() === day
|
|
225
237
|
&& expectedWeekdays[parsed.getUTCDay()] === weekday;
|
|
226
238
|
}
|
|
227
|
-
function parseCachePolicy(headers) {
|
|
239
|
+
function parseCachePolicy(headers, observedAt) {
|
|
228
240
|
const cacheControl = deliveryHeader(headers, 'cache-control');
|
|
229
241
|
const age = deliveryHeader(headers, 'age', 64);
|
|
230
242
|
const etagHeader = deliveryHeader(headers, 'etag');
|
|
@@ -329,8 +341,8 @@ function parseCachePolicy(headers) {
|
|
|
329
341
|
if (age.state === 'observed' && ageSeconds === null)
|
|
330
342
|
malformed = true;
|
|
331
343
|
const etag = headerValidity(etagHeader, (value) => /^(?:W\/)?"[\x21\x23-\x7e]*"$/u.test(value));
|
|
332
|
-
const lastModified = headerValidity(lastModifiedHeader, validHttpDate);
|
|
333
|
-
const expires = headerValidity(expiresHeader, validHttpDate);
|
|
344
|
+
const lastModified = headerValidity(lastModifiedHeader, (value) => validHttpDate(value, observedAt));
|
|
345
|
+
const expires = headerValidity(expiresHeader, (value) => validHttpDate(value, observedAt));
|
|
334
346
|
malformed = malformed || [etag, lastModified, expires].some((item) => item.present && item.valid === false);
|
|
335
347
|
const headersObserved = [cacheControl, age, etagHeader, lastModifiedHeader, expiresHeader]
|
|
336
348
|
.some((header) => header.state !== 'not_observed');
|
|
@@ -362,9 +374,9 @@ function parseCachePolicy(headers) {
|
|
|
362
374
|
truncated: partial,
|
|
363
375
|
};
|
|
364
376
|
}
|
|
365
|
-
function buildDeliveryMetadata(headers) {
|
|
377
|
+
function buildDeliveryMetadata(headers, observedAt) {
|
|
366
378
|
const contentEncoding = parseContentEncoding(headers);
|
|
367
|
-
const cacheResult = parseCachePolicy(headers);
|
|
379
|
+
const cacheResult = parseCachePolicy(headers, observedAt);
|
|
368
380
|
const cachePolicy = cacheResult.value;
|
|
369
381
|
const incomplete = [contentEncoding.status, cachePolicy.status].some((status) => ['partial', 'malformed'].includes(status));
|
|
370
382
|
const truncated = contentEncoding.status === 'partial' || cacheResult.truncated;
|
|
@@ -466,21 +478,22 @@ function buildHttpObservation(detail, options = {}) {
|
|
|
466
478
|
...(Array.isArray(options.previousAttempts) ? options.previousAttempts : []),
|
|
467
479
|
{ url: detail && detail.requestedUrl, httpStatus: status },
|
|
468
480
|
]);
|
|
481
|
+
const observation = createObservation({
|
|
482
|
+
status: incomplete ? 'partial' : 'success',
|
|
483
|
+
observedAt: options.observedAt,
|
|
484
|
+
scanMode: 'deep',
|
|
485
|
+
source: 'http',
|
|
486
|
+
durationMs: detail && detail.durationMs,
|
|
487
|
+
complete: !incomplete,
|
|
488
|
+
truncated: incomplete,
|
|
489
|
+
limitations,
|
|
490
|
+
diagnostics: {
|
|
491
|
+
redirectCount: redirects.length,
|
|
492
|
+
httpStatus: status,
|
|
493
|
+
},
|
|
494
|
+
});
|
|
469
495
|
return {
|
|
470
|
-
...
|
|
471
|
-
status: incomplete ? 'partial' : 'success',
|
|
472
|
-
observedAt: options.observedAt,
|
|
473
|
-
scanMode: 'deep',
|
|
474
|
-
source: 'http',
|
|
475
|
-
durationMs: detail && detail.durationMs,
|
|
476
|
-
complete: !incomplete,
|
|
477
|
-
truncated: incomplete,
|
|
478
|
-
limitations,
|
|
479
|
-
diagnostics: {
|
|
480
|
-
redirectCount: redirects.length,
|
|
481
|
-
httpStatus: status,
|
|
482
|
-
},
|
|
483
|
-
}),
|
|
496
|
+
...observation,
|
|
484
497
|
requestUrl: requested ? requested.url : null,
|
|
485
498
|
finalUrl: final ? final.url : null,
|
|
486
499
|
transportSecurity: final ? final.url.split(':', 1)[0] : null,
|
|
@@ -506,7 +519,7 @@ function buildHttpObservation(detail, options = {}) {
|
|
|
506
519
|
bytes: capturedBodyBytes,
|
|
507
520
|
} : null,
|
|
508
521
|
securityHeaders: securityHeaders(response && response.headers),
|
|
509
|
-
deliveryMetadata: buildDeliveryMetadata(response && response.headers),
|
|
522
|
+
deliveryMetadata: buildDeliveryMetadata(response && response.headers, observation.observedAt),
|
|
510
523
|
},
|
|
511
524
|
};
|
|
512
525
|
}
|
package/lib/lookup-cache.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// In-memory, so (like the bootstrap cache and the rate limiter) this
|
|
11
11
|
// applies globally on server.mts's one long-lived process, but only within
|
|
12
12
|
// a single warm container on Netlify Functions.
|
|
13
|
+
import { abortable } from "./abort.mjs";
|
|
13
14
|
const TTL_MS = 3 * 60 * 1000; // 3 minutes
|
|
14
15
|
const SWEEP_INTERVAL_MS = 5 * 60 * 1000;
|
|
15
16
|
// Bounds worst-case memory regardless of the TTL/sweep - without this, a
|
|
@@ -72,7 +73,7 @@ function getCached(key) {
|
|
|
72
73
|
deleteEntry(key);
|
|
73
74
|
return undefined;
|
|
74
75
|
}
|
|
75
|
-
return entry.value;
|
|
76
|
+
return structuredClone(entry.value);
|
|
76
77
|
}
|
|
77
78
|
function setCached(key, value) {
|
|
78
79
|
sweepExpiredEntries(Date.now());
|
|
@@ -80,9 +81,16 @@ function setCached(key, value) {
|
|
|
80
81
|
const size = approxByteSize(value);
|
|
81
82
|
// A value whose serialized size cannot be measured cannot participate in a
|
|
82
83
|
// byte-bounded cache. Return it to the current caller without retaining it.
|
|
83
|
-
if (size === null)
|
|
84
|
-
return;
|
|
85
|
-
|
|
84
|
+
if (size === null || size > MAX_TOTAL_BYTES)
|
|
85
|
+
return { value, owned: false };
|
|
86
|
+
let snapshot;
|
|
87
|
+
try {
|
|
88
|
+
snapshot = structuredClone(value);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return { value, owned: false };
|
|
92
|
+
}
|
|
93
|
+
store.set(key, { value: snapshot, expiresAt: Date.now() + TTL_MS, size });
|
|
86
94
|
totalBytes += size;
|
|
87
95
|
while (store.size > 0 && (store.size > MAX_ENTRIES || totalBytes > MAX_TOTAL_BYTES)) {
|
|
88
96
|
const oldestKey = store.keys().next().value;
|
|
@@ -90,6 +98,12 @@ function setCached(key, value) {
|
|
|
90
98
|
break;
|
|
91
99
|
deleteEntry(oldestKey);
|
|
92
100
|
}
|
|
101
|
+
return { value: snapshot, owned: true };
|
|
102
|
+
}
|
|
103
|
+
// Stored data and coalesced results belong to the cache, not to a caller. Each
|
|
104
|
+
// consumer receives its own copy, including the first and concurrent callers.
|
|
105
|
+
function callerResult(result) {
|
|
106
|
+
return result.owned ? structuredClone(result.value) : result.value;
|
|
93
107
|
}
|
|
94
108
|
// Concurrent callers for the same key (e.g. a bulk scan's concurrent
|
|
95
109
|
// workers, or a fast scan immediately followed by a deep-check, hitting the
|
|
@@ -103,25 +117,34 @@ const inFlight = new Map();
|
|
|
103
117
|
// The cache intentionally holds heterogeneous public lookup results. Callers
|
|
104
118
|
// supply distinct result contracts, so this internal compatibility boundary
|
|
105
119
|
// stays dynamically typed rather than asserting one shared payload shape.
|
|
106
|
-
async function cached(key, factory) {
|
|
120
|
+
async function cached(key, factory, signal) {
|
|
121
|
+
signal?.throwIfAborted();
|
|
107
122
|
const hit = getCached(key);
|
|
108
123
|
if (hit !== undefined)
|
|
109
124
|
return hit;
|
|
125
|
+
// A deadline-scoped caller may reuse completed public data, but must own
|
|
126
|
+
// its cancellable work rather than aborting another caller's shared request.
|
|
127
|
+
if (signal) {
|
|
128
|
+
const value = await abortable(factory, signal);
|
|
129
|
+
signal.throwIfAborted();
|
|
130
|
+
return callerResult(setCached(key, value));
|
|
131
|
+
}
|
|
110
132
|
const pending = inFlight.get(key);
|
|
111
133
|
if (pending)
|
|
112
|
-
return pending;
|
|
113
|
-
|
|
134
|
+
return callerResult(await pending);
|
|
135
|
+
// Register pending work before invoking the factory, including factories
|
|
136
|
+
// that throw synchronously. A rejected promise must never poison the key.
|
|
137
|
+
const promise = Promise.resolve().then(async () => {
|
|
114
138
|
try {
|
|
115
139
|
const value = await factory();
|
|
116
|
-
setCached(key, value);
|
|
117
|
-
return value;
|
|
140
|
+
return setCached(key, value);
|
|
118
141
|
}
|
|
119
142
|
finally {
|
|
120
143
|
inFlight.delete(key);
|
|
121
144
|
}
|
|
122
|
-
})
|
|
145
|
+
});
|
|
123
146
|
inFlight.set(key, promise);
|
|
124
|
-
return promise;
|
|
147
|
+
return callerResult(await promise);
|
|
125
148
|
}
|
|
126
149
|
// Expired entries are swept lazily during cache activity. This preserves the
|
|
127
150
|
// long-running Express cleanup behavior without creating an import-time timer,
|