@slicedearth/whoisleuth-cli 2.2.0 → 2.3.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/README.md +2 -1
- package/cli/arguments.mjs +389 -1745
- package/cli/artifact-validation/case-response.mjs +55 -30
- package/cli/artifact-validation/signed-review.mjs +30 -12
- package/cli/bulk-checkpoint.mjs +34 -3
- package/cli/case-pack.mjs +39 -92
- package/cli/command-argument-grammar.mjs +211 -0
- package/cli/command-reference.mjs +200 -213
- package/cli/completion.mjs +5 -1
- package/cli/discovery-observation-snapshot.mjs +16 -3
- package/cli/discovery-scan-command-runner.mjs +19 -0
- package/cli/discovery-scan.mjs +6 -0
- package/cli/domain-control-observations.mjs +1 -1
- package/cli/inline-command-families.mjs +1 -0
- package/cli/input.mjs +116 -0
- package/cli/interchange-report.mjs +14 -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/mail-header-review.mjs +467 -0
- package/cli/review-command-runner.mjs +25 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +7 -1
- package/docs/cli-reference.md +8 -1
- package/docs/cli.md +12 -1
- package/lib/bounded-json.mjs +9 -6
- package/lib/dns-convergence-review.mjs +1 -0
- package/lib/dns-intelligence.mjs +37 -3
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/domain-change-packet.mjs +42 -14
- package/lib/domain-control-flight-recorder.mjs +4 -1
- package/lib/domain-control-manifest.mjs +16 -16
- package/lib/domain-posture-parsers.mjs +114 -3
- package/lib/domain-posture.mjs +34 -3
- package/lib/evidence-export.mjs +60 -11
- package/lib/evidence-report.mjs +2 -2
- package/lib/html-signals.mjs +39 -33
- package/lib/local-geoip-evidence.mjs +19 -3
- package/lib/lookup-response.mjs +5 -4
- package/lib/lookup-source-progress.mjs +27 -4
- package/lib/nameserver-preflight-review.mjs +1 -0
- package/lib/rdap-capabilities.mjs +1 -1
- package/lib/registry-insights.mjs +35 -22
- package/lib/risk-calibration-summary.mjs +1 -3
- package/lib/security-txt.mjs +33 -1
- package/lib/static-html-analysis.mjs +69 -13
- package/lib/website-technology.mjs +79 -11
- package/lib/zone-intent-review.mjs +16 -6
- package/package.json +1 -1
- package/packages/cases/case-migration-model.mjs +71 -22
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +10 -21
- 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 +6 -4
- package/packages/cases/case-record-projection.mjs +218 -0
- package/packages/cases/case-report.mjs +11 -6
- package/packages/cases/case-response-model.mjs +43 -6
- package/packages/cases/case-response-packet.mjs +78 -27
- package/packages/cases/case-storage-model.mjs +6 -2
- package/packages/comparison/comparison-ledger-bulk.mjs +9 -10
- package/packages/comparison/page-similarity.mjs +3 -1
- package/packages/contracts/case-portability.mjs +171 -51
- package/packages/contracts/cli-command-semantics.mjs +93 -0
- package/packages/contracts/risk-calibration.mjs +15 -10
- package/packages/contracts/workspace-portability.mjs +44 -7
- package/packages/evidence/domain-control-runtime.mjs +46 -6
- package/packages/interchange/external-findings-import.mjs +3 -2
- package/packages/investigation/lookup-asset-graph.mjs +35 -27
- package/packages/workspace/brand-profile-model.mjs +118 -2
- package/packages/workspace/bulk-review-model.mjs +13 -7
- package/packages/workspace/bulk-session-model.mjs +5 -4
- package/packages/workspace/detection-rule-model.mjs +70 -22
- package/packages/workspace/relationship-observation-model.mjs +4 -1
- package/packages/workspace/website-snapshot-model.mjs +106 -8
- package/packages/workspace/workspace-archive.mjs +2 -0
- package/cli/evidence-command-arguments.mjs +0 -156
package/lib/evidence-export.mjs
CHANGED
|
@@ -119,6 +119,7 @@ const LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS = new Set([
|
|
|
119
119
|
'scanMode', 'durationMs', 'complete', 'truncated', 'limitations',
|
|
120
120
|
'diagnostics', 'detail', 'error', 'count', 'total', 'discarded', 'summary',
|
|
121
121
|
'findings', 'id', 'label', 'tone', 'category', 'confidence', 'compatibility', 'role', 'roles', 'evidence',
|
|
122
|
+
'description',
|
|
122
123
|
'value', 'values', 'name', 'type', 'types', 'mode', 'owner', 'target',
|
|
123
124
|
'url', 'uri', 'href',
|
|
124
125
|
'priority', 'ttl', 'records',
|
|
@@ -147,7 +148,8 @@ const LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS = new Set([
|
|
|
147
148
|
'chain', 'chainTruncated', 'standardName', 'size', 'subject', 'issuer',
|
|
148
149
|
'serialNumber', 'validFrom', 'validTo', 'fingerprintSha1',
|
|
149
150
|
'fingerprintSha256', 'isCertificateAuthority', 'subjectAltNames',
|
|
150
|
-
'dnsNames', 'ipAddresses', 'otherNames', '
|
|
151
|
+
'dnsNames', 'ipAddresses', 'otherNames', 'classes', 'dns', 'ip', 'directoryName',
|
|
152
|
+
'registeredId', 'otherName', 'publicKey', 'bits', 'curve',
|
|
151
153
|
'signature', 'oid', 'extendedKeyUsage', 'authorityInformationAccess',
|
|
152
154
|
'ocsp', 'caIssuers', 'unknownMethods', 'extensionProfile', 'parsed', 'partial',
|
|
153
155
|
'certificatePolicies', 'crlDistributionPoints', 'commonNames',
|
|
@@ -161,7 +163,7 @@ const LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS = new Set([
|
|
|
161
163
|
'externalOrigins', 'embeddedOrigins', 'contactDomains', 'downloads',
|
|
162
164
|
'explicitCount', 'riskyCount', 'riskyFileTypes', 'trackingIdentifiers',
|
|
163
165
|
'fingerprints', 'fingerprintVersion', 'exact', 'normalizedHtml',
|
|
164
|
-
'visibleText', 'domStructure', 'formStructure', 'resourceHosts',
|
|
166
|
+
'visibleText', 'domStructure', 'similarity', 'formStructure', 'resourceHosts',
|
|
165
167
|
'identifiers', 'featureCount', 'nodeCount', 'formCount', 'controlCount',
|
|
166
168
|
'parser', 'tokenCount', 'tagsExamined', 'discardedUrls', 'formsObserved',
|
|
167
169
|
'relationshipTagsExamined', 'relationshipUrlsDiscarded',
|
|
@@ -197,6 +199,9 @@ const LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS = new Set([
|
|
|
197
199
|
const LOOKUP_AVAILABILITY_CREDENTIAL_CATEGORY_KEYS = new Set([
|
|
198
200
|
'password', 'email', 'username', 'one_time_code', 'payment',
|
|
199
201
|
]);
|
|
202
|
+
const LOOKUP_AVAILABILITY_TLS_SAN_CLASS_KEYS = new Set([
|
|
203
|
+
'dns', 'ip', 'email', 'uri', 'directoryName', 'registeredId', 'otherName', 'unclassified',
|
|
204
|
+
]);
|
|
200
205
|
const LOOKUP_AVAILABILITY_VALUE_PATHS = new Set([
|
|
201
206
|
'dns.records.caa',
|
|
202
207
|
'dns.caaPolicy.records',
|
|
@@ -328,12 +333,12 @@ function projectLookupEvidencePrivacySafeTreeValue(value, state, depth) {
|
|
|
328
333
|
export function projectLookupEvidencePrivacySafeTree(value) {
|
|
329
334
|
return projectLookupEvidencePrivacySafeTreeValue(value, { entries: 0 }, 0);
|
|
330
335
|
}
|
|
331
|
-
function projectLookupEvidenceAvailabilityPublicValue(value,
|
|
336
|
+
function projectLookupEvidenceAvailabilityPublicValue(value, path, state, depth) {
|
|
332
337
|
consumePortableProjectionEntry(state, depth);
|
|
333
338
|
if (value === null || typeof value === 'boolean' || typeof value === 'number')
|
|
334
339
|
return value;
|
|
335
340
|
if (typeof value === 'string') {
|
|
336
|
-
const normalizedParent = normalizedEvidenceKey(
|
|
341
|
+
const normalizedParent = normalizedEvidenceKey(path.at(-1) || '');
|
|
337
342
|
if (PORTABLE_URL_KEYS.has(normalizedParent))
|
|
338
343
|
return portableUri(value);
|
|
339
344
|
if (PORTABLE_ORIGIN_COLLECTION_KEYS.has(normalizedParent))
|
|
@@ -342,7 +347,7 @@ function projectLookupEvidenceAvailabilityPublicValue(value, parentKey, state, d
|
|
|
342
347
|
}
|
|
343
348
|
if (Array.isArray(value)) {
|
|
344
349
|
return value.slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS)
|
|
345
|
-
.map((item) => projectLookupEvidenceAvailabilityPublicValue(item,
|
|
350
|
+
.map((item) => projectLookupEvidenceAvailabilityPublicValue(item, path, state, depth + 1));
|
|
346
351
|
}
|
|
347
352
|
const source = recordOrNull(value);
|
|
348
353
|
if (!source)
|
|
@@ -352,7 +357,31 @@ function projectLookupEvidenceAvailabilityPublicValue(value, parentKey, state, d
|
|
|
352
357
|
if (!LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS.has(key)
|
|
353
358
|
|| privateEvidenceKey(key, item))
|
|
354
359
|
continue;
|
|
355
|
-
|
|
360
|
+
const parentPath = path.join('.');
|
|
361
|
+
const sanClassCount = parentPath === 'tls.certificate.subjectAltNames.classes'
|
|
362
|
+
&& LOOKUP_AVAILABILITY_TLS_SAN_CLASS_KEYS.has(key)
|
|
363
|
+
&& Number.isSafeInteger(item)
|
|
364
|
+
&& Number(item) >= 0
|
|
365
|
+
&& Number(item) <= LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS;
|
|
366
|
+
if (['dns', 'ip', 'directoryName', 'registeredId', 'otherName'].includes(key)
|
|
367
|
+
&& !sanClassCount)
|
|
368
|
+
continue;
|
|
369
|
+
if (parentPath === 'tls.certificate.subjectAltNames.classes'
|
|
370
|
+
&& LOOKUP_AVAILABILITY_TLS_SAN_CLASS_KEYS.has(key)
|
|
371
|
+
&& !sanClassCount)
|
|
372
|
+
continue;
|
|
373
|
+
if (key === 'classes'
|
|
374
|
+
&& (parentPath !== 'tls.certificate.subjectAltNames' || !recordOrNull(item)))
|
|
375
|
+
continue;
|
|
376
|
+
if (key === 'similarity'
|
|
377
|
+
&& (parentPath !== 'pageIdentity.fingerprints.domStructure'
|
|
378
|
+
|| (item !== null && !recordOrNull(item))))
|
|
379
|
+
continue;
|
|
380
|
+
if (key === 'description'
|
|
381
|
+
&& (parentPath !== 'technologyProfile.findings.evidence'
|
|
382
|
+
|| typeof item !== 'string'))
|
|
383
|
+
continue;
|
|
384
|
+
output[key] = projectLookupEvidenceAvailabilityPublicValue(item, [...path, key], state, depth + 1);
|
|
356
385
|
}
|
|
357
386
|
return output;
|
|
358
387
|
}
|
|
@@ -381,11 +410,31 @@ function projectLookupEvidenceAvailabilityValue(value, path, state, depth) {
|
|
|
381
410
|
|| privateEvidenceKey(key, item))
|
|
382
411
|
continue;
|
|
383
412
|
const parentPath = path.join('.');
|
|
413
|
+
const boundedCount = Number.isSafeInteger(item)
|
|
414
|
+
&& Number(item) >= 0
|
|
415
|
+
&& Number(item) <= LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS;
|
|
416
|
+
const credentialCategoryCount = parentPath === 'credentialSurfaceProfile.inputs.categories'
|
|
417
|
+
&& boundedCount;
|
|
418
|
+
const sanClassCount = parentPath === 'tls.certificate.subjectAltNames.classes'
|
|
419
|
+
&& LOOKUP_AVAILABILITY_TLS_SAN_CLASS_KEYS.has(key)
|
|
420
|
+
&& boundedCount;
|
|
384
421
|
if (LOOKUP_AVAILABILITY_CREDENTIAL_CATEGORY_KEYS.has(key)
|
|
385
|
-
&&
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
422
|
+
&& !credentialCategoryCount
|
|
423
|
+
&& !sanClassCount)
|
|
424
|
+
continue;
|
|
425
|
+
if (['dns', 'ip', 'directoryName', 'registeredId', 'otherName'].includes(key)
|
|
426
|
+
&& !sanClassCount)
|
|
427
|
+
continue;
|
|
428
|
+
if (key === 'classes'
|
|
429
|
+
&& (parentPath !== 'tls.certificate.subjectAltNames' || !recordOrNull(item)))
|
|
430
|
+
continue;
|
|
431
|
+
if (key === 'similarity'
|
|
432
|
+
&& (parentPath !== 'pageIdentity.fingerprints.domStructure'
|
|
433
|
+
|| (item !== null && !recordOrNull(item))))
|
|
434
|
+
continue;
|
|
435
|
+
if (key === 'description'
|
|
436
|
+
&& (parentPath !== 'technologyProfile.findings.evidence'
|
|
437
|
+
|| typeof item !== 'string'))
|
|
389
438
|
continue;
|
|
390
439
|
if (key === 'owner'
|
|
391
440
|
&& (!LOOKUP_AVAILABILITY_OWNER_PATHS.has(parentPath) || typeof item !== 'string'))
|
|
@@ -419,7 +468,7 @@ function projectLookupEvidenceAvailabilityWithKeys(value, rootKeys, registryCont
|
|
|
419
468
|
continue;
|
|
420
469
|
output[key] = currentPrivacyRules
|
|
421
470
|
? projectLookupEvidenceAvailabilityValue(item, [key], state, 1)
|
|
422
|
-
: projectLookupEvidenceAvailabilityPublicValue(item, key, state, 1);
|
|
471
|
+
: projectLookupEvidenceAvailabilityPublicValue(item, [key], state, 1);
|
|
423
472
|
}
|
|
424
473
|
if (registryContactsExcluded)
|
|
425
474
|
output.registryContactsExcluded = true;
|
package/lib/evidence-report.mjs
CHANGED
|
@@ -331,7 +331,7 @@ function buildLookupEvidenceReport(document, options = {}) {
|
|
|
331
331
|
: [];
|
|
332
332
|
return {
|
|
333
333
|
title: cleanReportText(titleTarget),
|
|
334
|
-
notice: 'Human-readable summary
|
|
334
|
+
notice: 'Human-readable summary of the bounded, privacy-minimised JSON evidence package. Raw RDAP JSON, WHOIS response bodies, contact records, and other excluded collection data are not included in either artefact.',
|
|
335
335
|
metadata: [
|
|
336
336
|
reportField('Generated', report.generatedAt),
|
|
337
337
|
reportField('Generator', application.version ? `${application.name || 'WHOISleuth'} ${application.version}` : application.name || 'WHOISleuth'),
|
|
@@ -432,7 +432,7 @@ function buildLookupEvidenceReport(document, options = {}) {
|
|
|
432
432
|
'This report summarises point-in-time observations and registry publications. It does not prove ownership, activity, availability, or maliciousness.',
|
|
433
433
|
'Missing, skipped, partial, unsupported, or failed sources are inconclusive rather than negative evidence.',
|
|
434
434
|
'The Risk score is a versioned heuristic review priority, not a probability or determination of maliciousness. Review its listed factors and the underlying source states.',
|
|
435
|
-
'
|
|
435
|
+
'The JSON evidence package is a bounded, privacy-minimised projection. It excludes raw registry and WHOIS payloads, contact records, authentication and session material, and unreviewed fields.',
|
|
436
436
|
...(networkContext.contextVersion === 1 ? [
|
|
437
437
|
'Observed network registration describes one point-in-time public endpoint. It may identify an edge or shared network rather than the origin host and does not prove control, ownership, intent, or maliciousness.',
|
|
438
438
|
] : []),
|
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
|
|
@@ -102,12 +97,6 @@ function extractExternalAssetHosts(html, ownDomain) {
|
|
|
102
97
|
}
|
|
103
98
|
return [...hosts];
|
|
104
99
|
}
|
|
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
100
|
function parseAttributes(tag) {
|
|
112
101
|
const start = tag.search(/\s/);
|
|
113
102
|
if (start === -1)
|
|
@@ -307,9 +296,10 @@ function trackingIdentifiers(html) {
|
|
|
307
296
|
};
|
|
308
297
|
}
|
|
309
298
|
function extractPageRelationships(html, domain, options = {}) {
|
|
310
|
-
const
|
|
299
|
+
const documentUrl = resolvedBaseUrl(domain, options.baseUrl);
|
|
300
|
+
const baseUrl = resolvedBaseUrl(domain, options.effectiveBaseUrl ?? documentUrl);
|
|
311
301
|
const markup = typeof options.tagMarkup === 'string' ? options.tagMarkup : markupForTagParsing(html);
|
|
312
|
-
const baseOrigin = new URL(
|
|
302
|
+
const baseOrigin = options.documentOrigin ?? new URL(documentUrl).origin;
|
|
313
303
|
const resourceKeys = new Set();
|
|
314
304
|
const resourceOrigins = new Set();
|
|
315
305
|
const embeddedOrigins = new Set();
|
|
@@ -454,11 +444,16 @@ function metaRefreshTarget(content) {
|
|
|
454
444
|
return match ? (match[1] ?? match[2] ?? match[3] ?? '').trim() : null;
|
|
455
445
|
}
|
|
456
446
|
function extractPageIdentity(html, domain, options = {}) {
|
|
457
|
-
const
|
|
447
|
+
const documentUrl = resolvedBaseUrl(domain, options.baseUrl);
|
|
448
|
+
const baseAnalysis = options.effectiveBaseUrl === undefined
|
|
449
|
+
? analyzeStaticHtml(html, { baseUrl: documentUrl })
|
|
450
|
+
: null;
|
|
451
|
+
const baseUrl = resolvedBaseUrl(domain, options.effectiveBaseUrl ?? baseAnalysis?.effectiveBaseUrl ?? documentUrl);
|
|
452
|
+
const baseHrefState = options.baseHrefState ?? baseAnalysis?.baseHrefState ?? 'absent';
|
|
458
453
|
const tagMarkup = markupForTagParsing(html);
|
|
459
|
-
const
|
|
460
|
-
const baseOrigin =
|
|
461
|
-
const baseUsesHttps =
|
|
454
|
+
const parsedDocument = new URL(documentUrl);
|
|
455
|
+
const baseOrigin = options.documentOrigin ?? parsedDocument.origin;
|
|
456
|
+
const baseUsesHttps = parsedDocument.protocol === 'https:';
|
|
462
457
|
const externalFormOrigins = new Set();
|
|
463
458
|
let documentLanguage = null;
|
|
464
459
|
let canonical = null;
|
|
@@ -556,20 +551,25 @@ function extractPageIdentity(html, domain, options = {}) {
|
|
|
556
551
|
const queryOmitted = [canonical, metaRefresh, openGraphUrl].some((item) => item?.queryOmitted);
|
|
557
552
|
const pathTruncated = [canonical, metaRefresh, openGraphUrl].some((item) => item?.pathTruncated);
|
|
558
553
|
const sourceTruncated = options.sourceTruncated === true;
|
|
559
|
-
const relationships = extractPageRelationships(html, domain, { baseUrl, tagMarkup });
|
|
554
|
+
const relationships = extractPageRelationships(html, domain, { baseUrl: documentUrl, effectiveBaseUrl: baseUrl, documentOrigin: baseOrigin, tagMarkup });
|
|
560
555
|
const fingerprints = createPageFingerprints(html, {
|
|
561
|
-
|
|
556
|
+
// Retained fingerprint algorithms pre-date document-base handling. Keep
|
|
557
|
+
// their response-URL normalization stable until a versioned fingerprint
|
|
558
|
+
// migration is intentionally introduced.
|
|
559
|
+
baseUrl: documentUrl,
|
|
562
560
|
exactBodyHash: options.exactBodyHash,
|
|
563
561
|
sourceTruncated,
|
|
564
562
|
resources: relationships.resources,
|
|
565
563
|
trackingIdentifiers: relationships.trackingIdentifiers,
|
|
566
564
|
identifiersTruncated: relationships.diagnostics.trackingIdentifiersTruncated === true,
|
|
567
565
|
});
|
|
568
|
-
const truncated = sourceTruncated || tagLimitReached || formLimitReached || originLimitReached
|
|
566
|
+
const truncated = sourceTruncated || baseHrefState === 'invalid' || tagLimitReached || formLimitReached || originLimitReached
|
|
569
567
|
|| pathTruncated || relationships.truncated || fingerprints.truncated;
|
|
570
568
|
const limitations = ['Static HTML metadata only; JavaScript-rendered changes are not evaluated.'];
|
|
571
569
|
if (sourceTruncated)
|
|
572
570
|
limitations.push('Homepage body capture reached its byte limit; identity fields may be incomplete.');
|
|
571
|
+
if (baseHrefState === 'invalid')
|
|
572
|
+
limitations.push('The first bounded document base URL was invalid; relative URL relationships use the response URL and remain partial.');
|
|
573
573
|
if (tagLimitReached)
|
|
574
574
|
limitations.push(`Page identity parsing reached the ${MAX_IDENTITY_TAGS}-tag or ${MAX_IDENTITY_TAG_LENGTH}-character tag limit.`);
|
|
575
575
|
if (formLimitReached)
|
|
@@ -697,33 +697,37 @@ function buildPagePublicationMetadata(analysis, sourceTruncated) {
|
|
|
697
697
|
};
|
|
698
698
|
}
|
|
699
699
|
function extractHtmlSignals(html, domain, options = {}) {
|
|
700
|
-
const
|
|
700
|
+
const documentUrl = resolvedBaseUrl(domain, options.baseUrl);
|
|
701
|
+
const htmlAnalysis = analyzeStaticHtml(html, { baseUrl: documentUrl, includeVisibleText: true });
|
|
702
|
+
const effectiveBaseUrl = htmlAnalysis.effectiveBaseUrl ?? documentUrl;
|
|
703
|
+
const documentOrigin = new URL(documentUrl).origin;
|
|
704
|
+
const pageIdentity = options.includePageIdentity === false ? null : extractPageIdentity(html, domain, {
|
|
705
|
+
...options,
|
|
706
|
+
baseUrl: documentUrl,
|
|
707
|
+
effectiveBaseUrl,
|
|
708
|
+
documentOrigin,
|
|
709
|
+
baseHrefState: htmlAnalysis.baseHrefState,
|
|
710
|
+
});
|
|
701
711
|
const includeCredentialSurfaceProfile = pageIdentity && options.includeCredentialSurfaceProfile === true;
|
|
702
712
|
const includeStructuredDataIdentity = pageIdentity && options.includeStructuredDataIdentity !== false;
|
|
703
713
|
const includeTechnologyProfile = pageIdentity && options.includeTechnologyProfile !== false;
|
|
704
714
|
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
715
|
const pageIdentityOutput = pageIdentity && htmlAnalysis && options.includePublicationMetadata !== false
|
|
710
716
|
? {
|
|
711
717
|
...pageIdentity,
|
|
712
718
|
publicationMetadata: buildPagePublicationMetadata(htmlAnalysis.publicationMetadata, options.sourceTruncated === true),
|
|
713
719
|
}
|
|
714
720
|
: pageIdentity;
|
|
715
|
-
const pageLanguageSignal = detectPageLanguageSignal(html, pageIdentity?.documentLanguage, htmlAnalysis
|
|
721
|
+
const pageLanguageSignal = detectPageLanguageSignal(html, pageIdentity?.documentLanguage, htmlAnalysis);
|
|
716
722
|
return {
|
|
717
|
-
pageTitle:
|
|
718
|
-
hasPasswordField:
|
|
723
|
+
pageTitle: htmlAnalysis.title,
|
|
724
|
+
hasPasswordField: htmlAnalysis.forms.categories.password > 0,
|
|
719
725
|
phishingLanguageMatch: pageLanguageSignal?.label ?? null,
|
|
720
726
|
hasExternalFormAction: pageIdentity
|
|
721
727
|
? pageIdentity.forms.externalActionOrigins.length > 0
|
|
722
728
|
: null,
|
|
723
729
|
externalAssetHosts: extractExternalAssetHosts(html, domain),
|
|
724
|
-
cspMetaPolicy: htmlAnalysis
|
|
725
|
-
? analyzeCspMetaPolicies(htmlAnalysis.cspMetaPolicies, htmlAnalysis.cspMetaLimitReached)
|
|
726
|
-
: null,
|
|
730
|
+
cspMetaPolicy: analyzeCspMetaPolicies(htmlAnalysis.cspMetaPolicies, htmlAnalysis.cspMetaLimitReached),
|
|
727
731
|
pageIdentity: pageIdentityOutput,
|
|
728
732
|
credentialSurfaceProfile: includeCredentialSurfaceProfile && htmlAnalysis ? analyzeCredentialSurfaceProfile({
|
|
729
733
|
htmlAnalysis,
|
|
@@ -732,7 +736,7 @@ function extractHtmlSignals(html, domain, options = {}) {
|
|
|
732
736
|
}) : null,
|
|
733
737
|
structuredDataIdentity: includeStructuredDataIdentity && htmlAnalysis ? analyzeStructuredDataIdentity({
|
|
734
738
|
htmlAnalysis,
|
|
735
|
-
baseUrl,
|
|
739
|
+
baseUrl: effectiveBaseUrl,
|
|
736
740
|
observedAt: options.observedAt,
|
|
737
741
|
sourceTruncated: options.sourceTruncated,
|
|
738
742
|
}) : null,
|
|
@@ -742,12 +746,14 @@ function extractHtmlSignals(html, domain, options = {}) {
|
|
|
742
746
|
httpServer: options.httpServer,
|
|
743
747
|
responseHeaders: options.responseHeaders,
|
|
744
748
|
resourceOrigins: pageIdentity.resources.externalOrigins,
|
|
749
|
+
effectiveBaseUrl,
|
|
750
|
+
documentOrigin,
|
|
745
751
|
observedAt: options.observedAt,
|
|
746
752
|
sourceTruncated: options.sourceTruncated,
|
|
747
753
|
}) : null,
|
|
748
754
|
pageRoleProfile: includeDerivedPageProfiles && htmlAnalysis ? analyzePageRole({
|
|
749
755
|
htmlAnalysis,
|
|
750
|
-
pageTitle:
|
|
756
|
+
pageTitle: htmlAnalysis.title,
|
|
751
757
|
activityStatus: options.activityStatus,
|
|
752
758
|
observedAt: options.observedAt,
|
|
753
759
|
sourceTruncated: options.sourceTruncated,
|
|
@@ -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
|
}
|
package/lib/lookup-response.mjs
CHANGED
|
@@ -124,11 +124,12 @@ async function buildUnifiedLookupResponse(context) {
|
|
|
124
124
|
const result = availabilityResult.value;
|
|
125
125
|
availability = {
|
|
126
126
|
applicable: true,
|
|
127
|
-
domain: classified.value,
|
|
128
|
-
inputHostname: classified.inputHostname,
|
|
129
|
-
registrableDomain: classified.registrableDomain,
|
|
130
|
-
isSubdomain: classified.isSubdomain,
|
|
131
127
|
...result,
|
|
128
|
+
// Every observation nested in the availability envelope is collected
|
|
129
|
+
// against this registrable target. Request context remains on the
|
|
130
|
+
// top-level response; exact-host sources such as security.txt retain
|
|
131
|
+
// their own requested/final URL attribution.
|
|
132
|
+
domain: classified.value,
|
|
132
133
|
};
|
|
133
134
|
}
|
|
134
135
|
else {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Lookup. These deliberately exclude raw RDAP/WHOIS bodies, contacts, URLs,
|
|
3
3
|
// provider payloads, and request errors. The ordinary final Lookup response
|
|
4
4
|
// remains the only authoritative and persistable result.
|
|
5
|
+
import { THREAT_INTELLIGENCE_CONTRACT_VERSION, THREAT_INTELLIGENCE_RESULT_STATES, THREAT_INTELLIGENCE_SCHEMA, } from "./threat-intelligence-types.mjs";
|
|
5
6
|
const RESULT_STATES = new Set([
|
|
6
7
|
'available',
|
|
7
8
|
'expiring',
|
|
@@ -19,6 +20,12 @@ const DIRECT_STATES = new Set([
|
|
|
19
20
|
'unavailable',
|
|
20
21
|
'rate_limited',
|
|
21
22
|
]);
|
|
23
|
+
const THREAT_INTELLIGENCE_SOURCES = new Set([
|
|
24
|
+
'external_intelligence',
|
|
25
|
+
'malware_host_intelligence',
|
|
26
|
+
'malware_ioc_intelligence',
|
|
27
|
+
]);
|
|
28
|
+
const THREAT_INTELLIGENCE_STATES = new Set(THREAT_INTELLIGENCE_RESULT_STATES);
|
|
22
29
|
function record(value) {
|
|
23
30
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
24
31
|
? value
|
|
@@ -63,6 +70,15 @@ function normalizedState(source, outcome, value) {
|
|
|
63
70
|
if (source === 'domain_evidence') {
|
|
64
71
|
return record(value).deepScanComplete === false ? 'partial' : 'success';
|
|
65
72
|
}
|
|
73
|
+
if (THREAT_INTELLIGENCE_SOURCES.has(source)) {
|
|
74
|
+
const intelligence = record(value);
|
|
75
|
+
return intelligence.schema === THREAT_INTELLIGENCE_SCHEMA
|
|
76
|
+
&& intelligence.version === THREAT_INTELLIGENCE_CONTRACT_VERSION
|
|
77
|
+
&& typeof intelligence.state === 'string'
|
|
78
|
+
&& THREAT_INTELLIGENCE_STATES.has(intelligence.state)
|
|
79
|
+
? intelligence.state
|
|
80
|
+
: 'error';
|
|
81
|
+
}
|
|
66
82
|
const status = record(value).status;
|
|
67
83
|
if (typeof status === 'string' && DIRECT_STATES.has(status)) {
|
|
68
84
|
return status;
|
|
@@ -76,15 +92,22 @@ function normalizedState(source, outcome, value) {
|
|
|
76
92
|
function normalizeLookupSourceSettlement(source, outcome, value) {
|
|
77
93
|
const state = normalizedState(source, outcome, value);
|
|
78
94
|
const sourceRecord = record(value);
|
|
95
|
+
const threatObservation = THREAT_INTELLIGENCE_SOURCES.has(source)
|
|
96
|
+
? record(sourceRecord.observation)
|
|
97
|
+
: null;
|
|
79
98
|
const resultState = source === 'domain_evidence'
|
|
80
99
|
&& typeof sourceRecord.state === 'string'
|
|
81
100
|
&& RESULT_STATES.has(sourceRecord.state)
|
|
82
101
|
? sourceRecord.state
|
|
83
102
|
: null;
|
|
84
|
-
const complete =
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
103
|
+
const complete = threatObservation
|
|
104
|
+
? state !== 'error' && threatObservation.complete === true
|
|
105
|
+
: !['partial', 'error', 'unavailable', 'rate_limited'].includes(state);
|
|
106
|
+
const truncated = threatObservation
|
|
107
|
+
? threatObservation.truncated === true
|
|
108
|
+
: sourceRecord.truncated === true
|
|
109
|
+
|| sourceRecord.recordsTruncated === true
|
|
110
|
+
|| sourceRecord.bodyTruncated === true;
|
|
88
111
|
const limitation = outcome === 'rejected'
|
|
89
112
|
? 'This source did not complete. No absence or safety conclusion was inferred.'
|
|
90
113
|
: state === 'skipped'
|
|
@@ -116,6 +116,7 @@ export function reviewNameserverPreflight(inputRaw, generatedAtValue = new Date(
|
|
|
116
116
|
&& authorityState === 'authoritative'
|
|
117
117
|
&& servedSetState === 'aligned'
|
|
118
118
|
&& soaState === 'observed'
|
|
119
|
+
&& addressState !== 'non_public'
|
|
119
120
|
&& (!inBailiwick || addressState === 'ready');
|
|
120
121
|
return Object.freeze({
|
|
121
122
|
nameserver,
|
|
@@ -245,7 +245,7 @@ export function inspectRdapCapabilities(parsedValue, status) {
|
|
|
245
245
|
break;
|
|
246
246
|
}
|
|
247
247
|
const declarations = [...unique].sort().map((value) => {
|
|
248
|
-
const entry = EXTENSION_CATALOG[value];
|
|
248
|
+
const entry = Object.hasOwn(EXTENSION_CATALOG, value) ? EXTENSION_CATALOG[value] : undefined;
|
|
249
249
|
return entry
|
|
250
250
|
? {
|
|
251
251
|
identifier: value,
|
|
@@ -137,21 +137,22 @@ function disclosure(source, parsedValue, status) {
|
|
|
137
137
|
detail: 'The source completed without a usable registrant contact or an explicit disclosure marker.',
|
|
138
138
|
};
|
|
139
139
|
}
|
|
140
|
-
function lifecycle(statusesValue) {
|
|
140
|
+
function lifecycle(statusesValue, evidenceState) {
|
|
141
141
|
const rawStatuses = strings(statusesValue);
|
|
142
142
|
const normalized = rawStatuses.map(normalizedStatus);
|
|
143
143
|
const has = (value) => normalized.includes(value);
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
const
|
|
144
|
+
const observedState = (value) => has(value) ? true : evidenceState === 'complete' ? false : null;
|
|
145
|
+
const pendingDelete = observedState('pendingdelete');
|
|
146
|
+
const redemption = observedState('redemptionperiod');
|
|
147
|
+
const pendingTransfer = observedState('pendingtransfer');
|
|
148
|
+
const serverHold = observedState('serverhold');
|
|
149
|
+
const clientHold = observedState('clienthold');
|
|
149
150
|
const serverLocks = normalized.filter((value) => /^server(?:delete|renew|transfer|update)prohibited$/u.test(value));
|
|
150
151
|
const clientLocks = normalized.filter((value) => /^client(?:delete|renew|transfer|update)prohibited$/u.test(value));
|
|
151
|
-
let stage = '
|
|
152
|
-
let label = '
|
|
153
|
-
let acquisitionPath = ['
|
|
154
|
-
if (pendingDelete) {
|
|
152
|
+
let stage = 'unknown';
|
|
153
|
+
let label = 'Lifecycle unavailable';
|
|
154
|
+
let acquisitionPath = ['Obtain a qualifying registry or WHOIS publication before interpreting lifecycle or lock state.'];
|
|
155
|
+
if (pendingDelete === true) {
|
|
155
156
|
stage = 'pending_delete';
|
|
156
157
|
label = 'Pending delete';
|
|
157
158
|
acquisitionPath = [
|
|
@@ -160,7 +161,7 @@ function lifecycle(statusesValue) {
|
|
|
160
161
|
'Use a registrar or registry-supported acquisition path when policy permits.',
|
|
161
162
|
];
|
|
162
163
|
}
|
|
163
|
-
else if (redemption) {
|
|
164
|
+
else if (redemption === true) {
|
|
164
165
|
stage = 'redemption';
|
|
165
166
|
label = 'Redemption period';
|
|
166
167
|
acquisitionPath = [
|
|
@@ -169,16 +170,21 @@ function lifecycle(statusesValue) {
|
|
|
169
170
|
'Do not treat the domain as available or guaranteed to drop.',
|
|
170
171
|
];
|
|
171
172
|
}
|
|
172
|
-
else if (pendingTransfer) {
|
|
173
|
+
else if (pendingTransfer === true) {
|
|
173
174
|
stage = 'pending_transfer';
|
|
174
175
|
label = 'Pending transfer';
|
|
175
176
|
acquisitionPath = ['A registrar transfer is pending; this is not an availability signal.'];
|
|
176
177
|
}
|
|
177
|
-
else if (serverHold || clientHold) {
|
|
178
|
+
else if (serverHold === true || clientHold === true) {
|
|
178
179
|
stage = 'hold';
|
|
179
180
|
label = 'Registration hold';
|
|
180
181
|
acquisitionPath = ['A hold affects delegation or publication; it does not mean the registration is available.'];
|
|
181
182
|
}
|
|
183
|
+
else if (evidenceState === 'complete' || rawStatuses.length > 0) {
|
|
184
|
+
stage = 'registered';
|
|
185
|
+
label = 'Registered';
|
|
186
|
+
acquisitionPath = ['Continue monitoring registry status and expiry.'];
|
|
187
|
+
}
|
|
182
188
|
return {
|
|
183
189
|
stage,
|
|
184
190
|
label,
|
|
@@ -188,8 +194,8 @@ function lifecycle(statusesValue) {
|
|
|
188
194
|
pendingTransfer,
|
|
189
195
|
hold: { client: clientHold, server: serverHold },
|
|
190
196
|
locks: {
|
|
191
|
-
client: clientLocks.length > 0,
|
|
192
|
-
server: serverLocks.length > 0,
|
|
197
|
+
client: clientLocks.length > 0 ? true : evidenceState === 'complete' ? false : null,
|
|
198
|
+
server: serverLocks.length > 0 ? true : evidenceState === 'complete' ? false : null,
|
|
193
199
|
clientStatuses: clientLocks,
|
|
194
200
|
serverStatuses: serverLocks,
|
|
195
201
|
},
|
|
@@ -271,7 +277,18 @@ export function buildRegistryInsights(input) {
|
|
|
271
277
|
: counts.equivalent
|
|
272
278
|
? 'consistent'
|
|
273
279
|
: 'unavailable';
|
|
274
|
-
const
|
|
280
|
+
const publications = [
|
|
281
|
+
publicationDiagnostic('registry_rdap', input.rdapParsed, input.rdapStatus, input.rdapFetchedAt),
|
|
282
|
+
publicationDiagnostic('whois', input.whoisParsed, input.whoisStatus, input.whoisQueriedAt),
|
|
283
|
+
publicationDiagnostic('registrar_rdap', input.registrarRdapParsed, input.registrarRdapStatus, input.registrarRdapFetchedAt),
|
|
284
|
+
];
|
|
285
|
+
const lifecycleCandidates = [
|
|
286
|
+
{ statuses: strings(rdap.statuses), state: publications[0].state },
|
|
287
|
+
{ statuses: strings(whois.statuses), state: publications[1].state },
|
|
288
|
+
].filter((candidate) => candidate.state !== 'unavailable');
|
|
289
|
+
const lifecycleSource = lifecycleCandidates.find((candidate) => candidate.statuses.length)
|
|
290
|
+
?? lifecycleCandidates[0]
|
|
291
|
+
?? { statuses: [], state: 'unavailable' };
|
|
275
292
|
const routes = [
|
|
276
293
|
...contactRoute('registry', rdap.abuse, 'Registry RDAP abuse entity'),
|
|
277
294
|
...contactRoute('registrar', rdap.registrar, 'Registry RDAP registrar entity'),
|
|
@@ -294,7 +311,7 @@ export function buildRegistryInsights(input) {
|
|
|
294
311
|
whois: disclosure('whois', input.whoisParsed, input.whoisStatus),
|
|
295
312
|
limitation: 'Disclosure state describes what each point-in-time source published. It does not infer the identity, intent, reachability, or legal status of a registrant.',
|
|
296
313
|
},
|
|
297
|
-
lifecycle: lifecycle(statuses),
|
|
314
|
+
lifecycle: lifecycle(lifecycleSource.statuses, lifecycleSource.state),
|
|
298
315
|
reconciliation: {
|
|
299
316
|
state: reconciliationState,
|
|
300
317
|
conflictCount,
|
|
@@ -312,11 +329,7 @@ export function buildRegistryInsights(input) {
|
|
|
312
329
|
? 'The comparable fields are normalised as equivalent.'
|
|
313
330
|
: 'No comparable fields were available.',
|
|
314
331
|
},
|
|
315
|
-
publications
|
|
316
|
-
publicationDiagnostic('registry_rdap', input.rdapParsed, input.rdapStatus, input.rdapFetchedAt),
|
|
317
|
-
publicationDiagnostic('whois', input.whoisParsed, input.whoisStatus, input.whoisQueriedAt),
|
|
318
|
-
publicationDiagnostic('registrar_rdap', input.registrarRdapParsed, input.registrarRdapStatus, input.registrarRdapFetchedAt),
|
|
319
|
-
],
|
|
332
|
+
publications,
|
|
320
333
|
rdapCapabilities: {
|
|
321
334
|
registry: inspectRdapCapabilities(input.rdapParsed, input.rdapStatus),
|
|
322
335
|
registrar: inspectRdapCapabilities(input.registrarRdapParsed, input.registrarRdapStatus),
|
|
@@ -109,9 +109,7 @@ function thresholdMetrics(value, label, expectedThreshold, expectedTotal, expect
|
|
|
109
109
|
const recall = ratio(truePositive, truePositive + falseNegative);
|
|
110
110
|
const specificity = ratio(trueNegative, trueNegative + falsePositive);
|
|
111
111
|
const falsePositiveRate = ratio(falsePositive, falsePositive + trueNegative);
|
|
112
|
-
const f1 =
|
|
113
|
-
? null
|
|
114
|
-
: Number(((2 * precision * recall) / (precision + recall)).toFixed(4));
|
|
112
|
+
const f1 = ratio(2 * truePositive, 2 * truePositive + falsePositive + falseNegative);
|
|
115
113
|
const balancedAccuracy = recall === null || specificity === null
|
|
116
114
|
? null
|
|
117
115
|
: Number(((recall + specificity) / 2).toFixed(4));
|
package/lib/security-txt.mjs
CHANGED
|
@@ -50,6 +50,38 @@ function normalizedPublishedUri(value, schemes) {
|
|
|
50
50
|
return null;
|
|
51
51
|
if (url.protocol === 'https:' && !url.hostname)
|
|
52
52
|
return null;
|
|
53
|
+
if (url.protocol === 'mailto:') {
|
|
54
|
+
let recipients;
|
|
55
|
+
try {
|
|
56
|
+
recipients = decodeURIComponent(url.pathname).split(',');
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (!recipients.length || recipients.some((recipient) => {
|
|
62
|
+
const separator = recipient.lastIndexOf('@');
|
|
63
|
+
if (separator <= 0 || separator === recipient.length - 1)
|
|
64
|
+
return true;
|
|
65
|
+
const local = recipient.slice(0, separator);
|
|
66
|
+
const mailDomain = recipient.slice(separator + 1).toLowerCase();
|
|
67
|
+
return !/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+$/iu.test(local)
|
|
68
|
+
|| local.startsWith('.') || local.endsWith('.') || local.includes('..')
|
|
69
|
+
|| !canonicalHostname(mailDomain);
|
|
70
|
+
}))
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (url.protocol === 'tel:') {
|
|
74
|
+
let number;
|
|
75
|
+
try {
|
|
76
|
+
number = decodeURIComponent(url.pathname);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
if (!/^\+?[0-9(). -]+(?:;[a-z0-9-]+=[a-z0-9.-]+)*$/iu.test(number)
|
|
82
|
+
|| (number.match(/[0-9]/gu)?.length ?? 0) < 3)
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
53
85
|
url.search = '';
|
|
54
86
|
url.hash = '';
|
|
55
87
|
return url.toString().slice(0, MAX_SECURITY_TXT_URL_LENGTH);
|
|
@@ -211,7 +243,7 @@ function parseSecurityTxt(text, options = {}) {
|
|
|
211
243
|
const finalComparisonUrl = options.finalUrl ? normalizedCanonicalComparisonUrl(options.finalUrl) : null;
|
|
212
244
|
const canonicalMatches = boundedCanonical.values.length && finalUrl && finalComparisonUrl
|
|
213
245
|
? canonicalComparison.slice(0, MAX_SECURITY_TXT_VALUES)
|
|
214
|
-
.some((value) => value
|
|
246
|
+
.some((value) => value === finalComparisonUrl)
|
|
215
247
|
: null;
|
|
216
248
|
const stale = Boolean(expiresAt && Date.parse(expiresAt) <= (options.now ?? Date.now()));
|
|
217
249
|
const requiredMalformed = boundedContacts.values.length === 0 || expires.length !== 1;
|