@slicedearth/whoisleuth-cli 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +6 -0
- package/README.md +20 -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 +454 -1748
- package/cli/artifact-validation/case-response.mjs +66 -35
- 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 +112 -72
- 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-checkpoint.mjs +34 -3
- 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 +66 -111
- package/cli/command-argument-grammar.mjs +215 -0
- package/cli/command-reference.mjs +342 -285
- package/cli/completion.mjs +303 -80
- 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 +59 -18
- package/cli/discovery-scan-command-runner.mjs +21 -0
- package/cli/discovery-scan.mjs +13 -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 +2 -0
- package/cli/input.mjs +116 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +18 -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/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- 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-header-review.mjs +467 -0
- 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 +70 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +62 -7
- 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 +77 -24
- package/docs/cli.md +300 -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 +51 -8
- 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-convergence-review.mjs +1 -0
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +73 -26
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +49 -16
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +221 -117
- package/lib/domain-control-manifest.mjs +129 -69
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +201 -8
- package/lib/domain-posture.mjs +147 -29
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -543
- package/lib/evidence-report.mjs +5 -3
- 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 +111 -108
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/local-geoip-evidence.mjs +19 -3
- 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 +8 -10
- package/lib/lookup-source-progress.mjs +40 -14
- package/lib/lookup.mjs +48 -27
- package/lib/nameserver-preflight-review.mjs +1 -0
- 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-capabilities.mjs +1 -1
- 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 +85 -34
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +18 -6
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +42 -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 +178 -151
- 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 +153 -45
- 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/lib/zone-intent-review.mjs +16 -6
- 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 +127 -44
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +12 -22
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +95 -31
- package/packages/cases/case-record-projection.mjs +221 -0
- package/packages/cases/case-report.mjs +70 -54
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +64 -1115
- 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 +74 -572
- 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 +31 -2
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
- package/packages/comparison/page-similarity.mjs +5 -2
- 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 +366 -83
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +94 -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 +15 -11
- 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 +183 -211
- 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 +201 -33
- 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 +92 -51
- 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 +193 -165
- 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 +276 -22
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +23 -11
- package/packages/workspace/bulk-session-model.mjs +100 -24
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/detection-rule-model.mjs +70 -22
- 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 -23
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +164 -26
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +87 -21
- package/third-party-notices.txt +29 -2
- package/cli/evidence-command-arguments.mjs +0 -156
- 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
|
@@ -16,7 +16,6 @@ import { analyzeStaticHtml } from "./static-html-analysis.mjs";
|
|
|
16
16
|
import { analyzeStructuredDataIdentity } from "./structured-data-identity.mjs";
|
|
17
17
|
import { analyzeWebsiteTechnology } from "./website-technology.mjs";
|
|
18
18
|
import { PAGE_PUBLICATION_LIMITATIONS, PAGE_PUBLICATION_METADATA_VERSION, } from "./homepage-metadata-contract.mjs";
|
|
19
|
-
const MAX_TITLE_LENGTH = 200;
|
|
20
19
|
const MAX_EXTERNAL_ASSET_HOSTS = 20;
|
|
21
20
|
const MAX_IDENTITY_TAGS = 512;
|
|
22
21
|
const MAX_IDENTITY_TAG_LENGTH = 4096;
|
|
@@ -37,10 +36,6 @@ const MAX_URLS_PER_TAG = 20;
|
|
|
37
36
|
const PAGE_IDENTITY_VERSION = 3;
|
|
38
37
|
const CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f-\u009f]|\p{Default_Ignorable_Code_Point}/gu;
|
|
39
38
|
const HAS_CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f-\u009f]|\p{Default_Ignorable_Code_Point}/u;
|
|
40
|
-
const TITLE_RE = /<title[^>]*>([\s\S]*?)<\/title>/i;
|
|
41
|
-
// A password input is the single strongest static-HTML tell that a page is
|
|
42
|
-
// asking for credentials, regardless of what it claims to be.
|
|
43
|
-
const PASSWORD_FIELD_RE = /<input\b[^>]*\btype\s*=\s*["']?password["']?/i;
|
|
44
39
|
// <img>/<script>/<link> tags loading a resource from an absolute, external
|
|
45
40
|
// URL - a common phishing-kit tell is hotlinking the real brand's own logo/
|
|
46
41
|
// CSS/JS instead of copying it. Relative URLs (the common case, same
|
|
@@ -48,13 +43,8 @@ const PASSWORD_FIELD_RE = /<input\b[^>]*\btype\s*=\s*["']?password["']?/i;
|
|
|
48
43
|
// scoped to resource tags, not every <a href> - an outbound link to the
|
|
49
44
|
// real site is normal on all sorts of pages; a resource pulled live from it
|
|
50
45
|
// during page load is not.
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const IDENTITY_TAG_RE = /<(html|link|meta|form)\b[^>]{0,4096}>/gi;
|
|
54
|
-
const OVERSIZED_IDENTITY_TAG_RE = /<(?:html|link|meta|form)\b[^>]{4097}/i;
|
|
55
|
-
const RELATIONSHIP_TAG_RE = /<(a|img|script|link|iframe|frame|source|video|audio|object|embed)\b[^>]{0,4096}>/gi;
|
|
56
|
-
const OVERSIZED_RELATIONSHIP_TAG_RE = /<(?:a|img|script|link|iframe|frame|source|video|audio|object|embed)\b[^>]{4097}/i;
|
|
57
|
-
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']);
|
|
58
48
|
const LANGUAGE_TAG_RE = /^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/i;
|
|
59
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;
|
|
60
50
|
const RESOURCE_LINK_RELS = new Set(['stylesheet', 'icon', 'preload', 'prefetch', 'modulepreload', 'manifest']);
|
|
@@ -62,9 +52,6 @@ const RISKY_DOWNLOAD_EXTENSIONS = new Set([
|
|
|
62
52
|
'7z', 'apk', 'bat', 'cmd', 'dmg', 'docm', 'exe', 'img', 'iso', 'jar', 'js',
|
|
63
53
|
'msi', 'pkg', 'ps1', 'rar', 'scr', 'vbs', 'xlsm', 'zip',
|
|
64
54
|
]);
|
|
65
|
-
const HTML_COMMENT_RE = /<!--[\s\S]*?(?:-->|$)/g;
|
|
66
|
-
const RAW_TEXT_BLOCK_RE = /<(script|style|textarea|template)\b([^>]*)>[\s\S]*?<\/\1\s*>/gi;
|
|
67
|
-
const NON_EXECUTING_RAW_TEXT_BLOCK_RE = /<(style|textarea|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
|
|
68
55
|
function stripWwwPrefix(host) {
|
|
69
56
|
return host.toLowerCase().replace(/^www\./, '');
|
|
70
57
|
}
|
|
@@ -77,24 +64,19 @@ function boundedHtmlText(value, maxLength, ellipsis = false) {
|
|
|
77
64
|
return null;
|
|
78
65
|
if (text.length <= maxLength)
|
|
79
66
|
return text;
|
|
80
|
-
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);
|
|
81
68
|
}
|
|
82
|
-
function extractExternalAssetHosts(
|
|
69
|
+
function extractExternalAssetHosts(analysis, ownDomain, documentUrl) {
|
|
83
70
|
const ownHost = stripWwwPrefix(ownDomain);
|
|
84
71
|
const hosts = new Set();
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
while ((match = ASSET_TAG_RE.exec(html))) {
|
|
88
|
-
const resourceUrl = match[1];
|
|
89
|
-
if (!resourceUrl)
|
|
72
|
+
for (const element of analysis.elements) {
|
|
73
|
+
if (!element.html || !['img', 'script', 'link'].includes(element.name))
|
|
90
74
|
continue;
|
|
91
|
-
const
|
|
92
|
-
|
|
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)
|
|
93
78
|
continue;
|
|
94
|
-
const
|
|
95
|
-
if (!matchedHost || HAS_CONTROL_CHARACTER_RE.test(matchedHost))
|
|
96
|
-
continue;
|
|
97
|
-
const host = stripWwwPrefix(matchedHost);
|
|
79
|
+
const host = stripWwwPrefix(new URL(resource.url).hostname);
|
|
98
80
|
if (host && host !== ownHost)
|
|
99
81
|
hosts.add(host);
|
|
100
82
|
if (hosts.size >= MAX_EXTERNAL_ASSET_HOSTS)
|
|
@@ -102,38 +84,6 @@ function extractExternalAssetHosts(html, ownDomain) {
|
|
|
102
84
|
}
|
|
103
85
|
return [...hosts];
|
|
104
86
|
}
|
|
105
|
-
function extractPageTitle(html) {
|
|
106
|
-
const match = html.match(TITLE_RE);
|
|
107
|
-
if (!match)
|
|
108
|
-
return null;
|
|
109
|
-
return boundedHtmlText(match[1], MAX_TITLE_LENGTH, true);
|
|
110
|
-
}
|
|
111
|
-
function parseAttributes(tag) {
|
|
112
|
-
const start = tag.search(/\s/);
|
|
113
|
-
if (start === -1)
|
|
114
|
-
return new Map();
|
|
115
|
-
const attributes = new Map();
|
|
116
|
-
ATTRIBUTE_RE.lastIndex = 0;
|
|
117
|
-
let match;
|
|
118
|
-
while ((match = ATTRIBUTE_RE.exec(tag.slice(start)))) {
|
|
119
|
-
const name = (match[1] ?? '').toLowerCase();
|
|
120
|
-
if (!name)
|
|
121
|
-
continue;
|
|
122
|
-
if (!attributes.has(name))
|
|
123
|
-
attributes.set(name, match[2] ?? match[3] ?? match[4] ?? '');
|
|
124
|
-
}
|
|
125
|
-
return attributes;
|
|
126
|
-
}
|
|
127
|
-
function markupForTagParsing(html) {
|
|
128
|
-
return html
|
|
129
|
-
.replace(HTML_COMMENT_RE, ' ')
|
|
130
|
-
.replace(RAW_TEXT_BLOCK_RE, '<$1$2>');
|
|
131
|
-
}
|
|
132
|
-
function markupForTrackingIdentifiers(html) {
|
|
133
|
-
return html
|
|
134
|
-
.replace(HTML_COMMENT_RE, ' ')
|
|
135
|
-
.replace(NON_EXECUTING_RAW_TEXT_BLOCK_RE, ' ');
|
|
136
|
-
}
|
|
137
87
|
function normalizeLanguage(value) {
|
|
138
88
|
if (typeof value !== 'string' || value.length > 35 || HAS_CONTROL_CHARACTER_RE.test(value))
|
|
139
89
|
return null;
|
|
@@ -306,10 +256,10 @@ function trackingIdentifiers(html) {
|
|
|
306
256
|
truncated,
|
|
307
257
|
};
|
|
308
258
|
}
|
|
309
|
-
function extractPageRelationships(
|
|
310
|
-
const
|
|
311
|
-
const
|
|
312
|
-
const baseOrigin = new URL(
|
|
259
|
+
function extractPageRelationships(analysis, domain, options = {}) {
|
|
260
|
+
const documentUrl = resolvedBaseUrl(domain, options.baseUrl);
|
|
261
|
+
const baseUrl = resolvedBaseUrl(domain, options.effectiveBaseUrl ?? documentUrl);
|
|
262
|
+
const baseOrigin = options.documentOrigin ?? new URL(documentUrl).origin;
|
|
313
263
|
const resourceKeys = new Set();
|
|
314
264
|
const resourceOrigins = new Set();
|
|
315
265
|
const embeddedOrigins = new Set();
|
|
@@ -322,25 +272,26 @@ function extractPageRelationships(html, domain, options = {}) {
|
|
|
322
272
|
let riskyDownloadCount = 0;
|
|
323
273
|
let tagsExamined = 0;
|
|
324
274
|
let discardedUrls = 0;
|
|
325
|
-
let tagLimitReached =
|
|
275
|
+
let tagLimitReached = analysis.inputLimitReached || analysis.tagLimitReached;
|
|
326
276
|
let resourceOriginLimitReached = false;
|
|
327
277
|
let embeddedOriginLimitReached = false;
|
|
328
278
|
let contactDomainLimitReached = false;
|
|
329
279
|
let downloadOriginLimitReached = false;
|
|
330
280
|
let downloadFileTypeLimitReached = false;
|
|
331
281
|
let perTagLimitReached = false;
|
|
332
|
-
let
|
|
333
|
-
|
|
334
|
-
|
|
282
|
+
let resourceCountLimitReached = false;
|
|
283
|
+
for (const element of analysis.elements) {
|
|
284
|
+
if (!element.html || !RELATIONSHIP_TAGS.has(element.name))
|
|
285
|
+
continue;
|
|
335
286
|
if (tagsExamined >= MAX_RESOURCE_TAGS) {
|
|
336
287
|
tagLimitReached = true;
|
|
337
288
|
break;
|
|
338
289
|
}
|
|
339
290
|
tagsExamined += 1;
|
|
340
|
-
const tagName =
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
291
|
+
const tagName = element.name;
|
|
292
|
+
const attributes = new Map(element.attributes.map(({ name, value }) => [name, value]));
|
|
293
|
+
if (element.attributesTruncated)
|
|
294
|
+
tagLimitReached = true;
|
|
344
295
|
if (tagName === 'a') {
|
|
345
296
|
const href = attributes.get('href');
|
|
346
297
|
const contacts = mailtoDomains(href);
|
|
@@ -388,6 +339,10 @@ function extractPageRelationships(html, domain, options = {}) {
|
|
|
388
339
|
}
|
|
389
340
|
const key = `${reference.type}:${normalized.url}`;
|
|
390
341
|
if (!resourceKeys.has(key)) {
|
|
342
|
+
if (resourceKeys.size >= MAX_RESOURCE_TAGS) {
|
|
343
|
+
resourceCountLimitReached = true;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
391
346
|
resourceKeys.add(key);
|
|
392
347
|
byType[reference.type] += 1;
|
|
393
348
|
}
|
|
@@ -399,10 +354,15 @@ function extractPageRelationships(html, domain, options = {}) {
|
|
|
399
354
|
embeddedOriginLimitReached = true;
|
|
400
355
|
}
|
|
401
356
|
}
|
|
402
|
-
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;
|
|
403
363
|
const truncated = tagLimitReached || resourceOriginLimitReached || embeddedOriginLimitReached
|
|
404
364
|
|| contactDomainLimitReached || downloadOriginLimitReached || downloadFileTypeLimitReached
|
|
405
|
-
|| perTagLimitReached || tracking.truncated;
|
|
365
|
+
|| perTagLimitReached || resourceCountLimitReached || tracking.truncated;
|
|
406
366
|
const limitations = [];
|
|
407
367
|
if (tagLimitReached)
|
|
408
368
|
limitations.push(`Resource parsing reached the ${MAX_RESOURCE_TAGS}-tag or ${MAX_IDENTITY_TAG_LENGTH}-character tag limit.`);
|
|
@@ -418,6 +378,8 @@ function extractPageRelationships(html, domain, options = {}) {
|
|
|
418
378
|
limitations.push(`Only the first ${MAX_DOWNLOAD_FILE_TYPES} risky download file types were retained.`);
|
|
419
379
|
if (perTagLimitReached)
|
|
420
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.`);
|
|
421
383
|
if (tracking.truncated)
|
|
422
384
|
limitations.push(`Only the first ${MAX_TRACKING_IDENTIFIERS} tracking identifiers were retained.`);
|
|
423
385
|
return {
|
|
@@ -425,7 +387,7 @@ function extractPageRelationships(html, domain, options = {}) {
|
|
|
425
387
|
count: resourceKeys.size,
|
|
426
388
|
byType,
|
|
427
389
|
externalOrigins: [...resourceOrigins].sort(),
|
|
428
|
-
truncated: tagLimitReached || resourceOriginLimitReached || perTagLimitReached,
|
|
390
|
+
truncated: tagLimitReached || resourceOriginLimitReached || perTagLimitReached || resourceCountLimitReached,
|
|
429
391
|
},
|
|
430
392
|
embeddedOrigins: [...embeddedOrigins].sort(),
|
|
431
393
|
contactDomains: [...contactDomains].sort(),
|
|
@@ -454,11 +416,13 @@ function metaRefreshTarget(content) {
|
|
|
454
416
|
return match ? (match[1] ?? match[2] ?? match[3] ?? '').trim() : null;
|
|
455
417
|
}
|
|
456
418
|
function extractPageIdentity(html, domain, options = {}) {
|
|
457
|
-
const
|
|
458
|
-
const
|
|
459
|
-
const
|
|
460
|
-
const
|
|
461
|
-
const
|
|
419
|
+
const documentUrl = resolvedBaseUrl(domain, options.baseUrl);
|
|
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;
|
|
423
|
+
const parsedDocument = new URL(documentUrl);
|
|
424
|
+
const baseOrigin = options.documentOrigin ?? parsedDocument.origin;
|
|
425
|
+
const baseUsesHttps = parsedDocument.protocol === 'https:';
|
|
462
426
|
const externalFormOrigins = new Set();
|
|
463
427
|
let documentLanguage = null;
|
|
464
428
|
let canonical = null;
|
|
@@ -474,19 +438,19 @@ function extractPageIdentity(html, domain, options = {}) {
|
|
|
474
438
|
let discardedUrls = 0;
|
|
475
439
|
let formLimitReached = false;
|
|
476
440
|
let originLimitReached = false;
|
|
477
|
-
let tagLimitReached =
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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;
|
|
481
445
|
if (tagsExamined >= MAX_IDENTITY_TAGS) {
|
|
482
446
|
tagLimitReached = true;
|
|
483
447
|
break;
|
|
484
448
|
}
|
|
485
449
|
tagsExamined += 1;
|
|
486
|
-
const tagName =
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
450
|
+
const tagName = element.name;
|
|
451
|
+
const attributes = new Map(element.attributes.map(({ name, value }) => [name, value]));
|
|
452
|
+
if (element.attributesTruncated)
|
|
453
|
+
tagLimitReached = true;
|
|
490
454
|
if (tagName === 'html' && documentLanguage === null) {
|
|
491
455
|
documentLanguage = normalizeLanguage(attributes.get('lang'));
|
|
492
456
|
continue;
|
|
@@ -556,20 +520,23 @@ function extractPageIdentity(html, domain, options = {}) {
|
|
|
556
520
|
const queryOmitted = [canonical, metaRefresh, openGraphUrl].some((item) => item?.queryOmitted);
|
|
557
521
|
const pathTruncated = [canonical, metaRefresh, openGraphUrl].some((item) => item?.pathTruncated);
|
|
558
522
|
const sourceTruncated = options.sourceTruncated === true;
|
|
559
|
-
const relationships = extractPageRelationships(
|
|
523
|
+
const relationships = extractPageRelationships(analysis, domain, { baseUrl: documentUrl, effectiveBaseUrl: baseUrl, documentOrigin: baseOrigin });
|
|
560
524
|
const fingerprints = createPageFingerprints(html, {
|
|
561
|
-
baseUrl,
|
|
525
|
+
baseUrl: documentUrl,
|
|
526
|
+
htmlAnalysis: analysis,
|
|
562
527
|
exactBodyHash: options.exactBodyHash,
|
|
563
528
|
sourceTruncated,
|
|
564
529
|
resources: relationships.resources,
|
|
565
530
|
trackingIdentifiers: relationships.trackingIdentifiers,
|
|
566
531
|
identifiersTruncated: relationships.diagnostics.trackingIdentifiersTruncated === true,
|
|
567
532
|
});
|
|
568
|
-
const truncated = sourceTruncated || tagLimitReached || formLimitReached || originLimitReached
|
|
533
|
+
const truncated = sourceTruncated || baseHrefState === 'invalid' || tagLimitReached || formLimitReached || originLimitReached
|
|
569
534
|
|| pathTruncated || relationships.truncated || fingerprints.truncated;
|
|
570
535
|
const limitations = ['Static HTML metadata only; JavaScript-rendered changes are not evaluated.'];
|
|
571
536
|
if (sourceTruncated)
|
|
572
537
|
limitations.push('Homepage body capture reached its byte limit; identity fields may be incomplete.');
|
|
538
|
+
if (baseHrefState === 'invalid')
|
|
539
|
+
limitations.push('The first bounded document base URL was invalid; relative URL relationships use the response URL and remain partial.');
|
|
573
540
|
if (tagLimitReached)
|
|
574
541
|
limitations.push(`Page identity parsing reached the ${MAX_IDENTITY_TAGS}-tag or ${MAX_IDENTITY_TAG_LENGTH}-character tag limit.`);
|
|
575
542
|
if (formLimitReached)
|
|
@@ -696,34 +663,67 @@ function buildPagePublicationMetadata(analysis, sourceTruncated) {
|
|
|
696
663
|
},
|
|
697
664
|
};
|
|
698
665
|
}
|
|
699
|
-
function
|
|
700
|
-
|
|
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();
|
|
692
|
+
const documentUrl = resolvedBaseUrl(domain, options.baseUrl);
|
|
693
|
+
const htmlAnalysis = options.htmlAnalysis ?? analyzeStaticHtml(html, { baseUrl: documentUrl, includeVisibleText: true });
|
|
694
|
+
const effectiveBaseUrl = htmlAnalysis.effectiveBaseUrl ?? documentUrl;
|
|
695
|
+
const documentOrigin = new URL(documentUrl).origin;
|
|
696
|
+
const pageIdentity = options.includePageIdentity === false ? null : extractPageIdentity(html, domain, {
|
|
697
|
+
...options,
|
|
698
|
+
baseUrl: documentUrl,
|
|
699
|
+
effectiveBaseUrl,
|
|
700
|
+
documentOrigin,
|
|
701
|
+
baseHrefState: htmlAnalysis.baseHrefState,
|
|
702
|
+
htmlAnalysis,
|
|
703
|
+
});
|
|
701
704
|
const includeCredentialSurfaceProfile = pageIdentity && options.includeCredentialSurfaceProfile === true;
|
|
702
705
|
const includeStructuredDataIdentity = pageIdentity && options.includeStructuredDataIdentity !== false;
|
|
703
706
|
const includeTechnologyProfile = pageIdentity && options.includeTechnologyProfile !== false;
|
|
704
707
|
const includeDerivedPageProfiles = Boolean(pageIdentity);
|
|
705
|
-
const baseUrl = resolvedBaseUrl(domain, options.baseUrl);
|
|
706
|
-
const htmlAnalysis = includeCredentialSurfaceProfile || includeStructuredDataIdentity || includeTechnologyProfile || includeDerivedPageProfiles
|
|
707
|
-
? analyzeStaticHtml(html, { baseUrl, includeVisibleText: true })
|
|
708
|
-
: null;
|
|
709
708
|
const pageIdentityOutput = pageIdentity && htmlAnalysis && options.includePublicationMetadata !== false
|
|
710
709
|
? {
|
|
711
710
|
...pageIdentity,
|
|
712
711
|
publicationMetadata: buildPagePublicationMetadata(htmlAnalysis.publicationMetadata, options.sourceTruncated === true),
|
|
713
712
|
}
|
|
714
713
|
: pageIdentity;
|
|
715
|
-
const pageLanguageSignal = detectPageLanguageSignal(html, pageIdentity?.documentLanguage, htmlAnalysis
|
|
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;
|
|
716
717
|
return {
|
|
717
|
-
|
|
718
|
-
|
|
718
|
+
domainSaleSignal,
|
|
719
|
+
pageTitle: htmlAnalysis.title,
|
|
720
|
+
hasPasswordField: htmlAnalysis.forms.categories.password > 0,
|
|
719
721
|
phishingLanguageMatch: pageLanguageSignal?.label ?? null,
|
|
720
722
|
hasExternalFormAction: pageIdentity
|
|
721
723
|
? pageIdentity.forms.externalActionOrigins.length > 0
|
|
722
724
|
: null,
|
|
723
|
-
externalAssetHosts: extractExternalAssetHosts(
|
|
724
|
-
cspMetaPolicy: htmlAnalysis
|
|
725
|
-
? analyzeCspMetaPolicies(htmlAnalysis.cspMetaPolicies, htmlAnalysis.cspMetaLimitReached)
|
|
726
|
-
: null,
|
|
725
|
+
externalAssetHosts: extractExternalAssetHosts(htmlAnalysis, domain, documentUrl),
|
|
726
|
+
cspMetaPolicy: analyzeCspMetaPolicies(htmlAnalysis.cspMetaPolicies, htmlAnalysis.cspMetaLimitReached),
|
|
727
727
|
pageIdentity: pageIdentityOutput,
|
|
728
728
|
credentialSurfaceProfile: includeCredentialSurfaceProfile && htmlAnalysis ? analyzeCredentialSurfaceProfile({
|
|
729
729
|
htmlAnalysis,
|
|
@@ -732,23 +732,26 @@ function extractHtmlSignals(html, domain, options = {}) {
|
|
|
732
732
|
}) : null,
|
|
733
733
|
structuredDataIdentity: includeStructuredDataIdentity && htmlAnalysis ? analyzeStructuredDataIdentity({
|
|
734
734
|
htmlAnalysis,
|
|
735
|
-
baseUrl,
|
|
735
|
+
baseUrl: effectiveBaseUrl,
|
|
736
736
|
observedAt: options.observedAt,
|
|
737
737
|
sourceTruncated: options.sourceTruncated,
|
|
738
738
|
}) : null,
|
|
739
|
-
technologyProfile: includeTechnologyProfile && htmlAnalysis ? analyzeWebsiteTechnology({
|
|
739
|
+
technologyProfile: includeTechnologyProfile && htmlAnalysis ? await analyzeWebsiteTechnology({
|
|
740
740
|
htmlAnalysis,
|
|
741
741
|
generator: pageIdentity.generator,
|
|
742
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
742
743
|
httpServer: options.httpServer,
|
|
743
744
|
responseHeaders: options.responseHeaders,
|
|
744
745
|
resourceOrigins: pageIdentity.resources.externalOrigins,
|
|
746
|
+
effectiveBaseUrl,
|
|
747
|
+
documentOrigin,
|
|
745
748
|
observedAt: options.observedAt,
|
|
746
749
|
sourceTruncated: options.sourceTruncated,
|
|
747
750
|
}) : null,
|
|
748
751
|
pageRoleProfile: includeDerivedPageProfiles && htmlAnalysis ? analyzePageRole({
|
|
749
752
|
htmlAnalysis,
|
|
750
|
-
pageTitle:
|
|
751
|
-
activityStatus: options.activityStatus,
|
|
753
|
+
pageTitle: htmlAnalysis.title,
|
|
754
|
+
activityStatus: domainSaleSignal ? 'parked' : options.activityStatus,
|
|
752
755
|
observedAt: options.observedAt,
|
|
753
756
|
sourceTruncated: options.sourceTruncated,
|
|
754
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
|
}
|
|
@@ -45,14 +45,30 @@ function buildLocalGeoIpDatabase(value) {
|
|
|
45
45
|
}
|
|
46
46
|
const truncated = rows.length > MAX_GEOIP_RECORDS;
|
|
47
47
|
const normalized = rows.slice(0, MAX_GEOIP_RECORDS).map(normalizeGeoIpEntry);
|
|
48
|
-
const
|
|
49
|
-
|
|
48
|
+
const byNetwork = new Map();
|
|
49
|
+
const sameAttribution = (left, right) => (left.countryCode === right.countryCode
|
|
50
|
+
&& left.region === right.region
|
|
51
|
+
&& left.city === right.city
|
|
52
|
+
&& left.asn === right.asn
|
|
53
|
+
&& left.asName === right.asName);
|
|
54
|
+
for (const item of normalized) {
|
|
55
|
+
if (!item)
|
|
56
|
+
continue;
|
|
57
|
+
const existing = byNetwork.get(item.network);
|
|
58
|
+
if (existing && !sameAttribution(existing, item)) {
|
|
59
|
+
throw new TypeError(`A local GeoIP database must not assign conflicting evidence to canonical prefix ${item.network}.`);
|
|
60
|
+
}
|
|
61
|
+
if (!existing)
|
|
62
|
+
byNetwork.set(item.network, item);
|
|
63
|
+
}
|
|
64
|
+
const records = [...byNetwork.values()]
|
|
65
|
+
.sort((left, right) => right.parsedNetwork.length - left.parsedNetwork.length || left.network.localeCompare(right.network));
|
|
50
66
|
return Object.freeze({
|
|
51
67
|
sourceLabel,
|
|
52
68
|
databaseVersion,
|
|
53
69
|
license,
|
|
54
70
|
records: Object.freeze(records),
|
|
55
|
-
rejectedCount: normalized.
|
|
71
|
+
rejectedCount: normalized.filter((item) => item === null).length + Math.max(0, rows.length - MAX_GEOIP_RECORDS),
|
|
56
72
|
truncated,
|
|
57
73
|
});
|
|
58
74
|
}
|