@slicedearth/whoisleuth-cli 2.1.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 +24 -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 +63 -13
- 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 +240 -3
- 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 +183 -53
- 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 +115 -46
- 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
|
@@ -10,8 +10,9 @@ import { REGISTRAR_STANDING_SCHEMA, REGISTRAR_STANDING_VERSION, registrarIanaIds
|
|
|
10
10
|
export const LOOKUP_EVIDENCE_SCHEMA = 'whoisleuth.lookup-evidence';
|
|
11
11
|
export const V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION = 26;
|
|
12
12
|
export const PUBLISHED_V2_LOOKUP_EVIDENCE_SCHEMA_VERSION = 27;
|
|
13
|
+
export const LATEST_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION = 28;
|
|
13
14
|
export const PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION = PUBLISHED_V2_LOOKUP_EVIDENCE_SCHEMA_VERSION;
|
|
14
|
-
export const LOOKUP_EVIDENCE_SCHEMA_VERSION =
|
|
15
|
+
export const LOOKUP_EVIDENCE_SCHEMA_VERSION = LATEST_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION;
|
|
15
16
|
export const SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS = Object.freeze([
|
|
16
17
|
V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION,
|
|
17
18
|
PUBLISHED_V2_LOOKUP_EVIDENCE_SCHEMA_VERSION,
|
|
@@ -35,7 +36,7 @@ export const LOOKUP_EVIDENCE_COMPATIBILITY = defineSchemaCompatibility({
|
|
|
35
36
|
writeSemantics: 'read_only',
|
|
36
37
|
byteBudget: LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES,
|
|
37
38
|
owner: 'lib/evidence-export.mts',
|
|
38
|
-
note: 'Exact v1.47.4 version 26
|
|
39
|
+
note: 'Exact v1.47.4 version 26, published v2.0.1 version 27, and latest-public v2.1.0 version 28 remain replayable; version 28 adds a bounded official-source registrar-standing projection.',
|
|
39
40
|
});
|
|
40
41
|
const REGISTRAR_RDAP_STATUSES = new Set([
|
|
41
42
|
'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
|
|
@@ -118,6 +119,7 @@ const LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS = new Set([
|
|
|
118
119
|
'scanMode', 'durationMs', 'complete', 'truncated', 'limitations',
|
|
119
120
|
'diagnostics', 'detail', 'error', 'count', 'total', 'discarded', 'summary',
|
|
120
121
|
'findings', 'id', 'label', 'tone', 'category', 'confidence', 'compatibility', 'role', 'roles', 'evidence',
|
|
122
|
+
'description',
|
|
121
123
|
'value', 'values', 'name', 'type', 'types', 'mode', 'owner', 'target',
|
|
122
124
|
'url', 'uri', 'href',
|
|
123
125
|
'priority', 'ttl', 'records',
|
|
@@ -146,7 +148,8 @@ const LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS = new Set([
|
|
|
146
148
|
'chain', 'chainTruncated', 'standardName', 'size', 'subject', 'issuer',
|
|
147
149
|
'serialNumber', 'validFrom', 'validTo', 'fingerprintSha1',
|
|
148
150
|
'fingerprintSha256', 'isCertificateAuthority', 'subjectAltNames',
|
|
149
|
-
'dnsNames', 'ipAddresses', 'otherNames', '
|
|
151
|
+
'dnsNames', 'ipAddresses', 'otherNames', 'classes', 'dns', 'ip', 'directoryName',
|
|
152
|
+
'registeredId', 'otherName', 'publicKey', 'bits', 'curve',
|
|
150
153
|
'signature', 'oid', 'extendedKeyUsage', 'authorityInformationAccess',
|
|
151
154
|
'ocsp', 'caIssuers', 'unknownMethods', 'extensionProfile', 'parsed', 'partial',
|
|
152
155
|
'certificatePolicies', 'crlDistributionPoints', 'commonNames',
|
|
@@ -160,7 +163,7 @@ const LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS = new Set([
|
|
|
160
163
|
'externalOrigins', 'embeddedOrigins', 'contactDomains', 'downloads',
|
|
161
164
|
'explicitCount', 'riskyCount', 'riskyFileTypes', 'trackingIdentifiers',
|
|
162
165
|
'fingerprints', 'fingerprintVersion', 'exact', 'normalizedHtml',
|
|
163
|
-
'visibleText', 'domStructure', 'formStructure', 'resourceHosts',
|
|
166
|
+
'visibleText', 'domStructure', 'similarity', 'formStructure', 'resourceHosts',
|
|
164
167
|
'identifiers', 'featureCount', 'nodeCount', 'formCount', 'controlCount',
|
|
165
168
|
'parser', 'tokenCount', 'tagsExamined', 'discardedUrls', 'formsObserved',
|
|
166
169
|
'relationshipTagsExamined', 'relationshipUrlsDiscarded',
|
|
@@ -196,6 +199,9 @@ const LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS = new Set([
|
|
|
196
199
|
const LOOKUP_AVAILABILITY_CREDENTIAL_CATEGORY_KEYS = new Set([
|
|
197
200
|
'password', 'email', 'username', 'one_time_code', 'payment',
|
|
198
201
|
]);
|
|
202
|
+
const LOOKUP_AVAILABILITY_TLS_SAN_CLASS_KEYS = new Set([
|
|
203
|
+
'dns', 'ip', 'email', 'uri', 'directoryName', 'registeredId', 'otherName', 'unclassified',
|
|
204
|
+
]);
|
|
199
205
|
const LOOKUP_AVAILABILITY_VALUE_PATHS = new Set([
|
|
200
206
|
'dns.records.caa',
|
|
201
207
|
'dns.caaPolicy.records',
|
|
@@ -327,12 +333,12 @@ function projectLookupEvidencePrivacySafeTreeValue(value, state, depth) {
|
|
|
327
333
|
export function projectLookupEvidencePrivacySafeTree(value) {
|
|
328
334
|
return projectLookupEvidencePrivacySafeTreeValue(value, { entries: 0 }, 0);
|
|
329
335
|
}
|
|
330
|
-
function projectLookupEvidenceAvailabilityPublicValue(value,
|
|
336
|
+
function projectLookupEvidenceAvailabilityPublicValue(value, path, state, depth) {
|
|
331
337
|
consumePortableProjectionEntry(state, depth);
|
|
332
338
|
if (value === null || typeof value === 'boolean' || typeof value === 'number')
|
|
333
339
|
return value;
|
|
334
340
|
if (typeof value === 'string') {
|
|
335
|
-
const normalizedParent = normalizedEvidenceKey(
|
|
341
|
+
const normalizedParent = normalizedEvidenceKey(path.at(-1) || '');
|
|
336
342
|
if (PORTABLE_URL_KEYS.has(normalizedParent))
|
|
337
343
|
return portableUri(value);
|
|
338
344
|
if (PORTABLE_ORIGIN_COLLECTION_KEYS.has(normalizedParent))
|
|
@@ -341,7 +347,7 @@ function projectLookupEvidenceAvailabilityPublicValue(value, parentKey, state, d
|
|
|
341
347
|
}
|
|
342
348
|
if (Array.isArray(value)) {
|
|
343
349
|
return value.slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS)
|
|
344
|
-
.map((item) => projectLookupEvidenceAvailabilityPublicValue(item,
|
|
350
|
+
.map((item) => projectLookupEvidenceAvailabilityPublicValue(item, path, state, depth + 1));
|
|
345
351
|
}
|
|
346
352
|
const source = recordOrNull(value);
|
|
347
353
|
if (!source)
|
|
@@ -351,7 +357,31 @@ function projectLookupEvidenceAvailabilityPublicValue(value, parentKey, state, d
|
|
|
351
357
|
if (!LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS.has(key)
|
|
352
358
|
|| privateEvidenceKey(key, item))
|
|
353
359
|
continue;
|
|
354
|
-
|
|
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);
|
|
355
385
|
}
|
|
356
386
|
return output;
|
|
357
387
|
}
|
|
@@ -380,11 +410,31 @@ function projectLookupEvidenceAvailabilityValue(value, path, state, depth) {
|
|
|
380
410
|
|| privateEvidenceKey(key, item))
|
|
381
411
|
continue;
|
|
382
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;
|
|
383
421
|
if (LOOKUP_AVAILABILITY_CREDENTIAL_CATEGORY_KEYS.has(key)
|
|
384
|
-
&&
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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'))
|
|
388
438
|
continue;
|
|
389
439
|
if (key === 'owner'
|
|
390
440
|
&& (!LOOKUP_AVAILABILITY_OWNER_PATHS.has(parentPath) || typeof item !== 'string'))
|
|
@@ -418,7 +468,7 @@ function projectLookupEvidenceAvailabilityWithKeys(value, rootKeys, registryCont
|
|
|
418
468
|
continue;
|
|
419
469
|
output[key] = currentPrivacyRules
|
|
420
470
|
? projectLookupEvidenceAvailabilityValue(item, [key], state, 1)
|
|
421
|
-
: projectLookupEvidenceAvailabilityPublicValue(item, key, state, 1);
|
|
471
|
+
: projectLookupEvidenceAvailabilityPublicValue(item, [key], state, 1);
|
|
422
472
|
}
|
|
423
473
|
if (registryContactsExcluded)
|
|
424
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));
|