@slicedearth/whoisleuth-cli 1.39.1 → 1.45.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 +23 -4
- package/bin/whoisleuth.mjs +31 -9
- package/cli/archive-inspect.mjs +37 -2
- package/cli/arguments.mjs +361 -256
- package/cli/artifact-verify.mjs +35 -1
- package/cli/bulk-checkpoint.mjs +72 -39
- package/cli/bulk-command-runner.mjs +34 -4
- package/cli/bulk.mjs +18 -7
- package/cli/case-pack.mjs +136 -0
- package/cli/ci-report.mjs +73 -0
- package/cli/collection-preflight.mjs +59 -0
- package/cli/command-reference.mjs +395 -0
- package/cli/completion.mjs +67 -13
- package/cli/config-profile.mjs +183 -0
- package/cli/ct-event-intake.mjs +159 -0
- package/cli/discovery-observation-snapshot.mjs +8 -14
- package/cli/discovery-scan-command-runner.mjs +29 -2
- package/cli/discovery-snapshot.mjs +8 -15
- package/cli/domain-control-monitor.mjs +104 -0
- package/cli/domain-control-observations.mjs +235 -0
- package/cli/errors.mjs +7 -1
- package/cli/evidence-command-arguments.mjs +11 -0
- package/cli/evidence-command-runner.mjs +1 -0
- package/cli/external-observation-mapping.mjs +162 -0
- package/cli/fail-policy.mjs +64 -0
- package/cli/formatters/html.mjs +1 -1
- package/cli/formatters/json.mjs +12 -4
- package/cli/formatters/terminal.mjs +1 -1
- package/cli/http.mjs +2 -1
- package/cli/investigation-manifest.mjs +113 -0
- package/cli/investigation-run.mjs +112 -0
- package/cli/lookup-brief.mjs +116 -0
- package/cli/lookup-command-runner.mjs +8 -1
- package/cli/lookup-diff.mjs +17 -19
- package/cli/lookup-reconcile.mjs +1 -1
- package/cli/mail-review.mjs +5 -5
- package/cli/manual.mjs +1 -1
- package/cli/network-command-runner.mjs +4 -1
- package/cli/offline-evidence-review.mjs +105 -8
- package/cli/open-asset-model-bridge.mjs +169 -0
- package/cli/registry-cohort.mjs +121 -0
- package/cli/registry-doctor.mjs +106 -11
- package/cli/registry-fixture-scaffold.mjs +74 -0
- package/cli/runner.mjs +388 -315
- package/docs/cli-reference.md +266 -21
- package/docs/cli.md +43 -7
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +2 -2
- package/frontend/src/lib/analysis/brand-profile-model.js +50 -6
- package/frontend/src/lib/analysis/case-evidence-model.js +1 -1
- package/frontend/src/lib/analysis/case-record-core.js +1 -1
- package/frontend/src/lib/analysis/case-record-operations.js +1 -1
- package/frontend/src/lib/analysis/case-report.js +573 -0
- package/frontend/src/lib/analysis/case-response-model.js +30 -1
- package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
- package/frontend/src/lib/analysis/external-findings-import.js +320 -0
- package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
- package/frontend/src/lib/analysis/lookup-decision-support.js +9 -2
- package/frontend/src/lib/analysis/lookup-presentation.js +14 -30
- package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
- package/frontend/src/lib/analysis/scoring.js +1 -1
- package/frontend/src/lib/analysis/utils.js +1 -1
- package/frontend/src/lib/analysis/visualization-models.js +2 -3
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
- package/frontend/src/lib/analysis/workspace-archive.js +5 -7
- package/lib/abusech-auth.mjs +1 -1
- package/lib/availability.mjs +1 -1
- package/lib/bounded-contract-normalizers.mjs +33 -0
- package/lib/bounded-file.mjs +69 -0
- package/lib/bounded-json-response.mjs +157 -0
- package/lib/browser-library-profile.mjs +167 -38
- package/lib/dns-convergence-review.mjs +183 -0
- package/lib/dns-delegation-health.mjs +196 -3
- package/lib/domain-change-packet.mjs +133 -0
- package/lib/domain-change-review.mjs +307 -0
- package/lib/domain-control-flight-recorder.mjs +268 -0
- package/lib/domain-portfolio-review.mjs +151 -0
- package/lib/evidence-export.mjs +1 -1
- package/lib/html-signals.mjs +5 -11
- package/lib/lookup-cache.mjs +5 -1
- package/lib/nameserver-preflight-review.mjs +159 -0
- package/lib/page-language-signals.mjs +124 -0
- package/lib/rdap-transport.mjs +6 -6
- package/lib/risk-scoring.mjs +42 -1
- package/lib/safe-fetch.mjs +37 -32
- package/lib/static-html-analysis.mjs +29 -1
- package/lib/threat-intelligence-runtime.mjs +1 -2
- package/lib/trust-store-comparison.mjs +146 -0
- package/lib/website-technology.mjs +22 -12
- package/lib/zone-intent-review.mjs +534 -0
- package/package.json +1 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
|
|
2
|
+
export const DOMAIN_PORTFOLIO_INPUT_SCHEMA = 'whoisleuth.domain-portfolio.input';
|
|
3
|
+
export const DOMAIN_PORTFOLIO_REVIEW_SCHEMA = 'whoisleuth.domain-portfolio.review';
|
|
4
|
+
export const DOMAIN_PORTFOLIO_REVIEW_VERSION = 1;
|
|
5
|
+
export const MAX_PORTFOLIO_ASSETS = 500;
|
|
6
|
+
const ROOT_KEYS = new Set(['schema', 'version', 'portfolioLabel', 'assets']);
|
|
7
|
+
const ASSET_KEYS = new Set([
|
|
8
|
+
'domain', 'criticality', 'registrar', 'registrarAccount', 'expiresAt', 'autoRenew', 'dnsProviders', 'mailProviders',
|
|
9
|
+
'certificateProviders', 'recoveryDomains', 'reviewedAt',
|
|
10
|
+
]);
|
|
11
|
+
function text(value, label, maximum = 160) {
|
|
12
|
+
return requireBoundedString(value, label, maximum);
|
|
13
|
+
}
|
|
14
|
+
function optionalText(value, label, maximum = 160) {
|
|
15
|
+
if (value === null || value === undefined || value === '')
|
|
16
|
+
return null;
|
|
17
|
+
return text(value, label, maximum);
|
|
18
|
+
}
|
|
19
|
+
function timestamp(value, label, optional = false) {
|
|
20
|
+
if (optional && (value === null || value === undefined || value === ''))
|
|
21
|
+
return null;
|
|
22
|
+
return requireIsoTimestamp(value, label);
|
|
23
|
+
}
|
|
24
|
+
function domain(value, label) {
|
|
25
|
+
return requireDomainName(value, label);
|
|
26
|
+
}
|
|
27
|
+
function textList(value, label) {
|
|
28
|
+
if (!Array.isArray(value) || value.length > 16)
|
|
29
|
+
throw new TypeError(`${label} must contain no more than 16 labels.`);
|
|
30
|
+
return [...new Set(value.map((item, index) => text(item, `${label}[${index}]`, 120)))].sort();
|
|
31
|
+
}
|
|
32
|
+
function domainList(value, label) {
|
|
33
|
+
if (!Array.isArray(value) || value.length > 16)
|
|
34
|
+
throw new TypeError(`${label} must contain no more than 16 domains.`);
|
|
35
|
+
return [...new Set(value.map((item, index) => domain(item, `${label}[${index}]`)))].sort();
|
|
36
|
+
}
|
|
37
|
+
function nullableBoolean(value, label) {
|
|
38
|
+
if (value === null || value === undefined)
|
|
39
|
+
return null;
|
|
40
|
+
if (typeof value !== 'boolean')
|
|
41
|
+
throw new TypeError(`${label} must be true, false, or null.`);
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
export function reviewDomainPortfolio(inputRaw, generatedAtValue = new Date().toISOString()) {
|
|
45
|
+
const input = requireRecord(inputRaw, 'Domain portfolio input');
|
|
46
|
+
if (input.schema !== DOMAIN_PORTFOLIO_INPUT_SCHEMA || input.version !== 1)
|
|
47
|
+
throw new TypeError(`Domain portfolio input must use ${DOMAIN_PORTFOLIO_INPUT_SCHEMA} version 1.`);
|
|
48
|
+
exactKeys(input, ROOT_KEYS, 'Domain portfolio input');
|
|
49
|
+
if (!Array.isArray(input.assets) || input.assets.length < 1 || input.assets.length > MAX_PORTFOLIO_ASSETS) {
|
|
50
|
+
throw new TypeError(`Domain portfolio input must contain from 1 to ${MAX_PORTFOLIO_ASSETS} assets.`);
|
|
51
|
+
}
|
|
52
|
+
const portfolioLabel = text(input.portfolioLabel, 'portfolioLabel', 120);
|
|
53
|
+
const assets = input.assets.map((raw, index) => {
|
|
54
|
+
const item = requireRecord(raw, `assets[${index}]`);
|
|
55
|
+
exactKeys(item, ASSET_KEYS, `assets[${index}]`);
|
|
56
|
+
const criticality = item.criticality;
|
|
57
|
+
if (criticality !== 'low' && criticality !== 'standard' && criticality !== 'high' && criticality !== 'critical') {
|
|
58
|
+
throw new TypeError(`assets[${index}].criticality is unsupported.`);
|
|
59
|
+
}
|
|
60
|
+
return Object.freeze({
|
|
61
|
+
domain: domain(item.domain, `assets[${index}].domain`),
|
|
62
|
+
criticality,
|
|
63
|
+
registrar: optionalText(item.registrar, `assets[${index}].registrar`, 120),
|
|
64
|
+
registrarAccount: optionalText(item.registrarAccount, `assets[${index}].registrarAccount`, 120),
|
|
65
|
+
expiresAt: timestamp(item.expiresAt, `assets[${index}].expiresAt`, true),
|
|
66
|
+
autoRenew: nullableBoolean(item.autoRenew, `assets[${index}].autoRenew`),
|
|
67
|
+
dnsProviders: Object.freeze(textList(item.dnsProviders ?? [], `assets[${index}].dnsProviders`)),
|
|
68
|
+
mailProviders: Object.freeze(textList(item.mailProviders ?? [], `assets[${index}].mailProviders`)),
|
|
69
|
+
certificateProviders: Object.freeze(textList(item.certificateProviders ?? [], `assets[${index}].certificateProviders`)),
|
|
70
|
+
recoveryDomains: Object.freeze(domainList(item.recoveryDomains ?? [], `assets[${index}].recoveryDomains`)),
|
|
71
|
+
reviewedAt: timestamp(item.reviewedAt, `assets[${index}].reviewedAt`),
|
|
72
|
+
});
|
|
73
|
+
}).sort((left, right) => left.domain.localeCompare(right.domain));
|
|
74
|
+
if (new Set(assets.map((asset) => asset.domain)).size !== assets.length)
|
|
75
|
+
throw new TypeError('Domain portfolio assets must use unique domains.');
|
|
76
|
+
const dependencyRows = [];
|
|
77
|
+
for (const asset of assets) {
|
|
78
|
+
const critical = asset.criticality === 'critical' || asset.criticality === 'high';
|
|
79
|
+
if (asset.registrar)
|
|
80
|
+
dependencyRows.push({ type: 'registrar', provider: asset.registrar, domain: asset.domain, critical });
|
|
81
|
+
for (const provider of asset.dnsProviders)
|
|
82
|
+
dependencyRows.push({ type: 'dns', provider, domain: asset.domain, critical });
|
|
83
|
+
for (const provider of asset.mailProviders)
|
|
84
|
+
dependencyRows.push({ type: 'mail', provider, domain: asset.domain, critical });
|
|
85
|
+
for (const provider of asset.certificateProviders)
|
|
86
|
+
dependencyRows.push({ type: 'certificate', provider, domain: asset.domain, critical });
|
|
87
|
+
for (const provider of asset.recoveryDomains)
|
|
88
|
+
dependencyRows.push({ type: 'recovery', provider, domain: asset.domain, critical });
|
|
89
|
+
}
|
|
90
|
+
const grouped = new Map();
|
|
91
|
+
for (const row of dependencyRows) {
|
|
92
|
+
const key = `${row.type}\u0000${row.provider.toLowerCase()}`;
|
|
93
|
+
grouped.set(key, [...(grouped.get(key) ?? []), row]);
|
|
94
|
+
}
|
|
95
|
+
const simulations = [...grouped.values()].map((rows) => Object.freeze({
|
|
96
|
+
dependencyType: rows[0].type,
|
|
97
|
+
provider: rows[0].provider,
|
|
98
|
+
affectedDomains: Object.freeze([...new Set(rows.map((row) => row.domain))].sort()),
|
|
99
|
+
affectedCriticalDomains: Object.freeze([...new Set(rows.filter((row) => row.critical).map((row) => row.domain))].sort()),
|
|
100
|
+
share: Math.round((new Set(rows.map((row) => row.domain)).size / assets.length) * 1000) / 10,
|
|
101
|
+
})).sort((left, right) => (right.affectedDomains.length - left.affectedDomains.length
|
|
102
|
+
|| left.dependencyType.localeCompare(right.dependencyType)
|
|
103
|
+
|| left.provider.localeCompare(right.provider)));
|
|
104
|
+
const now = Date.parse(timestamp(generatedAtValue, 'generatedAt'));
|
|
105
|
+
const renewalQueue = assets.flatMap((asset) => {
|
|
106
|
+
const expiry = asset.expiresAt ? Date.parse(asset.expiresAt) : Number.NaN;
|
|
107
|
+
const days = Number.isFinite(expiry) ? Math.ceil((expiry - now) / 86_400_000) : null;
|
|
108
|
+
const reasons = [
|
|
109
|
+
...(days === null ? ['Expiry is unavailable.'] : days <= 90 ? [`Expiry is ${days < 0 ? `${Math.abs(days)} days overdue` : `due in ${days} days`}.`] : []),
|
|
110
|
+
...(asset.autoRenew === false ? ['Auto-renew is recorded as disabled.'] : asset.autoRenew === null ? ['Auto-renew state is unavailable.'] : []),
|
|
111
|
+
...(!asset.registrarAccount ? ['Registrar account label is unavailable.'] : []),
|
|
112
|
+
];
|
|
113
|
+
return reasons.length ? [Object.freeze({ domain: asset.domain, criticality: asset.criticality, expiresAt: asset.expiresAt, daysUntilExpiry: days, reasons: Object.freeze(reasons) })] : [];
|
|
114
|
+
}).sort((left, right) => ((left.daysUntilExpiry ?? Number.MAX_SAFE_INTEGER) - (right.daysUntilExpiry ?? Number.MAX_SAFE_INTEGER)
|
|
115
|
+
|| left.domain.localeCompare(right.domain)));
|
|
116
|
+
const owned = new Set(assets.map((asset) => asset.domain));
|
|
117
|
+
const recoveryCycles = assets.flatMap((asset) => asset.recoveryDomains
|
|
118
|
+
.filter((recoveryDomain) => owned.has(recoveryDomain))
|
|
119
|
+
.map((recoveryDomain) => Object.freeze({
|
|
120
|
+
protectedDomain: asset.domain,
|
|
121
|
+
recoveryDomain,
|
|
122
|
+
reciprocal: assets.find((candidate) => candidate.domain === recoveryDomain)?.recoveryDomains.includes(asset.domain) ?? false,
|
|
123
|
+
})));
|
|
124
|
+
const unknownCounts = Object.freeze({
|
|
125
|
+
registrar: assets.filter((asset) => !asset.registrar).length,
|
|
126
|
+
registrarAccount: assets.filter((asset) => !asset.registrarAccount).length,
|
|
127
|
+
expiry: assets.filter((asset) => !asset.expiresAt).length,
|
|
128
|
+
autoRenew: assets.filter((asset) => asset.autoRenew === null).length,
|
|
129
|
+
dns: assets.filter((asset) => !asset.dnsProviders.length).length,
|
|
130
|
+
mail: assets.filter((asset) => !asset.mailProviders.length).length,
|
|
131
|
+
certificate: assets.filter((asset) => !asset.certificateProviders.length).length,
|
|
132
|
+
recovery: assets.filter((asset) => !asset.recoveryDomains.length).length,
|
|
133
|
+
});
|
|
134
|
+
return Object.freeze({
|
|
135
|
+
schema: DOMAIN_PORTFOLIO_REVIEW_SCHEMA,
|
|
136
|
+
version: DOMAIN_PORTFOLIO_REVIEW_VERSION,
|
|
137
|
+
generatedAt: new Date(now).toISOString(),
|
|
138
|
+
portfolioLabel,
|
|
139
|
+
assets: Object.freeze(assets),
|
|
140
|
+
simulations: Object.freeze(simulations),
|
|
141
|
+
renewalQueue: Object.freeze(renewalQueue),
|
|
142
|
+
recoveryCycles: Object.freeze(recoveryCycles),
|
|
143
|
+
unknownCounts,
|
|
144
|
+
limitations: Object.freeze([
|
|
145
|
+
'This local review uses analyst-supplied portfolio assertions and makes no registrar, DNS, mail, certificate, account, or recovery request.',
|
|
146
|
+
'Provider and account labels are not proof of current configuration, ownership, control, availability, or contractual responsibility.',
|
|
147
|
+
'Failure simulations show exact supplied dependency concentration only; they do not predict an outage or establish that providers share infrastructure.',
|
|
148
|
+
'Do not include credentials, recovery codes, personal contact details, account identifiers, or other secrets in this input.',
|
|
149
|
+
]),
|
|
150
|
+
});
|
|
151
|
+
}
|
package/lib/evidence-export.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { buildRegistryInsights } from "./registry-insights.mjs";
|
|
|
3
3
|
import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "./project-metadata.mjs";
|
|
4
4
|
import { normalizeBoundedSemanticVersion } from "./semantic-version.mjs";
|
|
5
5
|
export const LOOKUP_EVIDENCE_SCHEMA = 'whoisleuth.lookup-evidence';
|
|
6
|
-
export const LOOKUP_EVIDENCE_SCHEMA_VERSION =
|
|
6
|
+
export const LOOKUP_EVIDENCE_SCHEMA_VERSION = 25;
|
|
7
7
|
const REGISTRAR_RDAP_STATUSES = new Set([
|
|
8
8
|
'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
|
|
9
9
|
]);
|
package/lib/html-signals.mjs
CHANGED
|
@@ -9,13 +9,13 @@ import { analyzeCredentialSurfaceProfile } from "./credential-surface-profile.mj
|
|
|
9
9
|
import { analyzeClientBehavior } from "./client-behavior-profile.mjs";
|
|
10
10
|
import { createObservation } from "./observation.mjs";
|
|
11
11
|
import { createPageFingerprints } from "./page-fingerprints.mjs";
|
|
12
|
+
import { detectPageLanguageSignal } from "./page-language-signals.mjs";
|
|
12
13
|
import { analyzePageRole } from "./page-role-profile.mjs";
|
|
13
14
|
import { analyzeCspMetaPolicies } from "./response-policy.mjs";
|
|
14
15
|
import { analyzeStaticHtml } from "./static-html-analysis.mjs";
|
|
15
16
|
import { analyzeStructuredDataIdentity } from "./structured-data-identity.mjs";
|
|
16
17
|
import { analyzeWebsiteTechnology } from "./website-technology.mjs";
|
|
17
18
|
const MAX_TITLE_LENGTH = 200;
|
|
18
|
-
const MAX_PHISHING_MATCH_LENGTH = 200;
|
|
19
19
|
const MAX_EXTERNAL_ASSET_HOSTS = 20;
|
|
20
20
|
const MAX_IDENTITY_TAGS = 512;
|
|
21
21
|
const MAX_IDENTITY_TAG_LENGTH = 4096;
|
|
@@ -40,12 +40,6 @@ const TITLE_RE = /<title[^>]*>([\s\S]*?)<\/title>/i;
|
|
|
40
40
|
// A password input is the single strongest static-HTML tell that a page is
|
|
41
41
|
// asking for credentials, regardless of what it claims to be.
|
|
42
42
|
const PASSWORD_FIELD_RE = /<input\b[^>]*\btype\s*=\s*["']?password["']?/i;
|
|
43
|
-
// Common urgency/social-engineering phrasing used to pressure a visitor
|
|
44
|
-
// into entering credentials - not exhaustive, and legitimate sites
|
|
45
|
-
// occasionally use similar language too, so this is one signal among
|
|
46
|
-
// several, not a verdict on its own (same framing as FOR_SALE_TEXT_RE in
|
|
47
|
-
// availability.js).
|
|
48
|
-
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;
|
|
49
43
|
// <img>/<script>/<link> tags loading a resource from an absolute, external
|
|
50
44
|
// URL - a common phishing-kit tell is hotlinking the real brand's own logo/
|
|
51
45
|
// CSS/JS instead of copying it. Relative URLs (the common case, same
|
|
@@ -625,7 +619,6 @@ function extractPageIdentity(html, domain, options = {}) {
|
|
|
625
619
|
};
|
|
626
620
|
}
|
|
627
621
|
function extractHtmlSignals(html, domain, options = {}) {
|
|
628
|
-
const phishingMatch = html.match(PHISHING_LANGUAGE_RE);
|
|
629
622
|
const pageIdentity = options.includePageIdentity === false ? null : extractPageIdentity(html, domain, options);
|
|
630
623
|
const includeCredentialSurfaceProfile = pageIdentity && options.includeCredentialSurfaceProfile === true;
|
|
631
624
|
const includeStructuredDataIdentity = pageIdentity && options.includeStructuredDataIdentity !== false;
|
|
@@ -633,12 +626,13 @@ function extractHtmlSignals(html, domain, options = {}) {
|
|
|
633
626
|
const includeDerivedPageProfiles = Boolean(pageIdentity);
|
|
634
627
|
const baseUrl = resolvedBaseUrl(domain, options.baseUrl);
|
|
635
628
|
const htmlAnalysis = includeCredentialSurfaceProfile || includeStructuredDataIdentity || includeTechnologyProfile || includeDerivedPageProfiles
|
|
636
|
-
? analyzeStaticHtml(html, { baseUrl })
|
|
629
|
+
? analyzeStaticHtml(html, { baseUrl, includeVisibleText: true })
|
|
637
630
|
: null;
|
|
631
|
+
const pageLanguageSignal = detectPageLanguageSignal(html, pageIdentity?.documentLanguage, htmlAnalysis ?? undefined);
|
|
638
632
|
return {
|
|
639
633
|
pageTitle: extractPageTitle(html),
|
|
640
634
|
hasPasswordField: PASSWORD_FIELD_RE.test(html),
|
|
641
|
-
phishingLanguageMatch:
|
|
635
|
+
phishingLanguageMatch: pageLanguageSignal?.label ?? null,
|
|
642
636
|
hasExternalFormAction: pageIdentity
|
|
643
637
|
? pageIdentity.forms.externalActionOrigins.length > 0
|
|
644
638
|
: null,
|
|
@@ -681,4 +675,4 @@ function extractHtmlSignals(html, domain, options = {}) {
|
|
|
681
675
|
}) : null,
|
|
682
676
|
};
|
|
683
677
|
}
|
|
684
|
-
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,
|
|
678
|
+
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, };
|
package/lib/lookup-cache.mjs
CHANGED
|
@@ -42,7 +42,7 @@ function approxByteSize(value) {
|
|
|
42
42
|
return Buffer.byteLength(JSON.stringify(value));
|
|
43
43
|
}
|
|
44
44
|
catch {
|
|
45
|
-
return
|
|
45
|
+
return null;
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
function deleteEntry(key) {
|
|
@@ -76,6 +76,10 @@ function setCached(key, value) {
|
|
|
76
76
|
sweepExpiredEntries(Date.now());
|
|
77
77
|
deleteEntry(key); // avoid double-counting bytes if this key is already cached
|
|
78
78
|
const size = approxByteSize(value);
|
|
79
|
+
// A value whose serialized size cannot be measured cannot participate in a
|
|
80
|
+
// byte-bounded cache. Return it to the current caller without retaining it.
|
|
81
|
+
if (size === null)
|
|
82
|
+
return;
|
|
79
83
|
store.set(key, { value, expiresAt: Date.now() + TTL_MS, size });
|
|
80
84
|
totalBytes += size;
|
|
81
85
|
while (store.size > 0 && (store.size > MAX_ENTRIES || totalBytes > MAX_TOTAL_BYTES)) {
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
|
|
3
|
+
import { isPrivateAddress } from "./safe-fetch.mjs";
|
|
4
|
+
export const NAMESERVER_PREFLIGHT_INPUT_SCHEMA = 'whoisleuth.nameserver-preflight.input';
|
|
5
|
+
export const NAMESERVER_PREFLIGHT_REVIEW_SCHEMA = 'whoisleuth.nameserver-preflight.review';
|
|
6
|
+
export const NAMESERVER_PREFLIGHT_REVIEW_VERSION = 1;
|
|
7
|
+
export const MAX_PREFLIGHT_NAMESERVERS = 8;
|
|
8
|
+
export const MAX_PREFLIGHT_ADDRESSES = 4;
|
|
9
|
+
const ROOT_KEYS = new Set(['schema', 'version', 'domain', 'intendedNameservers', 'observations']);
|
|
10
|
+
const OBSERVATION_KEYS = new Set(['nameserver', 'state', 'source', 'observedAt', 'addresses', 'authoritative', 'servedNameservers', 'soaPrimary', 'soaSerial']);
|
|
11
|
+
function text(value, label, maximum = 240) {
|
|
12
|
+
return requireBoundedString(value, label, maximum);
|
|
13
|
+
}
|
|
14
|
+
function hostname(value, label) {
|
|
15
|
+
return requireDomainName(value, label, 'hostname');
|
|
16
|
+
}
|
|
17
|
+
function timestamp(value, label) {
|
|
18
|
+
return requireIsoTimestamp(value, label);
|
|
19
|
+
}
|
|
20
|
+
function state(value, label) {
|
|
21
|
+
if (value !== 'observed' && value !== 'partial' && value !== 'unavailable')
|
|
22
|
+
throw new TypeError(`${label} is unsupported.`);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
function uniqueHostnames(value, label, minimum = 1) {
|
|
26
|
+
if (!Array.isArray(value) || value.length < minimum || value.length > MAX_PREFLIGHT_NAMESERVERS) {
|
|
27
|
+
throw new TypeError(`${label} must contain from ${minimum} to ${MAX_PREFLIGHT_NAMESERVERS} hostnames.`);
|
|
28
|
+
}
|
|
29
|
+
const names = value.map((item, index) => hostname(item, `${label}[${index}]`));
|
|
30
|
+
if (new Set(names).size !== names.length)
|
|
31
|
+
throw new TypeError(`${label} must not contain duplicate hostnames.`);
|
|
32
|
+
return Object.freeze(names.sort());
|
|
33
|
+
}
|
|
34
|
+
function addresses(value, label) {
|
|
35
|
+
if (!Array.isArray(value) || value.length > MAX_PREFLIGHT_ADDRESSES)
|
|
36
|
+
throw new TypeError(`${label} must contain no more than ${MAX_PREFLIGHT_ADDRESSES} addresses.`);
|
|
37
|
+
const normalised = value.map((item, index) => {
|
|
38
|
+
const supplied = text(item, `${label}[${index}]`, 80).toLowerCase();
|
|
39
|
+
if (!isIP(supplied) || supplied.includes('%'))
|
|
40
|
+
throw new TypeError(`${label}[${index}] must be an IPv4 or IPv6 address without a zone identifier.`);
|
|
41
|
+
return supplied;
|
|
42
|
+
});
|
|
43
|
+
return Object.freeze([...new Set(normalised)].sort());
|
|
44
|
+
}
|
|
45
|
+
function sameSet(left, right) {
|
|
46
|
+
return left.length === right.length && left.every((item, index) => item === right[index]);
|
|
47
|
+
}
|
|
48
|
+
export function reviewNameserverPreflight(inputRaw, generatedAtValue = new Date().toISOString()) {
|
|
49
|
+
const input = requireRecord(inputRaw, 'Nameserver preflight input');
|
|
50
|
+
if (input.schema !== NAMESERVER_PREFLIGHT_INPUT_SCHEMA || input.version !== 1) {
|
|
51
|
+
throw new TypeError(`Nameserver preflight input must use ${NAMESERVER_PREFLIGHT_INPUT_SCHEMA} version 1.`);
|
|
52
|
+
}
|
|
53
|
+
exactKeys(input, ROOT_KEYS, 'Nameserver preflight input');
|
|
54
|
+
const domain = hostname(input.domain, 'domain');
|
|
55
|
+
const intendedNameservers = uniqueHostnames(input.intendedNameservers, 'intendedNameservers');
|
|
56
|
+
if (!Array.isArray(input.observations) || input.observations.length > MAX_PREFLIGHT_NAMESERVERS) {
|
|
57
|
+
throw new TypeError(`observations must contain no more than ${MAX_PREFLIGHT_NAMESERVERS} entries.`);
|
|
58
|
+
}
|
|
59
|
+
const observations = input.observations.map((raw, index) => {
|
|
60
|
+
const item = requireRecord(raw, `observations[${index}]`);
|
|
61
|
+
exactKeys(item, OBSERVATION_KEYS, `observations[${index}]`);
|
|
62
|
+
const observationState = state(item.state, `observations[${index}].state`);
|
|
63
|
+
const nameserver = hostname(item.nameserver, `observations[${index}].nameserver`);
|
|
64
|
+
const observedAddresses = addresses(item.addresses ?? [], `observations[${index}].addresses`);
|
|
65
|
+
const servedNameservers = item.servedNameservers === undefined || item.servedNameservers === null
|
|
66
|
+
? Object.freeze([])
|
|
67
|
+
: uniqueHostnames(item.servedNameservers, `observations[${index}].servedNameservers`, 0);
|
|
68
|
+
const authoritative = item.authoritative === null || item.authoritative === undefined
|
|
69
|
+
? null
|
|
70
|
+
: typeof item.authoritative === 'boolean'
|
|
71
|
+
? item.authoritative
|
|
72
|
+
: (() => { throw new TypeError(`observations[${index}].authoritative must be true, false, or null.`); })();
|
|
73
|
+
const soaPrimary = item.soaPrimary === null || item.soaPrimary === undefined ? null : hostname(item.soaPrimary, `observations[${index}].soaPrimary`);
|
|
74
|
+
const soaSerial = item.soaSerial === null || item.soaSerial === undefined
|
|
75
|
+
? null
|
|
76
|
+
: Number.isSafeInteger(item.soaSerial) && Number(item.soaSerial) >= 0 && Number(item.soaSerial) <= 0xffff_ffff
|
|
77
|
+
? Number(item.soaSerial)
|
|
78
|
+
: (() => { throw new TypeError(`observations[${index}].soaSerial is outside its supported range.`); })();
|
|
79
|
+
if (observationState === 'unavailable' && (observedAddresses.length || servedNameservers.length || authoritative !== null || soaPrimary !== null || soaSerial !== null)) {
|
|
80
|
+
throw new TypeError(`observations[${index}] cannot contain observed values when unavailable.`);
|
|
81
|
+
}
|
|
82
|
+
return Object.freeze({
|
|
83
|
+
nameserver,
|
|
84
|
+
state: observationState,
|
|
85
|
+
source: text(item.source, `observations[${index}].source`, 240),
|
|
86
|
+
observedAt: timestamp(item.observedAt, `observations[${index}].observedAt`),
|
|
87
|
+
addresses: observedAddresses,
|
|
88
|
+
authoritative,
|
|
89
|
+
servedNameservers,
|
|
90
|
+
soaPrimary,
|
|
91
|
+
soaSerial,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
if (new Set(observations.map((item) => item.nameserver)).size !== observations.length)
|
|
95
|
+
throw new TypeError('observations must use unique nameservers.');
|
|
96
|
+
if (observations.some((item) => !intendedNameservers.includes(item.nameserver)))
|
|
97
|
+
throw new TypeError('observations can describe only intended nameservers.');
|
|
98
|
+
const rows = intendedNameservers.map((nameserver) => {
|
|
99
|
+
const observation = observations.find((item) => item.nameserver === nameserver) ?? null;
|
|
100
|
+
const inBailiwick = nameserver === domain || nameserver.endsWith(`.${domain}`);
|
|
101
|
+
const publicAddresses = observation?.addresses.filter((address) => !isPrivateAddress(address)) ?? [];
|
|
102
|
+
const addressState = !observation || observation.state === 'unavailable'
|
|
103
|
+
? 'unknown'
|
|
104
|
+
: !observation.addresses.length
|
|
105
|
+
? inBailiwick ? 'missing' : 'not_supplied'
|
|
106
|
+
: publicAddresses.length ? 'ready' : 'non_public';
|
|
107
|
+
const authorityState = !observation || observation.state !== 'observed' || observation.authoritative === null
|
|
108
|
+
? 'unknown'
|
|
109
|
+
: observation.authoritative ? 'authoritative' : 'not_authoritative';
|
|
110
|
+
const servedSetState = !observation || observation.state !== 'observed' || !observation.servedNameservers.length
|
|
111
|
+
? 'unknown'
|
|
112
|
+
: sameSet(observation.servedNameservers, intendedNameservers) ? 'aligned' : 'different';
|
|
113
|
+
const soaState = !observation || observation.state !== 'observed' || !observation.soaPrimary || observation.soaSerial === null
|
|
114
|
+
? 'unknown' : 'observed';
|
|
115
|
+
const ready = observation?.state === 'observed'
|
|
116
|
+
&& authorityState === 'authoritative'
|
|
117
|
+
&& servedSetState === 'aligned'
|
|
118
|
+
&& soaState === 'observed'
|
|
119
|
+
&& (!inBailiwick || addressState === 'ready');
|
|
120
|
+
return Object.freeze({
|
|
121
|
+
nameserver,
|
|
122
|
+
inBailiwick,
|
|
123
|
+
observationState: observation?.state ?? 'not_supplied',
|
|
124
|
+
addressState,
|
|
125
|
+
authorityState,
|
|
126
|
+
servedSetState,
|
|
127
|
+
soaState,
|
|
128
|
+
publicAddresses: Object.freeze(publicAddresses),
|
|
129
|
+
ready,
|
|
130
|
+
source: observation?.source ?? null,
|
|
131
|
+
observedAt: observation?.observedAt ?? null,
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
const reasons = rows.flatMap((row) => [
|
|
135
|
+
...(!row.ready && row.observationState !== 'observed' ? [`${row.nameserver} evidence is ${row.observationState}.`] : []),
|
|
136
|
+
...(row.addressState === 'missing' ? [`${row.nameserver} is in-bailiwick but no glue address was supplied.`] : []),
|
|
137
|
+
...(row.addressState === 'non_public' ? [`${row.nameserver} has no observed public address suitable for delegation.`] : []),
|
|
138
|
+
...(row.authorityState !== 'authoritative' ? [`${row.nameserver} authoritative service is ${row.authorityState}.`] : []),
|
|
139
|
+
...(row.servedSetState !== 'aligned' ? [`${row.nameserver} served NS set is ${row.servedSetState}.`] : []),
|
|
140
|
+
...(row.soaState !== 'observed' ? [`${row.nameserver} SOA evidence is ${row.soaState}.`] : []),
|
|
141
|
+
]);
|
|
142
|
+
const pass = rows.every((row) => row.ready);
|
|
143
|
+
return Object.freeze({
|
|
144
|
+
schema: NAMESERVER_PREFLIGHT_REVIEW_SCHEMA,
|
|
145
|
+
version: NAMESERVER_PREFLIGHT_REVIEW_VERSION,
|
|
146
|
+
generatedAt: timestamp(generatedAtValue, 'generatedAt'),
|
|
147
|
+
domain,
|
|
148
|
+
state: pass ? 'ready' : 'review',
|
|
149
|
+
intendedNameservers,
|
|
150
|
+
rows: Object.freeze(rows),
|
|
151
|
+
gate: Object.freeze({ pass, reasons: Object.freeze(reasons) }),
|
|
152
|
+
limitations: Object.freeze([
|
|
153
|
+
'This local preflight uses only analyst-supplied observations and makes no DNS or registry request.',
|
|
154
|
+
'A ready result describes the supplied collection moment; it does not apply a delegation, publish glue, or prove continued reachability.',
|
|
155
|
+
'Partial, unavailable, and omitted observations remain unknown and cannot satisfy the preflight gate.',
|
|
156
|
+
'Only in-bailiwick nameservers require supplied public glue evidence in this review; registry-specific policy must be checked separately.',
|
|
157
|
+
]),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Reviewed, bounded language packs for static homepage text already retained by
|
|
2
|
+
// Deep Lookup. Matching emits fixed labels only: no page phrase, surrounding
|
|
3
|
+
// text, or arbitrary markup is returned or stored.
|
|
4
|
+
import { analyzeStaticHtml, MAX_STATIC_HTML_CHARS, } from "./static-html-analysis.mjs";
|
|
5
|
+
export const PAGE_LANGUAGE_SIGNAL_VERSION = 1;
|
|
6
|
+
export const MAX_PAGE_LANGUAGE_SIGNAL_CHARS = MAX_STATIC_HTML_CHARS;
|
|
7
|
+
const CONTROL_CHARACTER_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
|
|
8
|
+
const CATEGORY_LABELS = Object.freeze({
|
|
9
|
+
account_verification: 'account-verification',
|
|
10
|
+
account_restriction: 'account-restriction',
|
|
11
|
+
payment_update: 'payment-update',
|
|
12
|
+
recovery_secret: 'wallet or recovery-secret',
|
|
13
|
+
urgent_action: 'urgent-action',
|
|
14
|
+
});
|
|
15
|
+
const PAGE_LANGUAGE_PACKS = Object.freeze([
|
|
16
|
+
{
|
|
17
|
+
id: 'en-reviewed-v1', language: 'en', label: 'English', patterns: [
|
|
18
|
+
{ category: 'account_verification', expression: /(?:verify your account|confirm your identity|unusual (?:sign-?in|login) activity|click here to (?:verify|confirm|update|restore)|confirm your password)/iu },
|
|
19
|
+
{ category: 'account_restriction', expression: /(?:account has been (?:suspended|limited|locked|restricted)|your account will be (?:suspended|closed|locked|terminated)|re-?activate your account|unauthorized access detected|your password (?:has expired|will expire soon))/iu },
|
|
20
|
+
{ category: 'urgent_action', expression: /(?:security alert|immediate action required)/iu },
|
|
21
|
+
{ category: 'payment_update', expression: /update your (?:payment|billing) (?:information|details)/iu },
|
|
22
|
+
{ category: 'recovery_secret', expression: /(?:(?:connect|verify) your wallet|(?:enter|verify) your (?:seed|recovery) phrase|enter your private key)/iu },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'es-reviewed-v1', language: 'es', label: 'Spanish', patterns: [
|
|
27
|
+
{ category: 'account_verification', expression: /(?:verifica (?:tu|su) cuenta|confirma (?:tu|su) identidad)/iu },
|
|
28
|
+
{ category: 'account_restriction', expression: /(?:tu|su) cuenta (?:ha sido|será) (?:suspendida|bloqueada|limitada|restringida)/iu },
|
|
29
|
+
{ category: 'urgent_action', expression: /(?:acción inmediata requerida|alerta de seguridad)/iu },
|
|
30
|
+
{ category: 'payment_update', expression: /actualiza (?:tu|su) (?:información|datos) de pago/iu },
|
|
31
|
+
{ category: 'recovery_secret', expression: /(?:introduce|ingrese) (?:tu|su) frase de recuperación/iu },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'fr-reviewed-v1', language: 'fr', label: 'French', patterns: [
|
|
36
|
+
{ category: 'account_verification', expression: /(?:vérifiez votre compte|confirmez votre identité|veuillez confirmer votre identité)/iu },
|
|
37
|
+
{ category: 'account_restriction', expression: /votre compte (?:a été|sera) (?:suspendu|bloqué|limité|restreint)/iu },
|
|
38
|
+
{ category: 'urgent_action', expression: /(?:action immédiate requise|alerte de sécurité)/iu },
|
|
39
|
+
{ category: 'payment_update', expression: /mettez à jour vos (?:informations|coordonnées) de paiement/iu },
|
|
40
|
+
{ category: 'recovery_secret', expression: /saisissez votre phrase de récupération/iu },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'de-reviewed-v1', language: 'de', label: 'German', patterns: [
|
|
45
|
+
{ category: 'account_verification', expression: /(?:bestätigen sie ihr konto|bestätigen sie ihre identität)/iu },
|
|
46
|
+
{ category: 'account_restriction', expression: /ihr konto wurde (?:gesperrt|eingeschränkt|suspendiert)/iu },
|
|
47
|
+
{ category: 'urgent_action', expression: /(?:sofortiges handeln erforderlich|sicherheitswarnung)/iu },
|
|
48
|
+
{ category: 'payment_update', expression: /aktualisieren sie ihre zahlungsdaten/iu },
|
|
49
|
+
{ category: 'recovery_secret', expression: /geben sie ihre wiederherstellungsphrase ein/iu },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'pt-reviewed-v1', language: 'pt', label: 'Portuguese', patterns: [
|
|
54
|
+
{ category: 'account_verification', expression: /(?:verifique sua conta|confirme sua identidade)/iu },
|
|
55
|
+
{ category: 'account_restriction', expression: /sua conta (?:foi|será) (?:suspensa|bloqueada|limitada|restrita)/iu },
|
|
56
|
+
{ category: 'urgent_action', expression: /(?:ação imediata necessária|alerta de segurança)/iu },
|
|
57
|
+
{ category: 'payment_update', expression: /atualize suas informações de pagamento/iu },
|
|
58
|
+
{ category: 'recovery_secret', expression: /insira sua frase de recuperação/iu },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: 'it-reviewed-v1', language: 'it', label: 'Italian', patterns: [
|
|
63
|
+
{ category: 'account_verification', expression: /(?:verifica il tuo account|conferma la tua identità)/iu },
|
|
64
|
+
{ category: 'account_restriction', expression: /il tuo account (?:è stato|sarà) (?:sospeso|bloccato|limitato)/iu },
|
|
65
|
+
{ category: 'urgent_action', expression: /(?:azione immediata richiesta|avviso di sicurezza)/iu },
|
|
66
|
+
{ category: 'payment_update', expression: /aggiorna (?:i tuoi dati|le tue informazioni) di pagamento/iu },
|
|
67
|
+
{ category: 'recovery_secret', expression: /inserisci la (?:tua )?frase di recupero/iu },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'nl-reviewed-v1', language: 'nl', label: 'Dutch', patterns: [
|
|
72
|
+
{ category: 'account_verification', expression: /(?:verifieer uw account|bevestig uw identiteit)/iu },
|
|
73
|
+
{ category: 'account_restriction', expression: /uw account (?:is|wordt) (?:geblokkeerd|beperkt|opgeschort)/iu },
|
|
74
|
+
{ category: 'urgent_action', expression: /(?:onmiddellijke actie vereist|beveiligingswaarschuwing)/iu },
|
|
75
|
+
{ category: 'payment_update', expression: /werk uw betalingsgegevens bij/iu },
|
|
76
|
+
{ category: 'recovery_secret', expression: /voer uw herstelzin in/iu },
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
]);
|
|
80
|
+
function preferredPacks(documentLanguage) {
|
|
81
|
+
if (typeof documentLanguage !== 'string')
|
|
82
|
+
return PAGE_LANGUAGE_PACKS;
|
|
83
|
+
const primary = documentLanguage.trim().toLowerCase().split('-', 1)[0];
|
|
84
|
+
if (!primary)
|
|
85
|
+
return PAGE_LANGUAGE_PACKS;
|
|
86
|
+
const declared = PAGE_LANGUAGE_PACKS.filter((pack) => pack.language === primary);
|
|
87
|
+
return declared.length
|
|
88
|
+
? Object.freeze([...declared, ...PAGE_LANGUAGE_PACKS.filter((pack) => pack.language !== primary)])
|
|
89
|
+
: PAGE_LANGUAGE_PACKS;
|
|
90
|
+
}
|
|
91
|
+
export function detectPageLanguageSignal(input, documentLanguage, suppliedAnalysis) {
|
|
92
|
+
if (typeof input !== 'string' || !input || input.length > MAX_PAGE_LANGUAGE_SIGNAL_CHARS || CONTROL_CHARACTER_RE.test(input)) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const analysis = suppliedAnalysis ?? analyzeStaticHtml(input, { includeVisibleText: true });
|
|
96
|
+
if (analysis.inputLimitReached || analysis.visibleTextLimitReached)
|
|
97
|
+
return null;
|
|
98
|
+
const visibleText = analysis.visibleText
|
|
99
|
+
.replace(/\s+/gu, ' ')
|
|
100
|
+
.trim();
|
|
101
|
+
if (!visibleText)
|
|
102
|
+
return null;
|
|
103
|
+
for (const pack of preferredPacks(documentLanguage)) {
|
|
104
|
+
for (const pattern of pack.patterns) {
|
|
105
|
+
if (!pattern.expression.test(visibleText))
|
|
106
|
+
continue;
|
|
107
|
+
return Object.freeze({
|
|
108
|
+
version: PAGE_LANGUAGE_SIGNAL_VERSION,
|
|
109
|
+
packId: pack.id,
|
|
110
|
+
language: pack.language,
|
|
111
|
+
category: pattern.category,
|
|
112
|
+
label: `Reviewed ${pack.label} ${CATEGORY_LABELS[pattern.category]} language`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
export function pageLanguagePackCatalogue() {
|
|
119
|
+
return Object.freeze(PAGE_LANGUAGE_PACKS.map((pack) => Object.freeze({
|
|
120
|
+
id: pack.id,
|
|
121
|
+
language: pack.language,
|
|
122
|
+
categories: Object.freeze([...new Set(pack.patterns.map((pattern) => pattern.category))]),
|
|
123
|
+
})));
|
|
124
|
+
}
|
package/lib/rdap-transport.mjs
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
// lib/rdap.mts.
|
|
4
4
|
import { readTextCapped, safeFetch, safeFetchDetailed } from "./safe-fetch.mjs";
|
|
5
5
|
const MAX_RDAP_BYTES = 2000000;
|
|
6
|
-
async function fetchRdapWithTimeout(url, options, timeoutMs) {
|
|
6
|
+
async function fetchRdapWithTimeout(url, options, timeoutMs, dependencies = {}) {
|
|
7
7
|
const controller = new AbortController();
|
|
8
8
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
9
9
|
try {
|
|
10
|
-
const response = await safeFetch(url, { ...options, signal: controller.signal });
|
|
11
|
-
const { text, truncated } = await readTextCapped(response, MAX_RDAP_BYTES);
|
|
10
|
+
const response = await (dependencies.fetch ?? safeFetch)(url, { ...options, signal: controller.signal });
|
|
11
|
+
const { text, truncated } = await (dependencies.readText ?? readTextCapped)(response, MAX_RDAP_BYTES);
|
|
12
12
|
if (truncated)
|
|
13
13
|
throw new Error(`Response from ${url} exceeded ${MAX_RDAP_BYTES} bytes`);
|
|
14
14
|
return { status: response.status, ok: response.ok, text };
|
|
@@ -17,12 +17,12 @@ async function fetchRdapWithTimeout(url, options, timeoutMs) {
|
|
|
17
17
|
clearTimeout(timeout);
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
-
async function fetchRdapDetailedWithTimeout(url, options, timeoutMs) {
|
|
20
|
+
async function fetchRdapDetailedWithTimeout(url, options, timeoutMs, dependencies = {}) {
|
|
21
21
|
const controller = new AbortController();
|
|
22
22
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
23
23
|
try {
|
|
24
|
-
const result = await safeFetchDetailed(url, { ...options, signal: controller.signal });
|
|
25
|
-
const { text, truncated } = await readTextCapped(result.response, MAX_RDAP_BYTES);
|
|
24
|
+
const result = await (dependencies.fetchDetailed ?? safeFetchDetailed)(url, { ...options, signal: controller.signal });
|
|
25
|
+
const { text, truncated } = await (dependencies.readText ?? readTextCapped)(result.response, MAX_RDAP_BYTES);
|
|
26
26
|
if (truncated)
|
|
27
27
|
throw new Error(`Response from ${url} exceeded ${MAX_RDAP_BYTES} bytes`);
|
|
28
28
|
return {
|
package/lib/risk-scoring.mjs
CHANGED
|
@@ -23,6 +23,14 @@ const FAMILY_CAPS = Object.freeze({
|
|
|
23
23
|
'external-intelligence': 18,
|
|
24
24
|
'operational-support': 12,
|
|
25
25
|
});
|
|
26
|
+
const REMOVABLE_RISK_FAMILY_MAP = Object.freeze({
|
|
27
|
+
'brand-presentation': true,
|
|
28
|
+
'credential-lure': true,
|
|
29
|
+
'domain-resemblance': true,
|
|
30
|
+
'external-intelligence': true,
|
|
31
|
+
'operational-support': true,
|
|
32
|
+
});
|
|
33
|
+
const REMOVABLE_RISK_FAMILIES = Object.freeze(Object.keys(REMOVABLE_RISK_FAMILY_MAP));
|
|
26
34
|
const HIGH_CONTEXT_MUTATIONS = new Set([
|
|
27
35
|
'unicode_homoglyph',
|
|
28
36
|
'unicode_homoglyph_depth_2',
|
|
@@ -105,13 +113,15 @@ function scoreQuality(input) {
|
|
|
105
113
|
}
|
|
106
114
|
// Risk prioritizes a registered lookalike/typosquat domain for analyst review.
|
|
107
115
|
// It is a heuristic indicator, never a maliciousness or safety verdict.
|
|
108
|
-
|
|
116
|
+
function explainRiskScoreInternal(input, excludedFamily) {
|
|
109
117
|
const state = input.availability ?? input.state;
|
|
110
118
|
if (typeof state !== 'string' || !RISK_STATES.has(state))
|
|
111
119
|
return null;
|
|
112
120
|
const factors = [];
|
|
113
121
|
const familyTotals = new Map();
|
|
114
122
|
const add = (family, label, requestedDelta) => {
|
|
123
|
+
if (family === excludedFamily)
|
|
124
|
+
return 0;
|
|
115
125
|
const current = familyTotals.get(family) ?? 0;
|
|
116
126
|
const cap = FAMILY_CAPS[family] ?? Number.POSITIVE_INFINITY;
|
|
117
127
|
const delta = Math.max(0, Math.min(requestedDelta, Math.max(0, cap - current)));
|
|
@@ -193,6 +203,37 @@ export function explainRiskScore(input) {
|
|
|
193
203
|
evidenceQuality: scoreQuality(input),
|
|
194
204
|
};
|
|
195
205
|
}
|
|
206
|
+
export function explainRiskScore(input) {
|
|
207
|
+
return explainRiskScoreInternal(input, null);
|
|
208
|
+
}
|
|
209
|
+
export function buildRiskScoreSensitivity(input) {
|
|
210
|
+
const baseline = explainRiskScoreInternal(input, null);
|
|
211
|
+
if (!baseline)
|
|
212
|
+
return null;
|
|
213
|
+
const removableFamilies = REMOVABLE_RISK_FAMILIES
|
|
214
|
+
.filter((family) => baseline.families.some((item) => item.id === family && item.contribution > 0));
|
|
215
|
+
const scenarios = removableFamilies.map((excludedFamily) => {
|
|
216
|
+
const result = explainRiskScoreInternal(input, excludedFamily);
|
|
217
|
+
const score = result?.score ?? baseline.score;
|
|
218
|
+
return Object.freeze({ excludedFamily, score, difference: score - baseline.score });
|
|
219
|
+
}).sort((left, right) => left.score - right.score || left.excludedFamily.localeCompare(right.excludedFamily));
|
|
220
|
+
const minimumScenarioScore = scenarios[0]?.score ?? baseline.score;
|
|
221
|
+
const thresholdState = baseline.score < RISK_REVIEW_THRESHOLD
|
|
222
|
+
? 'below'
|
|
223
|
+
: minimumScenarioScore < RISK_REVIEW_THRESHOLD ? 'crosses' : 'stable_above';
|
|
224
|
+
return Object.freeze({
|
|
225
|
+
version: 1,
|
|
226
|
+
baselineScore: baseline.score,
|
|
227
|
+
reviewThreshold: RISK_REVIEW_THRESHOLD,
|
|
228
|
+
minimumScenarioScore,
|
|
229
|
+
thresholdState,
|
|
230
|
+
scenarios: Object.freeze(scenarios),
|
|
231
|
+
limitations: Object.freeze([
|
|
232
|
+
'Each scenario removes one observed evidence family and recalculates family caps and corroboration; it does not predict missing evidence.',
|
|
233
|
+
'The sensitivity review qualifies prioritisation only and does not determine maliciousness, safety, ownership, or intent.',
|
|
234
|
+
]),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
196
237
|
export function computeRiskScore(input) {
|
|
197
238
|
return explainRiskScore(input)?.score ?? null;
|
|
198
239
|
}
|