@slicedearth/whoisleuth-cli 1.36.1 → 1.39.1
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 +17 -2
- package/bin/whoisleuth.mjs +41 -24
- package/cli/arguments.mjs +236 -5
- package/cli/artifact-verify.mjs +56 -14
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +18 -3
- package/cli/doctor.mjs +11 -5
- package/cli/evidence-command-arguments.mjs +1 -1
- package/cli/evidence-command-runner.mjs +2 -2
- package/cli/evidence-signing.mjs +4 -4
- package/cli/formatters/html.mjs +1 -1
- package/cli/formatters/json.mjs +13 -1
- package/cli/formatters/terminal.mjs +7 -3
- package/cli/investigation-plan.mjs +2 -2
- package/cli/local-mmdb-review.mjs +76 -0
- package/cli/lookalike-calibration.mjs +121 -0
- package/cli/lookup-command-runner.mjs +4 -1
- package/cli/lookup-plan.mjs +3 -3
- package/cli/lookup-reconcile.mjs +141 -0
- package/cli/lookup-timeline.mjs +1 -1
- package/cli/mail-review.mjs +1 -1
- package/cli/offline-evidence-review.mjs +20 -2
- package/cli/registry-doctor.mjs +125 -0
- package/cli/risk-calibration.mjs +117 -9
- package/cli/runner.mjs +236 -17
- package/cli/saved-lookup.mjs +2 -2
- package/cli/sharing-review.mjs +202 -0
- package/cli/source-reliability.mjs +1 -1
- package/docs/cli-reference.md +1110 -0
- package/docs/cli.md +224 -940
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +6 -6
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
- package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
- package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
- package/frontend/src/lib/analysis/case-migration-model.js +6 -1
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-core.js +5 -1
- package/frontend/src/lib/analysis/case-record-operations.js +6 -1
- package/frontend/src/lib/analysis/case-response-packet.js +7 -7
- package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
- package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
- package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +480 -0
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +74 -0
- package/frontend/src/lib/analysis/lookup-response.js +2 -0
- package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
- package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
- package/frontend/src/lib/analysis/page-similarity.js +1 -1
- package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
- package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
- package/frontend/src/lib/analysis/scoring.js +8 -88
- package/frontend/src/lib/analysis/shortlist-model.js +8 -4
- package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
- package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
- package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
- package/frontend/src/lib/analysis/visualization-models.js +370 -0
- package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive.js +2 -2
- package/lib/analyst-taxonomy.mjs +40 -0
- package/lib/availability.mjs +1 -1
- package/lib/browser-library-profile.mjs +15 -2
- package/lib/certificate-extension-profile.mjs +71 -0
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/domain-assurance.mjs +368 -0
- package/lib/domain-control-manifest.mjs +50 -7
- package/lib/domain-posture.mjs +9 -9
- package/lib/encrypted-dns-contract.mjs +1 -1
- package/lib/evidence-report-markdown.mjs +1 -1
- package/lib/evidence-report.mjs +1 -1
- package/lib/generated/cisa-kev-catalog.mjs +1668 -0
- package/lib/ip-prefix.mjs +34 -1
- package/lib/local-geoip-evidence.mjs +2 -2
- package/lib/lookup-response-contract.mjs +346 -0
- package/lib/observed-network-context.mjs +1 -1
- package/lib/opportunity-scoring.mjs +107 -0
- package/lib/page-fingerprints.mjs +1 -1
- package/lib/rdap-capabilities.mjs +6 -6
- package/lib/rdap-search-workbench.mjs +96 -6
- package/lib/registry-access.mjs +1 -1
- package/lib/registry-capability-catalogue.mjs +4 -4
- package/lib/registry-insights.mjs +6 -6
- package/lib/risk-scoring.mjs +206 -83
- package/lib/scoring-evidence-quality.mjs +107 -0
- package/lib/security-txt.mjs +3 -3
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threatfox-intelligence.mjs +1 -1
- package/lib/tls-intelligence.mjs +13 -3
- package/lib/urlhaus-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +5 -5
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-chain.mjs +1 -1
- package/package.json +5 -1
- package/third-party-notices.txt +911 -19
|
@@ -2,10 +2,16 @@ const RDAP_SEARCH_WORKBENCH_SCHEMA = 'whoisleuth.rdap-search-workbench';
|
|
|
2
2
|
const RDAP_SEARCH_WORKBENCH_VERSION = 1;
|
|
3
3
|
const MAX_HELP_ENTRIES = 64;
|
|
4
4
|
const MAX_QUERY_VALUE_LENGTH = 254;
|
|
5
|
-
const SEARCHABLE_RESOURCE_TYPES = new Set(['domains', 'nameservers', 'entities'
|
|
6
|
-
const RELATED_RESOURCE_TYPES = new Set(['
|
|
5
|
+
const SEARCHABLE_RESOURCE_TYPES = new Set(['domains', 'nameservers', 'entities']);
|
|
6
|
+
const RELATED_RESOURCE_TYPES = new Set(['entity']);
|
|
7
7
|
const RECOGNIZED_PROPERTIES = new Set(['handle', 'fn', 'email', 'role']);
|
|
8
8
|
const SENSITIVE_PROPERTIES = new Set(['fn', 'email']);
|
|
9
|
+
const REGISTERED_PROPERTY_PATHS = Object.freeze({
|
|
10
|
+
email: Object.freeze(["$.entities[*].vcardArray[1][?(@[0]=='email')][3]"]),
|
|
11
|
+
fn: Object.freeze(["$.entities[*].vcardArray[1][?(@[0]=='fn')][3]"]),
|
|
12
|
+
handle: Object.freeze(['$.entities[*].handle']),
|
|
13
|
+
role: Object.freeze(['$.entities[*].roles']),
|
|
14
|
+
});
|
|
9
15
|
function record(value) {
|
|
10
16
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
11
17
|
? value
|
|
@@ -136,14 +142,13 @@ function planRdapReverseSearch(help, requested) {
|
|
|
136
142
|
limitation: 'The supplied help response did not advertise this exact reverse-search tuple.',
|
|
137
143
|
});
|
|
138
144
|
}
|
|
139
|
-
const parameter = `${relatedResourceType}_${property}`;
|
|
140
145
|
const contactDisclosure = capability.disclosure === 'contact';
|
|
141
146
|
return Object.freeze({
|
|
142
147
|
schema: RDAP_SEARCH_WORKBENCH_SCHEMA,
|
|
143
148
|
version: RDAP_SEARCH_WORKBENCH_VERSION,
|
|
144
149
|
state: 'ready',
|
|
145
|
-
requestPath: `/${searchableResourceType}`,
|
|
146
|
-
query: Object.freeze({ [
|
|
150
|
+
requestPath: `/${searchableResourceType}/reverse_search/${relatedResourceType}`,
|
|
151
|
+
query: Object.freeze({ [property]: value }),
|
|
147
152
|
disclosure: Object.freeze({
|
|
148
153
|
class: capability.disclosure,
|
|
149
154
|
summary: contactDisclosure
|
|
@@ -154,4 +159,89 @@ function planRdapReverseSearch(help, requested) {
|
|
|
154
159
|
limitation: 'This is an offline request plan. It neither sends the query nor establishes result authority, completeness, or currentness.',
|
|
155
160
|
});
|
|
156
161
|
}
|
|
157
|
-
|
|
162
|
+
function inspectRdapReverseSearchResponse(value, requestedProperties = []) {
|
|
163
|
+
const document = record(value);
|
|
164
|
+
if (!document) {
|
|
165
|
+
return Object.freeze({
|
|
166
|
+
schema: RDAP_SEARCH_WORKBENCH_SCHEMA,
|
|
167
|
+
version: RDAP_SEARCH_WORKBENCH_VERSION,
|
|
168
|
+
state: 'invalid',
|
|
169
|
+
mappings: Object.freeze([]),
|
|
170
|
+
missingProperties: Object.freeze([]),
|
|
171
|
+
rejectedCount: 0,
|
|
172
|
+
truncated: false,
|
|
173
|
+
limitations: Object.freeze(['The reverse-search response was not an object.']),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const requested = [...new Set(requestedProperties
|
|
177
|
+
.map(boundedToken)
|
|
178
|
+
.filter((property) => Boolean(property)))]
|
|
179
|
+
.slice(0, RECOGNIZED_PROPERTIES.size);
|
|
180
|
+
const raw = document.reverse_search_properties_mapping;
|
|
181
|
+
if (!Array.isArray(raw)) {
|
|
182
|
+
return Object.freeze({
|
|
183
|
+
schema: RDAP_SEARCH_WORKBENCH_SCHEMA,
|
|
184
|
+
version: RDAP_SEARCH_WORKBENCH_VERSION,
|
|
185
|
+
state: 'invalid',
|
|
186
|
+
mappings: Object.freeze([]),
|
|
187
|
+
missingProperties: Object.freeze(requested),
|
|
188
|
+
rejectedCount: raw === undefined ? 0 : 1,
|
|
189
|
+
truncated: false,
|
|
190
|
+
limitations: Object.freeze([
|
|
191
|
+
'The response did not provide a valid reverse-search property mapping, so its result fields cannot be reconciled with the requested predicates.',
|
|
192
|
+
]),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
const mappings = [];
|
|
196
|
+
const seen = new Set();
|
|
197
|
+
let rejectedCount = 0;
|
|
198
|
+
const truncated = raw.length > MAX_HELP_ENTRIES;
|
|
199
|
+
for (const entry of raw.slice(0, MAX_HELP_ENTRIES)) {
|
|
200
|
+
const candidate = record(entry);
|
|
201
|
+
const property = boundedToken(candidate?.property);
|
|
202
|
+
const propertyPath = typeof candidate?.propertyPath === 'string'
|
|
203
|
+
? candidate.propertyPath.trim().slice(0, 512)
|
|
204
|
+
: '';
|
|
205
|
+
if (!property || !propertyPath || /[\x00-\x1f\x7f]/u.test(propertyPath)) {
|
|
206
|
+
rejectedCount += 1;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const key = `${property}\u0000${propertyPath}`;
|
|
210
|
+
if (seen.has(key))
|
|
211
|
+
continue;
|
|
212
|
+
seen.add(key);
|
|
213
|
+
mappings.push(Object.freeze({
|
|
214
|
+
property,
|
|
215
|
+
propertyPath,
|
|
216
|
+
state: REGISTERED_PROPERTY_PATHS[property]?.includes(propertyPath)
|
|
217
|
+
? 'registered'
|
|
218
|
+
: 'unrecognized',
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
if (truncated)
|
|
222
|
+
rejectedCount += raw.length - MAX_HELP_ENTRIES;
|
|
223
|
+
const mappedProperties = new Set(mappings.map((mapping) => mapping.property));
|
|
224
|
+
const missingProperties = requested.filter((property) => !mappedProperties.has(property));
|
|
225
|
+
const limitations = [];
|
|
226
|
+
if (missingProperties.length) {
|
|
227
|
+
limitations.push(`The response omitted mappings for requested properties: ${missingProperties.join(', ')}.`);
|
|
228
|
+
}
|
|
229
|
+
if (mappings.some((mapping) => mapping.state === 'unrecognized')) {
|
|
230
|
+
limitations.push('At least one mapping differs from the locally reviewed IANA registration and requires manual specification review.');
|
|
231
|
+
}
|
|
232
|
+
if (rejectedCount)
|
|
233
|
+
limitations.push(`${rejectedCount} malformed or excess mapping entr${rejectedCount === 1 ? 'y was' : 'ies were'} not used.`);
|
|
234
|
+
if (!mappings.length)
|
|
235
|
+
limitations.push('No usable reverse-search property mapping was retained.');
|
|
236
|
+
return Object.freeze({
|
|
237
|
+
schema: RDAP_SEARCH_WORKBENCH_SCHEMA,
|
|
238
|
+
version: RDAP_SEARCH_WORKBENCH_VERSION,
|
|
239
|
+
state: mappings.length && !limitations.length ? 'complete' : mappings.length ? 'partial' : 'invalid',
|
|
240
|
+
mappings: Object.freeze(mappings),
|
|
241
|
+
missingProperties: Object.freeze(missingProperties),
|
|
242
|
+
rejectedCount,
|
|
243
|
+
truncated,
|
|
244
|
+
limitations: Object.freeze(limitations),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
export { MAX_HELP_ENTRIES, MAX_QUERY_VALUE_LENGTH, RDAP_SEARCH_WORKBENCH_SCHEMA, RDAP_SEARCH_WORKBENCH_VERSION, normalizeRdapSearchHelp, planRdapReverseSearch, inspectRdapReverseSearchResponse, };
|
package/lib/registry-access.mjs
CHANGED
|
@@ -3,7 +3,7 @@ const REGISTRY_ACCESS_PROFILE_LABELS = Object.freeze({
|
|
|
3
3
|
'iana-referral': 'IANA referral discovery',
|
|
4
4
|
'no-iana-service': 'No service published by IANA',
|
|
5
5
|
'registry-policy-restricted': 'Registry policy restricted',
|
|
6
|
-
'source-ip-authorization-required': 'Source-IP
|
|
6
|
+
'source-ip-authorization-required': 'Source-IP authorisation required',
|
|
7
7
|
});
|
|
8
8
|
const WHOIS_REGISTRY_ACCESS_PROFILES = new Set([
|
|
9
9
|
'iana-referral',
|
|
@@ -51,9 +51,9 @@ const REGISTRY_STANDARDS_COVERAGE_SNAPSHOT = Object.freeze({
|
|
|
51
51
|
]),
|
|
52
52
|
interpretation: 'This official-source snapshot describes published service coverage at the verification date. It does not test current reachability or decide registration, availability, ownership, safety, or maliciousness.',
|
|
53
53
|
});
|
|
54
|
-
const DISCOVERY_LIMITATION = 'IANA discovery is available, but no suffix-specific query, encoding, or parser
|
|
54
|
+
const DISCOVERY_LIMITATION = 'IANA discovery is available, but no suffix-specific query, encoding, or parser behaviour is fixture-verified.';
|
|
55
55
|
const FIXTURE_LIMITATION = 'Synthetic fixtures verify the current parser profile; they do not prove current live-registry reachability, policy, or field publication.';
|
|
56
|
-
const ES_ACCESS_LIMITATION = 'The registry WHOIS service requires advance source-IP
|
|
56
|
+
const ES_ACCESS_LIMITATION = 'The registry WHOIS service requires advance source-IP authorisation. A failed or unavailable query is not evidence that the domain is unregistered.';
|
|
57
57
|
const SWITCH_ACCESS_LIMITATION = 'The registry directs public domain searches to its official lookup. Its non-standard-port Domain Check is not integrated, and IANA publishes no RDAP service. Missing registry data is not evidence that the domain is unregistered.';
|
|
58
58
|
const VN_ACCESS_LIMITATION = 'IANA publishes no domain WHOIS or RDAP service for this suffix. The official browser lookup is not integrated, and missing registry data is not evidence that the domain is unregistered.';
|
|
59
59
|
const UK_TRANSITION_LIMITATION = 'Synthetic fixtures verify the documented sectioned port-43 response while that WHOIS service is phased out. RDAP remains the preferred registry source, and fixture coverage does not prove current reachability or field publication.';
|
|
@@ -64,8 +64,8 @@ const SHARED_WHOIS_NO_RDAP_LIMITATION = 'Synthetic fixture reuse is limited to a
|
|
|
64
64
|
const WHOIS_ONLY_FIXTURE_LIMITATION = 'Synthetic fixtures verify the current WHOIS parser profile. IANA publishes no RDAP bootstrap service for this suffix, and missing registry data is not evidence that the domain is unregistered.';
|
|
65
65
|
const NEGATIVE_ONLY_FIXTURE_LIMITATION = 'Synthetic fixtures verify only the authoritative no-record dialect; registered-field compatibility is not claimed, and fixture coverage does not prove current live-registry reachability or policy.';
|
|
66
66
|
const WHOIS_ONLY_NEGATIVE_FIXTURE_LIMITATION = 'Synthetic fixtures verify only the authoritative WHOIS no-record dialect; registered-field compatibility is not claimed, IANA publishes no RDAP bootstrap service, and missing registry data is not evidence that the domain is unregistered.';
|
|
67
|
-
const UNVERIFIED_WHOIS_REFERRAL_LIMITATION = 'IANA publishes a domain WHOIS referral, but its response
|
|
68
|
-
const UNVERIFIED_WHOIS_WITH_RDAP_LIMITATION = 'IANA publishes domain WHOIS and RDAP discovery, but the WHOIS response
|
|
67
|
+
const UNVERIFIED_WHOIS_REFERRAL_LIMITATION = 'IANA publishes a domain WHOIS referral, but its response behaviour is not fixture-verified. A failed, restricted, prohibited, reserved, or otherwise inconclusive response is not evidence that the domain is unregistered, and IANA publishes no RDAP bootstrap service for this suffix.';
|
|
68
|
+
const UNVERIFIED_WHOIS_WITH_RDAP_LIMITATION = 'IANA publishes domain WHOIS and RDAP discovery, but the WHOIS response behaviour is not fixture-verified. A failed, restricted, prohibited, reserved, or otherwise inconclusive WHOIS response is not evidence that the domain is unregistered; RDAP remains separately attributed.';
|
|
69
69
|
const NORID_CLOSED_SUFFIX_LIMITATION = 'The registry has not opened this suffix for registrations, and IANA publishes no domain WHOIS or RDAP service. Missing registry data is contextual only and must not be interpreted as a live availability result.';
|
|
70
70
|
const MIL_ACCESS_LIMITATION = 'IANA publishes no public domain WHOIS or RDAP service for this sponsored suffix. Missing registry data is contextual only and is not evidence that a name is unregistered or available.';
|
|
71
71
|
const ARPA_ACCESS_LIMITATION = 'This infrastructure suffix is not ordinary public registration space. IANA publishes WHOIS metadata but no RDAP bootstrap service, and missing or non-domain-shaped data must not be interpreted as registration availability.';
|
|
@@ -83,7 +83,7 @@ function disclosure(source, parsedValue, status) {
|
|
|
83
83
|
return {
|
|
84
84
|
source,
|
|
85
85
|
state: 'unavailable',
|
|
86
|
-
detail: 'The source did not provide a usable
|
|
86
|
+
detail: 'The source did not provide a usable normalised publication.',
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
89
|
const parsed = record(parsedValue);
|
|
@@ -211,11 +211,11 @@ function publicationDiagnostic(source, parsedValue, status, observedAt) {
|
|
|
211
211
|
if (parsed.serverTruncated === true)
|
|
212
212
|
issues.push('The RDAP server declared a truncated response.');
|
|
213
213
|
if (parsed.entitiesTruncated === true)
|
|
214
|
-
issues.push('The
|
|
214
|
+
issues.push('The normalised entity inventory was capped.');
|
|
215
215
|
if (parsed.statusesTruncated === true)
|
|
216
|
-
issues.push('The
|
|
216
|
+
issues.push('The normalised status inventory was capped.');
|
|
217
217
|
if (strings(parsed.fieldsTruncated, 20).length)
|
|
218
|
-
issues.push('One or more
|
|
218
|
+
issues.push('One or more normalised WHOIS fields were capped.');
|
|
219
219
|
if (parsed.chainStatus === 'partial')
|
|
220
220
|
issues.push('The WHOIS referral chain was incomplete or conflicting.');
|
|
221
221
|
const conformance = strings(parsed.conformance, 30);
|
|
@@ -306,9 +306,9 @@ export function buildRegistryInsights(input) {
|
|
|
306
306
|
: unavailableCount
|
|
307
307
|
? 'The comparison is partial because at least one publication was unavailable or incomplete.'
|
|
308
308
|
: sourceOnlyCount || redactedCount
|
|
309
|
-
? 'The sources differ in publication or disclosure without a
|
|
309
|
+
? 'The sources differ in publication or disclosure without a normalised conflict.'
|
|
310
310
|
: counts.equivalent
|
|
311
|
-
? 'The comparable fields are
|
|
311
|
+
? 'The comparable fields are normalised as equivalent.'
|
|
312
312
|
: 'No comparable fields were available.',
|
|
313
313
|
},
|
|
314
314
|
publications: [
|
package/lib/risk-scoring.mjs
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
import { calibrateExternalIntelligenceRisk } from "./external-intelligence-risk.mjs";
|
|
2
|
+
import { buildScoreEvidenceQuality, } from "./scoring-evidence-quality.mjs";
|
|
2
3
|
const RISK_STATES = new Set(['registered', 'for_sale', 'expiring']);
|
|
3
4
|
const STATE_LABELS = Object.freeze({
|
|
4
5
|
for_sale: 'for sale',
|
|
5
6
|
expiring: 'expiring/pending delete',
|
|
6
7
|
registered: 'registered',
|
|
7
8
|
});
|
|
8
|
-
// Version
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
// on matching model versions so formula changes are not mistaken for changes
|
|
13
|
-
// in the observed domain.
|
|
14
|
-
export const RISK_MODEL_VERSION = 6;
|
|
9
|
+
// Version 7 caps each independent evidence family, treats generic operational
|
|
10
|
+
// properties as supporting context, and reports collection quality separately
|
|
11
|
+
// from the heuristic score. Missing evidence never contributes points.
|
|
12
|
+
export const RISK_MODEL_VERSION = 7;
|
|
15
13
|
export const RISK_REVIEW_THRESHOLD = 70;
|
|
16
14
|
const RISK_STATE_BASE = Object.freeze({
|
|
17
|
-
registered:
|
|
18
|
-
for_sale:
|
|
19
|
-
expiring:
|
|
15
|
+
registered: 6,
|
|
16
|
+
for_sale: 4,
|
|
17
|
+
expiring: 5,
|
|
18
|
+
});
|
|
19
|
+
const FAMILY_CAPS = Object.freeze({
|
|
20
|
+
'domain-resemblance': 20,
|
|
21
|
+
'brand-presentation': 24,
|
|
22
|
+
'credential-lure': 18,
|
|
23
|
+
'external-intelligence': 18,
|
|
24
|
+
'operational-support': 12,
|
|
20
25
|
});
|
|
21
26
|
const HIGH_CONTEXT_MUTATIONS = new Set([
|
|
22
27
|
'unicode_homoglyph',
|
|
@@ -49,7 +54,7 @@ export function normalizeRiskModelVersion(value) {
|
|
|
49
54
|
}
|
|
50
55
|
function formatAge(days) {
|
|
51
56
|
if (days < 60)
|
|
52
|
-
return `${days}d old`;
|
|
57
|
+
return `${Math.round(days)}d old`;
|
|
53
58
|
const years = days / 365.25;
|
|
54
59
|
return years < 1 ? `${Math.round(days / 30)}mo old` : `${years.toFixed(1)}y old`;
|
|
55
60
|
}
|
|
@@ -68,83 +73,201 @@ function mutationContext(mutationTypes) {
|
|
|
68
73
|
}
|
|
69
74
|
return null;
|
|
70
75
|
}
|
|
76
|
+
function hasBoolean(value) {
|
|
77
|
+
return typeof value === 'boolean';
|
|
78
|
+
}
|
|
79
|
+
function scoreQuality(input) {
|
|
80
|
+
const observedFamilies = ['registration'];
|
|
81
|
+
const expectedFamilies = ['registration', 'operational-support'];
|
|
82
|
+
if (Array.isArray(input.mutationTypes) || hasBoolean(input.idnReferenceMatch))
|
|
83
|
+
observedFamilies.push('domain-resemblance');
|
|
84
|
+
if (input.hasActiveBrandProfile === true)
|
|
85
|
+
expectedFamilies.push('domain-resemblance', 'brand-presentation');
|
|
86
|
+
if ([input.faviconMatch, input.faviconNearMatch, input.reusesOfficialAssets, input.pageBaselineMatch].some(hasBoolean)) {
|
|
87
|
+
observedFamilies.push('brand-presentation');
|
|
88
|
+
}
|
|
89
|
+
if ([input.hasPasswordField, input.hasExternalFormAction].some(hasBoolean) || typeof input.phishingLanguageMatch === 'string') {
|
|
90
|
+
observedFamilies.push('credential-lure');
|
|
91
|
+
}
|
|
92
|
+
if (input.threatIntelligence !== undefined && input.threatIntelligence !== null)
|
|
93
|
+
observedFamilies.push('external-intelligence');
|
|
94
|
+
if ([input.hasMx, input.hasSpf, input.hasDmarc].some(hasBoolean)
|
|
95
|
+
|| typeof input.activityStatus === 'string'
|
|
96
|
+
|| typeof input.domainAgeDays === 'number')
|
|
97
|
+
observedFamilies.push('operational-support');
|
|
98
|
+
if (input.scanDepth === 'deep')
|
|
99
|
+
expectedFamilies.push('credential-lure');
|
|
100
|
+
return buildScoreEvidenceQuality(input, {
|
|
101
|
+
expectedDepth: 'deep',
|
|
102
|
+
observedFamilies,
|
|
103
|
+
expectedFamilies,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
71
106
|
// Risk prioritizes a registered lookalike/typosquat domain for analyst review.
|
|
72
107
|
// It is a heuristic indicator, never a maliciousness or safety verdict.
|
|
73
|
-
export function explainRiskScore(
|
|
74
|
-
const state =
|
|
108
|
+
export function explainRiskScore(input) {
|
|
109
|
+
const state = input.availability ?? input.state;
|
|
75
110
|
if (typeof state !== 'string' || !RISK_STATES.has(state))
|
|
76
111
|
return null;
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
112
|
+
const factors = [];
|
|
113
|
+
const familyTotals = new Map();
|
|
114
|
+
const add = (family, label, requestedDelta) => {
|
|
115
|
+
const current = familyTotals.get(family) ?? 0;
|
|
116
|
+
const cap = FAMILY_CAPS[family] ?? Number.POSITIVE_INFINITY;
|
|
117
|
+
const delta = Math.max(0, Math.min(requestedDelta, Math.max(0, cap - current)));
|
|
118
|
+
const capped = delta !== requestedDelta;
|
|
119
|
+
factors.push({ family, label: capped ? `${label} (family cap reached)` : label, delta });
|
|
120
|
+
familyTotals.set(family, current + delta);
|
|
121
|
+
return delta;
|
|
122
|
+
};
|
|
123
|
+
add('registration', `Base context for “${STATE_LABELS[state] ?? state}”`, RISK_STATE_BASE[state] ?? 0);
|
|
124
|
+
const mutation = mutationContext(input.mutationTypes);
|
|
125
|
+
if (mutation)
|
|
126
|
+
add('domain-resemblance', mutation.label, mutation.delta);
|
|
127
|
+
if (input.idnReferenceMatch === true)
|
|
128
|
+
add('domain-resemblance', 'IDN skeleton matches an official Brand Profile domain', 20);
|
|
129
|
+
if (input.faviconMatch === true)
|
|
130
|
+
add('brand-presentation', 'Favicon matches an official Brand Profile site', 18);
|
|
131
|
+
else if (input.faviconNearMatch === true)
|
|
132
|
+
add('brand-presentation', 'Favicon resembles an official Brand Profile site', 14);
|
|
133
|
+
if (input.reusesOfficialAssets === true)
|
|
134
|
+
add('brand-presentation', 'Official asset-host relationship observed', 6);
|
|
135
|
+
if (input.pageBaselineMatch === true)
|
|
136
|
+
add('brand-presentation', 'Multiple independently compared page-identity components match the official baseline', 14);
|
|
137
|
+
if (typeof input.phishingLanguageMatch === 'string' && input.phishingLanguageMatch.trim()) {
|
|
138
|
+
add('credential-lure', 'Suspicious urgency language observed', 8);
|
|
139
|
+
}
|
|
140
|
+
if (input.hasPasswordField === true && input.hasExternalFormAction === true) {
|
|
141
|
+
add('credential-lure', 'Password form submits to an external origin', 10);
|
|
142
|
+
}
|
|
143
|
+
else if (input.hasPasswordField === true) {
|
|
144
|
+
add('credential-lure', 'Login/password form present', 5);
|
|
145
|
+
}
|
|
146
|
+
else if (input.hasExternalFormAction === true) {
|
|
147
|
+
add('credential-lure', 'External form destination observed without a password field', 0);
|
|
148
|
+
}
|
|
149
|
+
const external = calibrateExternalIntelligenceRisk(input.threatIntelligence);
|
|
150
|
+
if (external.factor)
|
|
151
|
+
add('external-intelligence', external.factor.label, external.contribution);
|
|
152
|
+
const primaryFamilies = ['domain-resemblance', 'brand-presentation', 'credential-lure', 'external-intelligence']
|
|
153
|
+
.filter((family) => (familyTotals.get(family) ?? 0) > 0);
|
|
154
|
+
if (primaryFamilies.length >= 2) {
|
|
155
|
+
add('corroboration', `Corroborating context across ${primaryFamilies.length} independent evidence families`, primaryFamilies.length >= 3 ? 18 : 10);
|
|
156
|
+
}
|
|
157
|
+
const hasIndependentContext = primaryFamilies.length > 0;
|
|
158
|
+
const supporting = (label, delta) => {
|
|
159
|
+
add('operational-support', hasIndependentContext ? label : `${label}; neutral without independent suspicious context`, hasIndependentContext ? delta : 0);
|
|
160
|
+
};
|
|
161
|
+
if (input.activityStatus === 'active')
|
|
162
|
+
supporting('Active website observed', 4);
|
|
163
|
+
if (input.hasMx === true)
|
|
164
|
+
supporting('Mail server configured', 4);
|
|
165
|
+
if (input.hasSpf === true && input.hasDmarc === true)
|
|
166
|
+
supporting('SPF and DMARC records present', 1);
|
|
167
|
+
else if (input.hasSpf === true || input.hasDmarc === true)
|
|
168
|
+
supporting('SPF or DMARC record present', 0);
|
|
169
|
+
if (typeof input.domainAgeDays === 'number' && Number.isFinite(input.domainAgeDays) && input.domainAgeDays >= 0) {
|
|
170
|
+
if (input.domainAgeDays < 90)
|
|
171
|
+
supporting(`Recently registered (${formatAge(input.domainAgeDays)})`, 6);
|
|
172
|
+
else if (input.domainAgeDays < 365)
|
|
173
|
+
supporting(`Registered under a year ago (${formatAge(input.domainAgeDays)})`, 2);
|
|
174
|
+
}
|
|
175
|
+
if (typeof input.privacyProtected === 'boolean') {
|
|
176
|
+
add('registration', 'Registration privacy is neutral context', 0);
|
|
177
|
+
}
|
|
178
|
+
const rawScore = Math.round(factors.reduce((total, factor) => total + factor.delta, 0));
|
|
179
|
+
const score = Math.max(0, Math.min(100, rawScore));
|
|
180
|
+
const familyOrder = [
|
|
181
|
+
'registration', 'domain-resemblance', 'brand-presentation', 'credential-lure',
|
|
182
|
+
'external-intelligence', 'corroboration', 'operational-support',
|
|
183
|
+
];
|
|
184
|
+
return {
|
|
185
|
+
modelVersion: RISK_MODEL_VERSION,
|
|
186
|
+
score,
|
|
187
|
+
rawScore,
|
|
188
|
+
capped: score !== rawScore,
|
|
189
|
+
factors,
|
|
190
|
+
families: familyOrder
|
|
191
|
+
.filter((id) => familyTotals.has(id))
|
|
192
|
+
.map((id) => ({ id, contribution: familyTotals.get(id) ?? 0, cap: FAMILY_CAPS[id] ?? null })),
|
|
193
|
+
evidenceQuality: scoreQuality(input),
|
|
85
194
|
};
|
|
86
|
-
const context = mutationContext(r.mutationTypes);
|
|
87
|
-
if (context)
|
|
88
|
-
addContextualFactor('domain-resemblance', context.label, context.delta);
|
|
89
|
-
if (r.faviconMatch === true) {
|
|
90
|
-
addContextualFactor('brand-presentation', 'Favicon matches your Brand Profile official site', 18);
|
|
91
|
-
}
|
|
92
|
-
else if (r.faviconNearMatch === true) {
|
|
93
|
-
addContextualFactor('brand-presentation', 'Favicon resembles your official site (perceptual match)', 14);
|
|
94
|
-
}
|
|
95
|
-
if (r.reusesOfficialAssets === true) {
|
|
96
|
-
addContextualFactor('brand-presentation', 'Official asset host relationship observed', 6);
|
|
97
|
-
}
|
|
98
|
-
if (typeof r.phishingLanguageMatch === 'string' && r.phishingLanguageMatch.trim()) {
|
|
99
|
-
addContextualFactor('credential-lure', 'Suspicious urgency language observed', 8);
|
|
100
|
-
}
|
|
101
|
-
if (r.hasPasswordField === true) {
|
|
102
|
-
addContextualFactor('credential-lure', 'Login/password form present', 5);
|
|
103
|
-
}
|
|
104
|
-
if (contextualFamilies.size >= 2) {
|
|
105
|
-
const bonus = contextualFamilies.size >= 3 ? 20 : 10;
|
|
106
|
-
factors.push({ label: `Corroborating context across ${contextualFamilies.size} distinct evidence families`, delta: bonus });
|
|
107
|
-
score += bonus;
|
|
108
|
-
}
|
|
109
|
-
const externalEvidence = calibrateExternalIntelligenceRisk(r.threatIntelligence);
|
|
110
|
-
if (externalEvidence.factor) {
|
|
111
|
-
factors.push(externalEvidence.factor);
|
|
112
|
-
score += externalEvidence.contribution;
|
|
113
|
-
}
|
|
114
|
-
if (r.activityStatus === 'active') {
|
|
115
|
-
factors.push({ label: 'Active site in use', delta: 8 });
|
|
116
|
-
score += 8;
|
|
117
|
-
}
|
|
118
|
-
if (r.hasMx === true) {
|
|
119
|
-
factors.push({ label: 'Mail server configured', delta: 8 });
|
|
120
|
-
score += 8;
|
|
121
|
-
}
|
|
122
|
-
if (r.hasSpf === true && r.hasDmarc === true) {
|
|
123
|
-
factors.push({ label: 'SPF + DMARC records present', delta: 3 });
|
|
124
|
-
score += 3;
|
|
125
|
-
}
|
|
126
|
-
else if (r.hasSpf === true || r.hasDmarc === true) {
|
|
127
|
-
factors.push({ label: 'SPF or DMARC record present', delta: 1 });
|
|
128
|
-
score += 1;
|
|
129
|
-
}
|
|
130
|
-
if (r.privacyProtected === true) {
|
|
131
|
-
factors.push({ label: 'WHOIS privacy protected', delta: 3 });
|
|
132
|
-
score += 3;
|
|
133
|
-
}
|
|
134
|
-
if (typeof r.domainAgeDays === 'number' && Number.isFinite(r.domainAgeDays) && r.domainAgeDays >= 0) {
|
|
135
|
-
if (r.domainAgeDays < 90) {
|
|
136
|
-
factors.push({ label: `Recently registered (${formatAge(r.domainAgeDays)})`, delta: 10 });
|
|
137
|
-
score += 10;
|
|
138
|
-
}
|
|
139
|
-
else if (r.domainAgeDays < 365) {
|
|
140
|
-
factors.push({ label: `Registered under a year ago (${formatAge(r.domainAgeDays)})`, delta: 4 });
|
|
141
|
-
score += 4;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
return { modelVersion: RISK_MODEL_VERSION, score: Math.max(0, Math.min(100, Math.round(score))), factors };
|
|
145
195
|
}
|
|
146
|
-
export function computeRiskScore(
|
|
147
|
-
return explainRiskScore(
|
|
196
|
+
export function computeRiskScore(input) {
|
|
197
|
+
return explainRiskScore(input)?.score ?? null;
|
|
198
|
+
}
|
|
199
|
+
// Retained only for deterministic offline comparison of reviewed calibration
|
|
200
|
+
// datasets. Runtime lookups always use the current model above.
|
|
201
|
+
export function explainRiskScoreV6(input) {
|
|
202
|
+
const state = input.availability ?? input.state;
|
|
203
|
+
if (typeof state !== 'string' || !RISK_STATES.has(state))
|
|
204
|
+
return null;
|
|
205
|
+
const legacyBase = { registered: 10, for_sale: 5, expiring: 8 };
|
|
206
|
+
const factors = [];
|
|
207
|
+
const totals = new Map();
|
|
208
|
+
const add = (family, label, delta) => {
|
|
209
|
+
factors.push({ family, label, delta });
|
|
210
|
+
totals.set(family, (totals.get(family) ?? 0) + delta);
|
|
211
|
+
};
|
|
212
|
+
add('registration', `Base score for “${STATE_LABELS[state] ?? state}”`, legacyBase[state] ?? 0);
|
|
213
|
+
const contextual = new Set();
|
|
214
|
+
const context = mutationContext(input.mutationTypes);
|
|
215
|
+
if (context) {
|
|
216
|
+
add('domain-resemblance', context.label, context.delta);
|
|
217
|
+
contextual.add('domain-resemblance');
|
|
218
|
+
}
|
|
219
|
+
if (input.faviconMatch === true) {
|
|
220
|
+
add('brand-presentation', 'Favicon matches an official Brand Profile site', 18);
|
|
221
|
+
contextual.add('brand-presentation');
|
|
222
|
+
}
|
|
223
|
+
else if (input.faviconNearMatch === true) {
|
|
224
|
+
add('brand-presentation', 'Favicon resembles an official Brand Profile site', 14);
|
|
225
|
+
contextual.add('brand-presentation');
|
|
226
|
+
}
|
|
227
|
+
if (input.reusesOfficialAssets === true) {
|
|
228
|
+
add('brand-presentation', 'Official asset-host relationship observed', 6);
|
|
229
|
+
contextual.add('brand-presentation');
|
|
230
|
+
}
|
|
231
|
+
if (typeof input.phishingLanguageMatch === 'string' && input.phishingLanguageMatch.trim()) {
|
|
232
|
+
add('credential-lure', 'Suspicious urgency language observed', 8);
|
|
233
|
+
contextual.add('credential-lure');
|
|
234
|
+
}
|
|
235
|
+
if (input.hasPasswordField === true) {
|
|
236
|
+
add('credential-lure', 'Login/password form present', 5);
|
|
237
|
+
contextual.add('credential-lure');
|
|
238
|
+
}
|
|
239
|
+
if (contextual.size >= 2)
|
|
240
|
+
add('corroboration', `Corroborating context across ${contextual.size} distinct evidence families`, contextual.size >= 3 ? 20 : 10);
|
|
241
|
+
const external = calibrateExternalIntelligenceRisk(input.threatIntelligence);
|
|
242
|
+
if (external.factor)
|
|
243
|
+
add('external-intelligence', external.factor.label, external.contribution);
|
|
244
|
+
if (input.activityStatus === 'active')
|
|
245
|
+
add('operational-support', 'Active site in use', 8);
|
|
246
|
+
if (input.hasMx === true)
|
|
247
|
+
add('operational-support', 'Mail server configured', 8);
|
|
248
|
+
if (input.hasSpf === true && input.hasDmarc === true)
|
|
249
|
+
add('operational-support', 'SPF and DMARC records present', 3);
|
|
250
|
+
else if (input.hasSpf === true || input.hasDmarc === true)
|
|
251
|
+
add('operational-support', 'SPF or DMARC record present', 1);
|
|
252
|
+
if (input.privacyProtected === true)
|
|
253
|
+
add('registration', 'WHOIS privacy protected', 3);
|
|
254
|
+
if (typeof input.domainAgeDays === 'number' && Number.isFinite(input.domainAgeDays) && input.domainAgeDays >= 0) {
|
|
255
|
+
if (input.domainAgeDays < 90)
|
|
256
|
+
add('operational-support', `Recently registered (${formatAge(input.domainAgeDays)})`, 10);
|
|
257
|
+
else if (input.domainAgeDays < 365)
|
|
258
|
+
add('operational-support', `Registered under a year ago (${formatAge(input.domainAgeDays)})`, 4);
|
|
259
|
+
}
|
|
260
|
+
const rawScore = Math.round(factors.reduce((total, factor) => total + factor.delta, 0));
|
|
261
|
+
const score = Math.max(0, Math.min(100, rawScore));
|
|
262
|
+
return {
|
|
263
|
+
modelVersion: 6,
|
|
264
|
+
score,
|
|
265
|
+
rawScore,
|
|
266
|
+
capped: score !== rawScore,
|
|
267
|
+
factors,
|
|
268
|
+
families: [...totals].map(([id, contribution]) => ({ id, contribution, cap: null })),
|
|
269
|
+
evidenceQuality: scoreQuality(input),
|
|
270
|
+
};
|
|
148
271
|
}
|
|
149
272
|
export function riskTone(score) {
|
|
150
273
|
if (score === null)
|
|
@@ -159,6 +282,6 @@ export function formatScoreBreakdown(explained, separator = '\n') {
|
|
|
159
282
|
if (!explained)
|
|
160
283
|
return '';
|
|
161
284
|
const parts = explained.factors.map((factor) => `${factor.label} ${factor.delta >= 0 ? '+' : ''}${Math.round(factor.delta)}`);
|
|
162
|
-
parts.push(`Total ${explained.score}`);
|
|
285
|
+
parts.push(`Total ${explained.score} · Risk model v${explained.modelVersion} · evidence ${explained.evidenceQuality.state}`);
|
|
163
286
|
return parts.join(separator);
|
|
164
287
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export const SCORE_EVIDENCE_QUALITY_VERSION = 1;
|
|
2
|
+
const MAX_SOURCES = 24;
|
|
3
|
+
const MAX_FAMILIES = 12;
|
|
4
|
+
const SOURCE_ID_RE = /^[a-z][a-z0-9_-]{0,63}$/u;
|
|
5
|
+
const COMPLETE_STATES = new Set(['complete', 'not_found', 'success']);
|
|
6
|
+
const LIMITED_STATES = new Set(['partial', 'unknown']);
|
|
7
|
+
const UNAVAILABLE_STATES = new Set(['error', 'rate_limited', 'unavailable']);
|
|
8
|
+
const SKIPPED_STATES = new Set(['skipped', 'unsupported']);
|
|
9
|
+
const ACCEPTED_SOURCE_STATES = new Set([
|
|
10
|
+
...COMPLETE_STATES,
|
|
11
|
+
...LIMITED_STATES,
|
|
12
|
+
...UNAVAILABLE_STATES,
|
|
13
|
+
...SKIPPED_STATES,
|
|
14
|
+
]);
|
|
15
|
+
function boundedFamilyList(value) {
|
|
16
|
+
const output = new Set();
|
|
17
|
+
for (const item of value.slice(0, MAX_FAMILIES * 2)) {
|
|
18
|
+
const normalized = String(item).trim().toLowerCase().replace(/[^a-z0-9_-]+/gu, '-').slice(0, 64);
|
|
19
|
+
if (SOURCE_ID_RE.test(normalized))
|
|
20
|
+
output.add(normalized);
|
|
21
|
+
if (output.size >= MAX_FAMILIES)
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
return [...output].sort();
|
|
25
|
+
}
|
|
26
|
+
function sourceCoverage(value) {
|
|
27
|
+
if (!Array.isArray(value))
|
|
28
|
+
return [];
|
|
29
|
+
const output = [];
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
for (const candidate of value.slice(0, MAX_SOURCES * 4)) {
|
|
32
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate))
|
|
33
|
+
continue;
|
|
34
|
+
const item = candidate;
|
|
35
|
+
const source = typeof item.source === 'string' ? item.source.trim().toLowerCase() : '';
|
|
36
|
+
const state = typeof item.state === 'string' ? item.state.trim().toLowerCase() : '';
|
|
37
|
+
if (!SOURCE_ID_RE.test(source) || seen.has(source))
|
|
38
|
+
continue;
|
|
39
|
+
if (!ACCEPTED_SOURCE_STATES.has(state))
|
|
40
|
+
continue;
|
|
41
|
+
seen.add(source);
|
|
42
|
+
output.push({ source, state });
|
|
43
|
+
if (output.length >= MAX_SOURCES)
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
return output;
|
|
47
|
+
}
|
|
48
|
+
function scanDepth(value) {
|
|
49
|
+
return value === 'deep' || value === 'fast' ? value : 'unknown';
|
|
50
|
+
}
|
|
51
|
+
function observedFreshness(value) {
|
|
52
|
+
if (typeof value !== 'string' || value.length > 64)
|
|
53
|
+
return 'unknown';
|
|
54
|
+
return Number.isFinite(Date.parse(value)) ? 'observed' : 'unknown';
|
|
55
|
+
}
|
|
56
|
+
export function buildScoreEvidenceQuality(input, options) {
|
|
57
|
+
const depth = scanDepth(input.scanDepth);
|
|
58
|
+
const coverage = sourceCoverage(input.sourceCoverage);
|
|
59
|
+
const completeSources = coverage.filter((item) => COMPLETE_STATES.has(item.state)).length;
|
|
60
|
+
const limitedSources = coverage.filter((item) => LIMITED_STATES.has(item.state)).length;
|
|
61
|
+
const unavailableSources = coverage.filter((item) => UNAVAILABLE_STATES.has(item.state)).length;
|
|
62
|
+
const skippedSources = coverage.filter((item) => SKIPPED_STATES.has(item.state)).length;
|
|
63
|
+
const observedFamilies = boundedFamilyList(options.observedFamilies ?? []);
|
|
64
|
+
const expectedFamilies = boundedFamilyList(options.expectedFamilies ?? []);
|
|
65
|
+
const observedSet = new Set(observedFamilies);
|
|
66
|
+
const missingFamilies = expectedFamilies.filter((family) => !observedSet.has(family));
|
|
67
|
+
const limitations = [];
|
|
68
|
+
let state = 'complete';
|
|
69
|
+
if (!coverage.length || depth === 'unknown')
|
|
70
|
+
state = 'unknown';
|
|
71
|
+
else if (unavailableSources || limitedSources)
|
|
72
|
+
state = 'partial';
|
|
73
|
+
else if (options.expectedDepth === 'deep' && depth !== 'deep')
|
|
74
|
+
state = 'limited';
|
|
75
|
+
if (missingFamilies.length && state === 'complete')
|
|
76
|
+
state = 'limited';
|
|
77
|
+
if (options.expectedDepth === 'deep' && depth !== 'deep') {
|
|
78
|
+
limitations.push('A fast or unknown-depth observation does not evaluate every website and mail scoring family.');
|
|
79
|
+
}
|
|
80
|
+
if (limitedSources || unavailableSources) {
|
|
81
|
+
limitations.push('One or more score-relevant sources were partial, unavailable, or indeterminate.');
|
|
82
|
+
}
|
|
83
|
+
if (skippedSources) {
|
|
84
|
+
limitations.push('One or more reported sources were skipped or unsupported and did not contribute to the score.');
|
|
85
|
+
}
|
|
86
|
+
if (!coverage.length)
|
|
87
|
+
limitations.push('Source-level coverage was not supplied with this score.');
|
|
88
|
+
if (missingFamilies.length)
|
|
89
|
+
limitations.push(`No usable observation was supplied for: ${missingFamilies.join(', ')}.`);
|
|
90
|
+
const boundedLimitations = [
|
|
91
|
+
...limitations.slice(0, 5),
|
|
92
|
+
'Evidence coverage qualifies the score; it never adds points or turns missing evidence into a negative finding.',
|
|
93
|
+
];
|
|
94
|
+
return Object.freeze({
|
|
95
|
+
version: SCORE_EVIDENCE_QUALITY_VERSION,
|
|
96
|
+
state,
|
|
97
|
+
scanDepth: depth,
|
|
98
|
+
freshness: observedFreshness(input.observedAt),
|
|
99
|
+
completeSources,
|
|
100
|
+
limitedSources,
|
|
101
|
+
unavailableSources,
|
|
102
|
+
skippedSources,
|
|
103
|
+
observedFamilies: Object.freeze(observedFamilies),
|
|
104
|
+
missingFamilies: Object.freeze(missingFamilies),
|
|
105
|
+
limitations: Object.freeze(boundedLimitations),
|
|
106
|
+
});
|
|
107
|
+
}
|