@slicedearth/whoisleuth-cli 1.32.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/LICENSE +661 -0
- package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
- package/NOTICE +76 -0
- package/README.md +84 -0
- package/TRADEMARKS.md +41 -0
- package/bin/whoisleuth.mjs +35 -0
- package/cli/archive-inspect.mjs +198 -0
- package/cli/arguments.mjs +1022 -0
- package/cli/artifact-verify.mjs +210 -0
- package/cli/bulk-checkpoint.mjs +218 -0
- package/cli/bulk-command-runner.mjs +109 -0
- package/cli/bulk-output.mjs +112 -0
- package/cli/bulk.mjs +133 -0
- package/cli/compare.mjs +238 -0
- package/cli/completion.mjs +175 -0
- package/cli/csv.mjs +1 -0
- package/cli/discover.mjs +50 -0
- package/cli/discovery-command-runner.mjs +36 -0
- package/cli/discovery-observation-snapshot.mjs +315 -0
- package/cli/discovery-scan-command-runner.mjs +141 -0
- package/cli/discovery-scan.mjs +325 -0
- package/cli/discovery-snapshot.mjs +133 -0
- package/cli/discovery-workflow.mjs +73 -0
- package/cli/doctor.mjs +137 -0
- package/cli/errors.mjs +21 -0
- package/cli/evidence-command-arguments.mjs +145 -0
- package/cli/evidence-command-runner.mjs +106 -0
- package/cli/evidence-signing.mjs +215 -0
- package/cli/exit-codes.mjs +9 -0
- package/cli/export-evidence.mjs +28 -0
- package/cli/formatters/evidence-report.mjs +3 -0
- package/cli/formatters/html.mjs +69 -0
- package/cli/formatters/json.mjs +135 -0
- package/cli/formatters/markdown.mjs +3 -0
- package/cli/formatters/terminal.mjs +658 -0
- package/cli/http.mjs +27 -0
- package/cli/lookup-command-runner.mjs +87 -0
- package/cli/lookup-diff.mjs +171 -0
- package/cli/mail-review.mjs +204 -0
- package/cli/manual.mjs +34 -0
- package/cli/network-command-runner.mjs +88 -0
- package/cli/output-file.mjs +97 -0
- package/cli/page-compare.mjs +123 -0
- package/cli/posture.mjs +20 -0
- package/cli/progress-events.mjs +53 -0
- package/cli/progress.mjs +56 -0
- package/cli/registry-access.mjs +3 -0
- package/cli/registry-support.mjs +120 -0
- package/cli/risk-calibration.mjs +294 -0
- package/cli/runner-types.mjs +1 -0
- package/cli/runner.mjs +726 -0
- package/cli/saved-lookup.mjs +87 -0
- package/cli/source-reliability.mjs +727 -0
- package/cli/strict-exit.mjs +55 -0
- package/cli/terminal-presentation.mjs +147 -0
- package/docs/cli.md +827 -0
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
- package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
- package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
- package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
- package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
- package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
- package/frontend/src/lib/analysis/bulk-sort.js +49 -0
- package/frontend/src/lib/analysis/campaign-model.js +265 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
- package/frontend/src/lib/analysis/case-migration-model.js +248 -0
- package/frontend/src/lib/analysis/case-model.js +5 -0
- package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
- package/frontend/src/lib/analysis/case-record-core.js +232 -0
- package/frontend/src/lib/analysis/case-record-model.js +7 -0
- package/frontend/src/lib/analysis/case-record-operations.js +224 -0
- package/frontend/src/lib/analysis/case-response-model.js +640 -0
- package/frontend/src/lib/analysis/case-response-packet.js +573 -0
- package/frontend/src/lib/analysis/case-storage-model.js +93 -0
- package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
- package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
- package/frontend/src/lib/analysis/http-summary.js +165 -0
- package/frontend/src/lib/analysis/investigation-guide.js +500 -0
- package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
- package/frontend/src/lib/analysis/page-baseline.js +272 -0
- package/frontend/src/lib/analysis/page-similarity.js +179 -0
- package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
- package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
- package/frontend/src/lib/analysis/scoring.js +135 -0
- package/frontend/src/lib/analysis/shortlist-model.js +209 -0
- package/frontend/src/lib/analysis/utils.js +265 -0
- package/frontend/src/lib/analysis/watchlist-history.js +643 -0
- package/frontend/src/lib/analysis/watchlist-store.js +127 -0
- package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
- package/frontend/src/lib/analysis/workspace-archive.js +472 -0
- package/lib/abort.mjs +26 -0
- package/lib/abusech-auth.mjs +19 -0
- package/lib/availability.mjs +716 -0
- package/lib/bounded-contract-normalizers.mjs +61 -0
- package/lib/browser-library-profile.mjs +310 -0
- package/lib/bulk-comparison-evidence.mjs +83 -0
- package/lib/classify.mjs +93 -0
- package/lib/client-behavior-profile.mjs +83 -0
- package/lib/credential-surface-profile.mjs +66 -0
- package/lib/csv.mjs +23 -0
- package/lib/ct-query.mjs +27 -0
- package/lib/ct-search.mjs +393 -0
- package/lib/distributed-operation-budget.mjs +461 -0
- package/lib/dns-delegation-health.mjs +344 -0
- package/lib/dns-intelligence.mjs +739 -0
- package/lib/dns-mx.mjs +24 -0
- package/lib/dns-resolver-selection.mjs +33 -0
- package/lib/domain-posture-analysis.mjs +254 -0
- package/lib/domain-posture-parsers.mjs +358 -0
- package/lib/domain-posture.mjs +745 -0
- package/lib/error-detail.mjs +10 -0
- package/lib/evidence-export.mjs +431 -0
- package/lib/evidence-report-markdown.mjs +71 -0
- package/lib/evidence-report.mjs +391 -0
- package/lib/external-intelligence-risk.mjs +108 -0
- package/lib/favicon.mjs +146 -0
- package/lib/feature-policy.mjs +70 -0
- package/lib/generated/retire-browser-catalog.mjs +8724 -0
- package/lib/generated/unicode-confusables-17.mjs +93 -0
- package/lib/hostname.mjs +38 -0
- package/lib/html-signals.mjs +680 -0
- package/lib/http-intelligence.mjs +232 -0
- package/lib/idn-confusable-policy.mjs +86 -0
- package/lib/idn-confusables.mjs +465 -0
- package/lib/lookup-cache.mjs +130 -0
- package/lib/lookup-progress.mjs +291 -0
- package/lib/lookup-source-progress.mjs +109 -0
- package/lib/lookup-threat-provider-inventory.mjs +13 -0
- package/lib/lookup.mjs +592 -0
- package/lib/observation.mjs +114 -0
- package/lib/observed-network-context.mjs +355 -0
- package/lib/operation-budget.mjs +489 -0
- package/lib/page-fingerprints.mjs +481 -0
- package/lib/page-role-profile.mjs +117 -0
- package/lib/perceptual-hash.mjs +451 -0
- package/lib/rdap-attempts.mjs +34 -0
- package/lib/rdap-bootstrap.mjs +173 -0
- package/lib/rdap-capabilities.mjs +292 -0
- package/lib/rdap-client.mjs +125 -0
- package/lib/rdap-normalization.mjs +691 -0
- package/lib/rdap-registrar.mjs +196 -0
- package/lib/rdap-transport.mjs +39 -0
- package/lib/rdap-types.mjs +1 -0
- package/lib/rdap-validation.mjs +94 -0
- package/lib/rdap.mjs +16 -0
- package/lib/registry-access.mjs +32 -0
- package/lib/registry-capabilities.mjs +117 -0
- package/lib/registry-capability-catalogue.mjs +1206 -0
- package/lib/registry-comparison.mjs +318 -0
- package/lib/registry-dates.mjs +190 -0
- package/lib/registry-insights.mjs +325 -0
- package/lib/response-policy.mjs +256 -0
- package/lib/risk-scoring.mjs +164 -0
- package/lib/safe-fetch.mjs +446 -0
- package/lib/security-txt.mjs +359 -0
- package/lib/service-binding-dns.mjs +608 -0
- package/lib/sslbl-certificates.generated.mjs +143 -0
- package/lib/sslbl-intelligence.mjs +241 -0
- package/lib/static-html-analysis.mjs +340 -0
- package/lib/structured-data-identity.mjs +323 -0
- package/lib/threat-intelligence-contract.mjs +5 -0
- package/lib/threat-intelligence-definition-registry.mjs +34 -0
- package/lib/threat-intelligence-fixtures.mjs +82 -0
- package/lib/threat-intelligence-manifests.mjs +512 -0
- package/lib/threat-intelligence-runtime.mjs +662 -0
- package/lib/threat-intelligence-targets.mjs +168 -0
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +307 -0
- package/lib/tls-intelligence.mjs +814 -0
- package/lib/typosquat-generator.mjs +884 -0
- package/lib/urlhaus-intelligence.mjs +361 -0
- package/lib/urlscan-intelligence.mjs +349 -0
- package/lib/website-security-posture.mjs +325 -0
- package/lib/website-technology.mjs +437 -0
- package/lib/whois-authority.mjs +98 -0
- package/lib/whois-chain.mjs +261 -0
- package/lib/whois-contacts.mjs +163 -0
- package/lib/whois-contracts.mjs +3 -0
- package/lib/whois-normalization.mjs +27 -0
- package/lib/whois-transport.mjs +100 -0
- package/lib/whois-values.mjs +92 -0
- package/lib/whois.mjs +1187 -0
- package/package.json +55 -0
- package/third-party-notices.txt +5571 -0
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
// Bounded signals extracted from a domain's already-fetched homepage HTML (see
|
|
2
|
+
// fetchHomepage in availability.mts), with no extra network request. Existing
|
|
3
|
+
// conservative field extractors share one standards-compliant static parser
|
|
4
|
+
// pass for technology, browser-library, and structured-data projections. None
|
|
5
|
+
// of these helpers creates a browser DOM or executes page JavaScript.
|
|
6
|
+
import { isIP } from 'node:net';
|
|
7
|
+
import { domainToASCII } from 'node:url';
|
|
8
|
+
import { analyzeCredentialSurfaceProfile } from "./credential-surface-profile.mjs";
|
|
9
|
+
import { analyzeClientBehavior } from "./client-behavior-profile.mjs";
|
|
10
|
+
import { createObservation } from "./observation.mjs";
|
|
11
|
+
import { createPageFingerprints } from "./page-fingerprints.mjs";
|
|
12
|
+
import { analyzePageRole } from "./page-role-profile.mjs";
|
|
13
|
+
import { analyzeStaticHtml } from "./static-html-analysis.mjs";
|
|
14
|
+
import { analyzeStructuredDataIdentity } from "./structured-data-identity.mjs";
|
|
15
|
+
import { analyzeWebsiteTechnology } from "./website-technology.mjs";
|
|
16
|
+
const MAX_TITLE_LENGTH = 200;
|
|
17
|
+
const MAX_PHISHING_MATCH_LENGTH = 200;
|
|
18
|
+
const MAX_EXTERNAL_ASSET_HOSTS = 20;
|
|
19
|
+
const MAX_IDENTITY_TAGS = 512;
|
|
20
|
+
const MAX_IDENTITY_TAG_LENGTH = 4096;
|
|
21
|
+
const MAX_IDENTITY_TEXT = 200;
|
|
22
|
+
const MAX_GENERATOR_LENGTH = 120;
|
|
23
|
+
const MAX_URL_INPUT_LENGTH = 4096;
|
|
24
|
+
const MAX_URL_OUTPUT_LENGTH = 2048;
|
|
25
|
+
const MAX_FORMS = 50;
|
|
26
|
+
const MAX_FORM_ACTION_ORIGINS = 10;
|
|
27
|
+
const MAX_RESOURCE_TAGS = 1024;
|
|
28
|
+
const MAX_RESOURCE_ORIGINS = 30;
|
|
29
|
+
const MAX_EMBEDDED_ORIGINS = 20;
|
|
30
|
+
const MAX_CONTACT_DOMAINS = 20;
|
|
31
|
+
const MAX_DOWNLOAD_ORIGINS = 20;
|
|
32
|
+
const MAX_DOWNLOAD_FILE_TYPES = 20;
|
|
33
|
+
const MAX_TRACKING_IDENTIFIERS = 30;
|
|
34
|
+
const MAX_URLS_PER_TAG = 20;
|
|
35
|
+
const PAGE_IDENTITY_VERSION = 3;
|
|
36
|
+
const CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/g;
|
|
37
|
+
const HAS_CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/;
|
|
38
|
+
const TITLE_RE = /<title[^>]*>([\s\S]*?)<\/title>/i;
|
|
39
|
+
// A password input is the single strongest static-HTML tell that a page is
|
|
40
|
+
// asking for credentials, regardless of what it claims to be.
|
|
41
|
+
const PASSWORD_FIELD_RE = /<input\b[^>]*\btype\s*=\s*["']?password["']?/i;
|
|
42
|
+
// Common urgency/social-engineering phrasing used to pressure a visitor
|
|
43
|
+
// into entering credentials - not exhaustive, and legitimate sites
|
|
44
|
+
// occasionally use similar language too, so this is one signal among
|
|
45
|
+
// several, not a verdict on its own (same framing as FOR_SALE_TEXT_RE in
|
|
46
|
+
// availability.js).
|
|
47
|
+
const PHISHING_LANGUAGE_RE = /(verify your account|confirm your identity|unusual (?:sign-?in|login) activity|account has been (?:suspended|limited|locked|restricted)|your account will be (?:suspended|closed|locked|terminated)|click here to (?:verify|confirm|update|restore)|security alert|immediate action required|re-?activate your account|unauthorized access detected|update your (?:payment|billing) (?:information|details)|confirm your password|your password (?:has expired|will expire soon)|connect your wallet|enter your (?:seed|recovery) phrase|verify your (?:seed|recovery) phrase|enter your private key)/i;
|
|
48
|
+
// <img>/<script>/<link> tags loading a resource from an absolute, external
|
|
49
|
+
// URL - a common phishing-kit tell is hotlinking the real brand's own logo/
|
|
50
|
+
// CSS/JS instead of copying it. Relative URLs (the common case, same
|
|
51
|
+
// origin) are skipped since they have no host to extract. Deliberately
|
|
52
|
+
// scoped to resource tags, not every <a href> - an outbound link to the
|
|
53
|
+
// real site is normal on all sorts of pages; a resource pulled live from it
|
|
54
|
+
// during page load is not.
|
|
55
|
+
const ASSET_TAG_RE = /<(?:img|script|link)\b[^>]*?\b(?:src|href)\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
|
56
|
+
const ABSOLUTE_URL_HOST_RE = /^(?:https?:)?\/\/([^/]+)/i;
|
|
57
|
+
const IDENTITY_TAG_RE = /<(html|link|meta|form)\b[^>]{0,4096}>/gi;
|
|
58
|
+
const OVERSIZED_IDENTITY_TAG_RE = /<(?:html|link|meta|form)\b[^>]{4097}/i;
|
|
59
|
+
const RELATIONSHIP_TAG_RE = /<(a|img|script|link|iframe|frame|source|video|audio|object|embed)\b[^>]{0,4096}>/gi;
|
|
60
|
+
const OVERSIZED_RELATIONSHIP_TAG_RE = /<(?:a|img|script|link|iframe|frame|source|video|audio|object|embed)\b[^>]{4097}/i;
|
|
61
|
+
const ATTRIBUTE_RE = /([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
62
|
+
const LANGUAGE_TAG_RE = /^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/i;
|
|
63
|
+
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;
|
|
64
|
+
const RESOURCE_LINK_RELS = new Set(['stylesheet', 'icon', 'preload', 'prefetch', 'modulepreload', 'manifest']);
|
|
65
|
+
const RISKY_DOWNLOAD_EXTENSIONS = new Set([
|
|
66
|
+
'7z', 'apk', 'bat', 'cmd', 'dmg', 'docm', 'exe', 'img', 'iso', 'jar', 'js',
|
|
67
|
+
'msi', 'pkg', 'ps1', 'rar', 'scr', 'vbs', 'xlsm', 'zip',
|
|
68
|
+
]);
|
|
69
|
+
const HTML_COMMENT_RE = /<!--[\s\S]*?(?:-->|$)/g;
|
|
70
|
+
const RAW_TEXT_BLOCK_RE = /<(script|style|textarea|template)\b([^>]*)>[\s\S]*?<\/\1\s*>/gi;
|
|
71
|
+
const NON_EXECUTING_RAW_TEXT_BLOCK_RE = /<(style|textarea|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
|
|
72
|
+
function stripWwwPrefix(host) {
|
|
73
|
+
return host.toLowerCase().replace(/^www\./, '');
|
|
74
|
+
}
|
|
75
|
+
function boundedHtmlText(value, maxLength, ellipsis = false) {
|
|
76
|
+
const text = String(value == null ? '' : value)
|
|
77
|
+
.replace(CONTROL_CHARACTER_RE, ' ')
|
|
78
|
+
.replace(/\s+/g, ' ')
|
|
79
|
+
.trim();
|
|
80
|
+
if (!text)
|
|
81
|
+
return null;
|
|
82
|
+
if (text.length <= maxLength)
|
|
83
|
+
return text;
|
|
84
|
+
return ellipsis ? `${text.slice(0, maxLength)}…` : text.slice(0, maxLength);
|
|
85
|
+
}
|
|
86
|
+
function extractExternalAssetHosts(html, ownDomain) {
|
|
87
|
+
const ownHost = stripWwwPrefix(ownDomain);
|
|
88
|
+
const hosts = new Set();
|
|
89
|
+
let match;
|
|
90
|
+
ASSET_TAG_RE.lastIndex = 0;
|
|
91
|
+
while ((match = ASSET_TAG_RE.exec(html))) {
|
|
92
|
+
const resourceUrl = match[1];
|
|
93
|
+
if (!resourceUrl)
|
|
94
|
+
continue;
|
|
95
|
+
const hostMatch = resourceUrl.match(ABSOLUTE_URL_HOST_RE);
|
|
96
|
+
if (!hostMatch)
|
|
97
|
+
continue;
|
|
98
|
+
const matchedHost = hostMatch[1];
|
|
99
|
+
if (!matchedHost || HAS_CONTROL_CHARACTER_RE.test(matchedHost))
|
|
100
|
+
continue;
|
|
101
|
+
const host = stripWwwPrefix(matchedHost);
|
|
102
|
+
if (host && host !== ownHost)
|
|
103
|
+
hosts.add(host);
|
|
104
|
+
if (hosts.size >= MAX_EXTERNAL_ASSET_HOSTS)
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
return [...hosts];
|
|
108
|
+
}
|
|
109
|
+
function extractPageTitle(html) {
|
|
110
|
+
const match = html.match(TITLE_RE);
|
|
111
|
+
if (!match)
|
|
112
|
+
return null;
|
|
113
|
+
return boundedHtmlText(match[1], MAX_TITLE_LENGTH, true);
|
|
114
|
+
}
|
|
115
|
+
function parseAttributes(tag) {
|
|
116
|
+
const start = tag.search(/\s/);
|
|
117
|
+
if (start === -1)
|
|
118
|
+
return new Map();
|
|
119
|
+
const attributes = new Map();
|
|
120
|
+
ATTRIBUTE_RE.lastIndex = 0;
|
|
121
|
+
let match;
|
|
122
|
+
while ((match = ATTRIBUTE_RE.exec(tag.slice(start)))) {
|
|
123
|
+
const name = (match[1] ?? '').toLowerCase();
|
|
124
|
+
if (!name)
|
|
125
|
+
continue;
|
|
126
|
+
if (!attributes.has(name))
|
|
127
|
+
attributes.set(name, match[2] ?? match[3] ?? match[4] ?? '');
|
|
128
|
+
}
|
|
129
|
+
return attributes;
|
|
130
|
+
}
|
|
131
|
+
function markupForTagParsing(html) {
|
|
132
|
+
return html
|
|
133
|
+
.replace(HTML_COMMENT_RE, ' ')
|
|
134
|
+
.replace(RAW_TEXT_BLOCK_RE, '<$1$2>');
|
|
135
|
+
}
|
|
136
|
+
function markupForTrackingIdentifiers(html) {
|
|
137
|
+
return html
|
|
138
|
+
.replace(HTML_COMMENT_RE, ' ')
|
|
139
|
+
.replace(NON_EXECUTING_RAW_TEXT_BLOCK_RE, ' ');
|
|
140
|
+
}
|
|
141
|
+
function normalizeLanguage(value) {
|
|
142
|
+
if (typeof value !== 'string' || value.length > 35 || HAS_CONTROL_CHARACTER_RE.test(value))
|
|
143
|
+
return null;
|
|
144
|
+
const normalized = value.trim();
|
|
145
|
+
return LANGUAGE_TAG_RE.test(normalized) ? normalized.toLowerCase() : null;
|
|
146
|
+
}
|
|
147
|
+
function normalizeIdentityUrl(value, baseUrl) {
|
|
148
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_URL_INPUT_LENGTH || HAS_CONTROL_CHARACTER_RE.test(value))
|
|
149
|
+
return null;
|
|
150
|
+
const normalized = value.trim();
|
|
151
|
+
if (!normalized)
|
|
152
|
+
return null;
|
|
153
|
+
try {
|
|
154
|
+
const parsed = new URL(normalized, baseUrl);
|
|
155
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || !parsed.hostname)
|
|
156
|
+
return null;
|
|
157
|
+
const queryOmitted = Boolean(parsed.search || parsed.hash);
|
|
158
|
+
parsed.search = '';
|
|
159
|
+
parsed.hash = '';
|
|
160
|
+
let url = parsed.toString();
|
|
161
|
+
let pathTruncated = false;
|
|
162
|
+
if (url.length > MAX_URL_OUTPUT_LENGTH) {
|
|
163
|
+
parsed.pathname = '/';
|
|
164
|
+
url = parsed.toString();
|
|
165
|
+
pathTruncated = true;
|
|
166
|
+
}
|
|
167
|
+
if (url.length > MAX_URL_OUTPUT_LENGTH)
|
|
168
|
+
return null;
|
|
169
|
+
return { url, queryOmitted, pathTruncated };
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function resolvedBaseUrl(domain, suppliedBaseUrl) {
|
|
176
|
+
const safeFallback = normalizeIdentityUrl(`https://${domain}/`, 'https://invalid.example/');
|
|
177
|
+
const fallbackBase = safeFallback ? safeFallback.url : 'https://invalid.example/';
|
|
178
|
+
const suppliedBase = normalizeIdentityUrl(suppliedBaseUrl, fallbackBase);
|
|
179
|
+
return suppliedBase ? suppliedBase.url : fallbackBase;
|
|
180
|
+
}
|
|
181
|
+
function addBounded(set, value, limit) {
|
|
182
|
+
if (set.has(value))
|
|
183
|
+
return false;
|
|
184
|
+
if (set.size >= limit)
|
|
185
|
+
return true;
|
|
186
|
+
set.add(value);
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
function normalizeContactDomain(value) {
|
|
190
|
+
if (typeof value !== 'string' || !value || value.length > 253 || HAS_CONTROL_CHARACTER_RE.test(value))
|
|
191
|
+
return null;
|
|
192
|
+
const ascii = domainToASCII(value.trim().replace(/\.$/, '').toLowerCase());
|
|
193
|
+
if (!ascii || isIP(ascii) || !HOSTNAME_RE.test(ascii))
|
|
194
|
+
return null;
|
|
195
|
+
return ascii;
|
|
196
|
+
}
|
|
197
|
+
function mailtoDomains(value) {
|
|
198
|
+
if (typeof value !== 'string' || value.length > MAX_URL_INPUT_LENGTH || HAS_CONTROL_CHARACTER_RE.test(value) || !/^mailto:/i.test(value)) {
|
|
199
|
+
return { domains: [], truncated: false };
|
|
200
|
+
}
|
|
201
|
+
const recipients = (value.slice(7).split('?', 1)[0] ?? '').split(',');
|
|
202
|
+
const truncated = recipients.length > MAX_URLS_PER_TAG;
|
|
203
|
+
const domains = new Set();
|
|
204
|
+
for (const recipient of recipients.slice(0, MAX_URLS_PER_TAG)) {
|
|
205
|
+
const at = recipient.lastIndexOf('@');
|
|
206
|
+
if (at <= 0)
|
|
207
|
+
continue;
|
|
208
|
+
const domain = normalizeContactDomain(recipient.slice(at + 1));
|
|
209
|
+
if (domain)
|
|
210
|
+
domains.add(domain);
|
|
211
|
+
}
|
|
212
|
+
return { domains: [...domains].sort(), truncated };
|
|
213
|
+
}
|
|
214
|
+
function srcsetUrls(value) {
|
|
215
|
+
if (typeof value !== 'string' || value.length > MAX_URL_INPUT_LENGTH || HAS_CONTROL_CHARACTER_RE.test(value)) {
|
|
216
|
+
return { urls: [], truncated: false };
|
|
217
|
+
}
|
|
218
|
+
// Data URLs may contain unescaped commas, so a lightweight comma splitter
|
|
219
|
+
// cannot distinguish one inline resource from several candidates. Skip the
|
|
220
|
+
// whole attribute and disclose the incomplete parse rather than inventing
|
|
221
|
+
// same-origin resources from fragments of encoded data.
|
|
222
|
+
if (/(?:^|,)\s*data:/i.test(value))
|
|
223
|
+
return { urls: [], truncated: true };
|
|
224
|
+
const candidates = value.split(',');
|
|
225
|
+
return {
|
|
226
|
+
urls: candidates
|
|
227
|
+
.slice(0, MAX_URLS_PER_TAG)
|
|
228
|
+
.map((candidate) => candidate.trim().split(/\s+/, 1)[0] ?? '')
|
|
229
|
+
.filter(Boolean),
|
|
230
|
+
truncated: candidates.length > MAX_URLS_PER_TAG,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function resourceReferences(tagName, attributes) {
|
|
234
|
+
const references = [];
|
|
235
|
+
const pushAttribute = (type, name) => {
|
|
236
|
+
const value = attributes.get(name);
|
|
237
|
+
if (value !== undefined)
|
|
238
|
+
references.push({ type, value });
|
|
239
|
+
};
|
|
240
|
+
if (tagName === 'img') {
|
|
241
|
+
pushAttribute('image', 'src');
|
|
242
|
+
const srcset = srcsetUrls(attributes.get('srcset'));
|
|
243
|
+
references.push(...srcset.urls.map((value) => ({ type: 'image', value })));
|
|
244
|
+
return { references, truncated: srcset.truncated };
|
|
245
|
+
}
|
|
246
|
+
if (tagName === 'script')
|
|
247
|
+
pushAttribute('script', 'src');
|
|
248
|
+
if (tagName === 'link') {
|
|
249
|
+
const rels = String(attributes.get('rel') || '').toLowerCase().split(/\s+/).filter(Boolean);
|
|
250
|
+
if (rels.some((rel) => RESOURCE_LINK_RELS.has(rel))) {
|
|
251
|
+
pushAttribute(rels.includes('stylesheet') ? 'stylesheet' : 'link', 'href');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (['iframe', 'frame'].includes(tagName))
|
|
255
|
+
pushAttribute('frame', 'src');
|
|
256
|
+
if (tagName === 'source') {
|
|
257
|
+
pushAttribute('media', 'src');
|
|
258
|
+
const srcset = srcsetUrls(attributes.get('srcset'));
|
|
259
|
+
references.push(...srcset.urls.map((value) => ({ type: 'media', value })));
|
|
260
|
+
return { references, truncated: srcset.truncated };
|
|
261
|
+
}
|
|
262
|
+
if (['video', 'audio'].includes(tagName))
|
|
263
|
+
pushAttribute('media', 'src');
|
|
264
|
+
if (tagName === 'video')
|
|
265
|
+
pushAttribute('image', 'poster');
|
|
266
|
+
if (tagName === 'object')
|
|
267
|
+
pushAttribute('object', 'data');
|
|
268
|
+
if (tagName === 'embed')
|
|
269
|
+
pushAttribute('object', 'src');
|
|
270
|
+
return { references, truncated: false };
|
|
271
|
+
}
|
|
272
|
+
function downloadExtension(url) {
|
|
273
|
+
try {
|
|
274
|
+
const filename = new URL(url).pathname.split('/').pop() || '';
|
|
275
|
+
const match = filename.toLowerCase().match(/\.([a-z0-9]{1,8})$/);
|
|
276
|
+
return match?.[1] ?? null;
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const TRACKING_PATTERNS = [
|
|
283
|
+
{ type: 'tag-container', regex: /\b(GTM-[A-Z0-9]{4,12})\b/gi },
|
|
284
|
+
{ type: 'analytics-property', regex: /\b(G-[A-Z0-9]{10,16})\b/gi },
|
|
285
|
+
{ type: 'legacy-analytics-property', regex: /\b(UA-\d{4,12}-\d{1,4})\b/gi },
|
|
286
|
+
{ type: 'advertising-property', regex: /\b(AW-\d{5,20})\b/gi },
|
|
287
|
+
];
|
|
288
|
+
function trackingIdentifiers(html) {
|
|
289
|
+
const identifiers = new Map();
|
|
290
|
+
let truncated = false;
|
|
291
|
+
for (const pattern of TRACKING_PATTERNS) {
|
|
292
|
+
pattern.regex.lastIndex = 0;
|
|
293
|
+
let match;
|
|
294
|
+
while ((match = pattern.regex.exec(html))) {
|
|
295
|
+
const value = (match[1] ?? '').toUpperCase();
|
|
296
|
+
if (!value)
|
|
297
|
+
continue;
|
|
298
|
+
const key = `${pattern.type}:${value}`;
|
|
299
|
+
if (!identifiers.has(key)) {
|
|
300
|
+
if (identifiers.size >= MAX_TRACKING_IDENTIFIERS) {
|
|
301
|
+
truncated = true;
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
identifiers.set(key, { type: pattern.type, value });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
values: [...identifiers.values()].sort((left, right) => left.type.localeCompare(right.type) || left.value.localeCompare(right.value)),
|
|
310
|
+
truncated,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function extractPageRelationships(html, domain, options = {}) {
|
|
314
|
+
const baseUrl = resolvedBaseUrl(domain, options.baseUrl);
|
|
315
|
+
const markup = typeof options.tagMarkup === 'string' ? options.tagMarkup : markupForTagParsing(html);
|
|
316
|
+
const baseOrigin = new URL(baseUrl).origin;
|
|
317
|
+
const resourceKeys = new Set();
|
|
318
|
+
const resourceOrigins = new Set();
|
|
319
|
+
const embeddedOrigins = new Set();
|
|
320
|
+
const contactDomains = new Set();
|
|
321
|
+
const downloadOrigins = new Set();
|
|
322
|
+
const downloadFileTypes = new Set();
|
|
323
|
+
const byType = { image: 0, script: 0, stylesheet: 0, link: 0, frame: 0, media: 0, object: 0 };
|
|
324
|
+
let downloadCount = 0;
|
|
325
|
+
let explicitDownloadCount = 0;
|
|
326
|
+
let riskyDownloadCount = 0;
|
|
327
|
+
let tagsExamined = 0;
|
|
328
|
+
let discardedUrls = 0;
|
|
329
|
+
let tagLimitReached = OVERSIZED_RELATIONSHIP_TAG_RE.test(markup);
|
|
330
|
+
let resourceOriginLimitReached = false;
|
|
331
|
+
let embeddedOriginLimitReached = false;
|
|
332
|
+
let contactDomainLimitReached = false;
|
|
333
|
+
let downloadOriginLimitReached = false;
|
|
334
|
+
let downloadFileTypeLimitReached = false;
|
|
335
|
+
let perTagLimitReached = false;
|
|
336
|
+
let match;
|
|
337
|
+
RELATIONSHIP_TAG_RE.lastIndex = 0;
|
|
338
|
+
while ((match = RELATIONSHIP_TAG_RE.exec(markup))) {
|
|
339
|
+
if (tagsExamined >= MAX_RESOURCE_TAGS) {
|
|
340
|
+
tagLimitReached = true;
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
tagsExamined += 1;
|
|
344
|
+
const tagName = (match[1] ?? '').toLowerCase();
|
|
345
|
+
if (!tagName)
|
|
346
|
+
continue;
|
|
347
|
+
const attributes = parseAttributes(match[0]);
|
|
348
|
+
if (tagName === 'a') {
|
|
349
|
+
const href = attributes.get('href');
|
|
350
|
+
const contacts = mailtoDomains(href);
|
|
351
|
+
if (contacts.truncated)
|
|
352
|
+
perTagLimitReached = true;
|
|
353
|
+
for (const contactDomain of contacts.domains) {
|
|
354
|
+
if (addBounded(contactDomains, contactDomain, MAX_CONTACT_DOMAINS))
|
|
355
|
+
contactDomainLimitReached = true;
|
|
356
|
+
}
|
|
357
|
+
if (typeof href === 'string' && !/^mailto:/i.test(href)) {
|
|
358
|
+
const normalized = normalizeIdentityUrl(href, baseUrl);
|
|
359
|
+
if (normalized) {
|
|
360
|
+
const extension = downloadExtension(normalized.url);
|
|
361
|
+
const explicit = attributes.has('download');
|
|
362
|
+
const risky = Boolean(extension && RISKY_DOWNLOAD_EXTENSIONS.has(extension));
|
|
363
|
+
if (explicit || risky) {
|
|
364
|
+
downloadCount += 1;
|
|
365
|
+
if (explicit)
|
|
366
|
+
explicitDownloadCount += 1;
|
|
367
|
+
if (risky && extension) {
|
|
368
|
+
riskyDownloadCount += 1;
|
|
369
|
+
if (addBounded(downloadFileTypes, extension, MAX_DOWNLOAD_FILE_TYPES))
|
|
370
|
+
downloadFileTypeLimitReached = true;
|
|
371
|
+
}
|
|
372
|
+
const origin = new URL(normalized.url).origin;
|
|
373
|
+
if (origin !== baseOrigin && addBounded(downloadOrigins, origin, MAX_DOWNLOAD_ORIGINS))
|
|
374
|
+
downloadOriginLimitReached = true;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
else if (attributes.has('download') && href) {
|
|
378
|
+
discardedUrls += 1;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const resources = resourceReferences(tagName, attributes);
|
|
384
|
+
if (resources.truncated)
|
|
385
|
+
perTagLimitReached = true;
|
|
386
|
+
for (const reference of resources.references) {
|
|
387
|
+
const normalized = normalizeIdentityUrl(reference.value, baseUrl);
|
|
388
|
+
if (!normalized) {
|
|
389
|
+
if (reference.value)
|
|
390
|
+
discardedUrls += 1;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
const key = `${reference.type}:${normalized.url}`;
|
|
394
|
+
if (!resourceKeys.has(key)) {
|
|
395
|
+
resourceKeys.add(key);
|
|
396
|
+
byType[reference.type] += 1;
|
|
397
|
+
}
|
|
398
|
+
const origin = new URL(normalized.url).origin;
|
|
399
|
+
if (origin !== baseOrigin && addBounded(resourceOrigins, origin, MAX_RESOURCE_ORIGINS))
|
|
400
|
+
resourceOriginLimitReached = true;
|
|
401
|
+
if (['frame', 'object'].includes(reference.type) && origin !== baseOrigin
|
|
402
|
+
&& addBounded(embeddedOrigins, origin, MAX_EMBEDDED_ORIGINS))
|
|
403
|
+
embeddedOriginLimitReached = true;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const tracking = trackingIdentifiers(markupForTrackingIdentifiers(html));
|
|
407
|
+
const truncated = tagLimitReached || resourceOriginLimitReached || embeddedOriginLimitReached
|
|
408
|
+
|| contactDomainLimitReached || downloadOriginLimitReached || downloadFileTypeLimitReached
|
|
409
|
+
|| perTagLimitReached || tracking.truncated;
|
|
410
|
+
const limitations = [];
|
|
411
|
+
if (tagLimitReached)
|
|
412
|
+
limitations.push(`Resource parsing reached the ${MAX_RESOURCE_TAGS}-tag or ${MAX_IDENTITY_TAG_LENGTH}-character tag limit.`);
|
|
413
|
+
if (resourceOriginLimitReached)
|
|
414
|
+
limitations.push(`Only the first ${MAX_RESOURCE_ORIGINS} external resource origins were retained.`);
|
|
415
|
+
if (embeddedOriginLimitReached)
|
|
416
|
+
limitations.push(`Only the first ${MAX_EMBEDDED_ORIGINS} embedded origins were retained.`);
|
|
417
|
+
if (contactDomainLimitReached)
|
|
418
|
+
limitations.push(`Only the first ${MAX_CONTACT_DOMAINS} contact domains were retained.`);
|
|
419
|
+
if (downloadOriginLimitReached)
|
|
420
|
+
limitations.push(`Only the first ${MAX_DOWNLOAD_ORIGINS} external download origins were retained.`);
|
|
421
|
+
if (downloadFileTypeLimitReached)
|
|
422
|
+
limitations.push(`Only the first ${MAX_DOWNLOAD_FILE_TYPES} risky download file types were retained.`);
|
|
423
|
+
if (perTagLimitReached)
|
|
424
|
+
limitations.push(`Some srcset URL candidates could not be safely enumerated within the ${MAX_URLS_PER_TAG}-candidate per-tag boundary.`);
|
|
425
|
+
if (tracking.truncated)
|
|
426
|
+
limitations.push(`Only the first ${MAX_TRACKING_IDENTIFIERS} tracking identifiers were retained.`);
|
|
427
|
+
return {
|
|
428
|
+
resources: {
|
|
429
|
+
count: resourceKeys.size,
|
|
430
|
+
byType,
|
|
431
|
+
externalOrigins: [...resourceOrigins].sort(),
|
|
432
|
+
truncated: tagLimitReached || resourceOriginLimitReached || perTagLimitReached,
|
|
433
|
+
},
|
|
434
|
+
embeddedOrigins: [...embeddedOrigins].sort(),
|
|
435
|
+
contactDomains: [...contactDomains].sort(),
|
|
436
|
+
downloads: {
|
|
437
|
+
count: downloadCount,
|
|
438
|
+
explicitCount: explicitDownloadCount,
|
|
439
|
+
riskyCount: riskyDownloadCount,
|
|
440
|
+
externalOrigins: [...downloadOrigins].sort(),
|
|
441
|
+
riskyFileTypes: [...downloadFileTypes].sort(),
|
|
442
|
+
truncated: tagLimitReached || downloadOriginLimitReached || downloadFileTypeLimitReached,
|
|
443
|
+
},
|
|
444
|
+
trackingIdentifiers: tracking.values,
|
|
445
|
+
truncated,
|
|
446
|
+
limitations,
|
|
447
|
+
diagnostics: {
|
|
448
|
+
relationshipTagsExamined: tagsExamined,
|
|
449
|
+
relationshipUrlsDiscarded: discardedUrls,
|
|
450
|
+
trackingIdentifiersTruncated: tracking.truncated,
|
|
451
|
+
},
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function metaRefreshTarget(content) {
|
|
455
|
+
if (typeof content !== 'string' || content.length > MAX_URL_INPUT_LENGTH || HAS_CONTROL_CHARACTER_RE.test(content))
|
|
456
|
+
return null;
|
|
457
|
+
const match = content.match(/(?:^|;)\s*url\s*=\s*(?:"([^"]*)"|'([^']*)'|([^;]*))/i);
|
|
458
|
+
return match ? (match[1] ?? match[2] ?? match[3] ?? '').trim() : null;
|
|
459
|
+
}
|
|
460
|
+
function extractPageIdentity(html, domain, options = {}) {
|
|
461
|
+
const baseUrl = resolvedBaseUrl(domain, options.baseUrl);
|
|
462
|
+
const tagMarkup = markupForTagParsing(html);
|
|
463
|
+
const parsedBase = new URL(baseUrl);
|
|
464
|
+
const baseOrigin = parsedBase.origin;
|
|
465
|
+
const baseUsesHttps = parsedBase.protocol === 'https:';
|
|
466
|
+
const externalFormOrigins = new Set();
|
|
467
|
+
let documentLanguage = null;
|
|
468
|
+
let canonical = null;
|
|
469
|
+
let metaRefresh = null;
|
|
470
|
+
let openGraphTitle = null;
|
|
471
|
+
let openGraphSiteName = null;
|
|
472
|
+
let openGraphUrl = null;
|
|
473
|
+
let generator = null;
|
|
474
|
+
let formCount = 0;
|
|
475
|
+
let postFormCount = 0;
|
|
476
|
+
let insecureActionCount = 0;
|
|
477
|
+
let tagsExamined = 0;
|
|
478
|
+
let discardedUrls = 0;
|
|
479
|
+
let formLimitReached = false;
|
|
480
|
+
let originLimitReached = false;
|
|
481
|
+
let tagLimitReached = OVERSIZED_IDENTITY_TAG_RE.test(tagMarkup);
|
|
482
|
+
let match;
|
|
483
|
+
IDENTITY_TAG_RE.lastIndex = 0;
|
|
484
|
+
while ((match = IDENTITY_TAG_RE.exec(tagMarkup))) {
|
|
485
|
+
if (tagsExamined >= MAX_IDENTITY_TAGS) {
|
|
486
|
+
tagLimitReached = true;
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
tagsExamined += 1;
|
|
490
|
+
const tagName = (match[1] ?? '').toLowerCase();
|
|
491
|
+
if (!tagName)
|
|
492
|
+
continue;
|
|
493
|
+
const attributes = parseAttributes(match[0]);
|
|
494
|
+
if (tagName === 'html' && documentLanguage === null) {
|
|
495
|
+
documentLanguage = normalizeLanguage(attributes.get('lang'));
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (tagName === 'link' && canonical === null) {
|
|
499
|
+
const rel = String(attributes.get('rel') || '').toLowerCase().split(/\s+/);
|
|
500
|
+
if (rel.includes('canonical')) {
|
|
501
|
+
canonical = normalizeIdentityUrl(attributes.get('href'), baseUrl);
|
|
502
|
+
if (!canonical && attributes.has('href'))
|
|
503
|
+
discardedUrls += 1;
|
|
504
|
+
}
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (tagName === 'meta') {
|
|
508
|
+
const name = String(attributes.get('name') || '').trim().toLowerCase();
|
|
509
|
+
const property = String(attributes.get('property') || '').trim().toLowerCase();
|
|
510
|
+
const httpEquiv = String(attributes.get('http-equiv') || '').trim().toLowerCase();
|
|
511
|
+
const content = attributes.get('content');
|
|
512
|
+
if (httpEquiv === 'refresh' && metaRefresh === null) {
|
|
513
|
+
const target = metaRefreshTarget(content);
|
|
514
|
+
metaRefresh = normalizeIdentityUrl(target, baseUrl);
|
|
515
|
+
if (!metaRefresh && (target || (typeof content === 'string' && /(?:^|;)\s*url\s*=/i.test(content))))
|
|
516
|
+
discardedUrls += 1;
|
|
517
|
+
}
|
|
518
|
+
else if (property === 'og:title' && openGraphTitle === null) {
|
|
519
|
+
openGraphTitle = boundedHtmlText(content, MAX_IDENTITY_TEXT, true);
|
|
520
|
+
}
|
|
521
|
+
else if (property === 'og:site_name' && openGraphSiteName === null) {
|
|
522
|
+
openGraphSiteName = boundedHtmlText(content, MAX_IDENTITY_TEXT, true);
|
|
523
|
+
}
|
|
524
|
+
else if (property === 'og:url' && openGraphUrl === null) {
|
|
525
|
+
openGraphUrl = normalizeIdentityUrl(content, baseUrl);
|
|
526
|
+
if (content && !openGraphUrl)
|
|
527
|
+
discardedUrls += 1;
|
|
528
|
+
}
|
|
529
|
+
else if (name === 'generator' && generator === null) {
|
|
530
|
+
generator = boundedHtmlText(content, MAX_GENERATOR_LENGTH, true);
|
|
531
|
+
}
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (tagName === 'form') {
|
|
535
|
+
if (formCount >= MAX_FORMS) {
|
|
536
|
+
formLimitReached = true;
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
formCount += 1;
|
|
540
|
+
if (String(attributes.get('method') || 'get').trim().toLowerCase() === 'post')
|
|
541
|
+
postFormCount += 1;
|
|
542
|
+
if (!attributes.has('action') || !String(attributes.get('action')).trim())
|
|
543
|
+
continue;
|
|
544
|
+
const action = normalizeIdentityUrl(attributes.get('action'), baseUrl);
|
|
545
|
+
if (!action) {
|
|
546
|
+
discardedUrls += 1;
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
const actionOrigin = new URL(action.url).origin;
|
|
550
|
+
if (baseUsesHttps && action.url.startsWith('http:'))
|
|
551
|
+
insecureActionCount += 1;
|
|
552
|
+
if (actionOrigin !== baseOrigin) {
|
|
553
|
+
if (externalFormOrigins.size < MAX_FORM_ACTION_ORIGINS)
|
|
554
|
+
externalFormOrigins.add(actionOrigin);
|
|
555
|
+
else if (!externalFormOrigins.has(actionOrigin))
|
|
556
|
+
originLimitReached = true;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const queryOmitted = [canonical, metaRefresh, openGraphUrl].some((item) => item?.queryOmitted);
|
|
561
|
+
const pathTruncated = [canonical, metaRefresh, openGraphUrl].some((item) => item?.pathTruncated);
|
|
562
|
+
const sourceTruncated = options.sourceTruncated === true;
|
|
563
|
+
const relationships = extractPageRelationships(html, domain, { baseUrl, tagMarkup });
|
|
564
|
+
const fingerprints = createPageFingerprints(html, {
|
|
565
|
+
baseUrl,
|
|
566
|
+
exactBodyHash: options.exactBodyHash,
|
|
567
|
+
sourceTruncated,
|
|
568
|
+
resources: relationships.resources,
|
|
569
|
+
trackingIdentifiers: relationships.trackingIdentifiers,
|
|
570
|
+
identifiersTruncated: relationships.diagnostics.trackingIdentifiersTruncated === true,
|
|
571
|
+
});
|
|
572
|
+
const truncated = sourceTruncated || tagLimitReached || formLimitReached || originLimitReached
|
|
573
|
+
|| pathTruncated || relationships.truncated || fingerprints.truncated;
|
|
574
|
+
const limitations = ['Static HTML metadata only; JavaScript-rendered changes are not evaluated.'];
|
|
575
|
+
if (sourceTruncated)
|
|
576
|
+
limitations.push('Homepage body capture reached its byte limit; identity fields may be incomplete.');
|
|
577
|
+
if (tagLimitReached)
|
|
578
|
+
limitations.push(`Page identity parsing reached the ${MAX_IDENTITY_TAGS}-tag or ${MAX_IDENTITY_TAG_LENGTH}-character tag limit.`);
|
|
579
|
+
if (formLimitReached)
|
|
580
|
+
limitations.push(`Only the first ${MAX_FORMS} forms were summarized.`);
|
|
581
|
+
if (originLimitReached)
|
|
582
|
+
limitations.push(`Only the first ${MAX_FORM_ACTION_ORIGINS} external form-action origins were retained.`);
|
|
583
|
+
if (queryOmitted)
|
|
584
|
+
limitations.push('Query strings and fragments were omitted from retained page-identity URLs.');
|
|
585
|
+
if (pathTruncated)
|
|
586
|
+
limitations.push('An overlong page-identity URL path was replaced by its origin.');
|
|
587
|
+
limitations.push(...relationships.limitations);
|
|
588
|
+
limitations.push(...fingerprints.limitations);
|
|
589
|
+
return {
|
|
590
|
+
identityVersion: PAGE_IDENTITY_VERSION,
|
|
591
|
+
...createObservation({
|
|
592
|
+
status: truncated ? 'partial' : 'success',
|
|
593
|
+
observedAt: options.observedAt,
|
|
594
|
+
scanMode: 'deep',
|
|
595
|
+
source: 'html',
|
|
596
|
+
complete: !truncated,
|
|
597
|
+
truncated,
|
|
598
|
+
limitations,
|
|
599
|
+
diagnostics: {
|
|
600
|
+
tagsExamined,
|
|
601
|
+
discardedUrls,
|
|
602
|
+
formsObserved: formCount,
|
|
603
|
+
...relationships.diagnostics,
|
|
604
|
+
},
|
|
605
|
+
}),
|
|
606
|
+
documentLanguage,
|
|
607
|
+
canonical,
|
|
608
|
+
metaRefresh,
|
|
609
|
+
openGraph: { title: openGraphTitle, siteName: openGraphSiteName, url: openGraphUrl },
|
|
610
|
+
generator,
|
|
611
|
+
forms: {
|
|
612
|
+
count: formCount,
|
|
613
|
+
postCount: postFormCount,
|
|
614
|
+
insecureActionCount,
|
|
615
|
+
externalActionOrigins: [...externalFormOrigins].sort(),
|
|
616
|
+
truncated: formLimitReached || originLimitReached,
|
|
617
|
+
},
|
|
618
|
+
resources: relationships.resources,
|
|
619
|
+
embeddedOrigins: relationships.embeddedOrigins,
|
|
620
|
+
contactDomains: relationships.contactDomains,
|
|
621
|
+
downloads: relationships.downloads,
|
|
622
|
+
trackingIdentifiers: relationships.trackingIdentifiers,
|
|
623
|
+
fingerprints,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
function extractHtmlSignals(html, domain, options = {}) {
|
|
627
|
+
const phishingMatch = html.match(PHISHING_LANGUAGE_RE);
|
|
628
|
+
const pageIdentity = options.includePageIdentity === false ? null : extractPageIdentity(html, domain, options);
|
|
629
|
+
const includeCredentialSurfaceProfile = pageIdentity && options.includeCredentialSurfaceProfile === true;
|
|
630
|
+
const includeStructuredDataIdentity = pageIdentity && options.includeStructuredDataIdentity !== false;
|
|
631
|
+
const includeTechnologyProfile = pageIdentity && options.includeTechnologyProfile !== false;
|
|
632
|
+
const includeDerivedPageProfiles = Boolean(pageIdentity);
|
|
633
|
+
const baseUrl = resolvedBaseUrl(domain, options.baseUrl);
|
|
634
|
+
const htmlAnalysis = includeCredentialSurfaceProfile || includeStructuredDataIdentity || includeTechnologyProfile || includeDerivedPageProfiles
|
|
635
|
+
? analyzeStaticHtml(html, { baseUrl })
|
|
636
|
+
: null;
|
|
637
|
+
return {
|
|
638
|
+
pageTitle: extractPageTitle(html),
|
|
639
|
+
hasPasswordField: PASSWORD_FIELD_RE.test(html),
|
|
640
|
+
phishingLanguageMatch: phishingMatch ? boundedHtmlText(phishingMatch[0], MAX_PHISHING_MATCH_LENGTH) : null,
|
|
641
|
+
hasExternalFormAction: pageIdentity
|
|
642
|
+
? pageIdentity.forms.externalActionOrigins.length > 0
|
|
643
|
+
: null,
|
|
644
|
+
externalAssetHosts: extractExternalAssetHosts(html, domain),
|
|
645
|
+
pageIdentity,
|
|
646
|
+
credentialSurfaceProfile: includeCredentialSurfaceProfile && htmlAnalysis ? analyzeCredentialSurfaceProfile({
|
|
647
|
+
htmlAnalysis,
|
|
648
|
+
observedAt: options.observedAt,
|
|
649
|
+
sourceTruncated: options.sourceTruncated,
|
|
650
|
+
}) : null,
|
|
651
|
+
structuredDataIdentity: includeStructuredDataIdentity && htmlAnalysis ? analyzeStructuredDataIdentity({
|
|
652
|
+
htmlAnalysis,
|
|
653
|
+
baseUrl,
|
|
654
|
+
observedAt: options.observedAt,
|
|
655
|
+
sourceTruncated: options.sourceTruncated,
|
|
656
|
+
}) : null,
|
|
657
|
+
technologyProfile: includeTechnologyProfile && htmlAnalysis ? analyzeWebsiteTechnology({
|
|
658
|
+
htmlAnalysis,
|
|
659
|
+
generator: pageIdentity.generator,
|
|
660
|
+
httpServer: options.httpServer,
|
|
661
|
+
responseHeaders: options.responseHeaders,
|
|
662
|
+
resourceOrigins: pageIdentity.resources.externalOrigins,
|
|
663
|
+
observedAt: options.observedAt,
|
|
664
|
+
sourceTruncated: options.sourceTruncated,
|
|
665
|
+
}) : null,
|
|
666
|
+
pageRoleProfile: includeDerivedPageProfiles && htmlAnalysis ? analyzePageRole({
|
|
667
|
+
htmlAnalysis,
|
|
668
|
+
pageTitle: extractPageTitle(html),
|
|
669
|
+
activityStatus: options.activityStatus,
|
|
670
|
+
observedAt: options.observedAt,
|
|
671
|
+
sourceTruncated: options.sourceTruncated,
|
|
672
|
+
}) : null,
|
|
673
|
+
clientBehaviorProfile: includeDerivedPageProfiles && htmlAnalysis ? analyzeClientBehavior({
|
|
674
|
+
htmlAnalysis,
|
|
675
|
+
observedAt: options.observedAt,
|
|
676
|
+
sourceTruncated: options.sourceTruncated,
|
|
677
|
+
}) : null,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
export { PAGE_IDENTITY_VERSION, MAX_IDENTITY_TAGS, MAX_FORMS, MAX_FORM_ACTION_ORIGINS, MAX_RESOURCE_TAGS, MAX_RESOURCE_ORIGINS, MAX_EMBEDDED_ORIGINS, MAX_CONTACT_DOMAINS, MAX_TRACKING_IDENTIFIERS, extractHtmlSignals, extractPageIdentity, extractPageRelationships, };
|