@slicedearth/whoisleuth-cli 1.34.0 → 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.
Files changed (131) hide show
  1. package/README.md +19 -2
  2. package/bin/whoisleuth.mjs +41 -24
  3. package/cli/arguments.mjs +326 -3
  4. package/cli/artifact-verify.mjs +60 -14
  5. package/cli/bulk.mjs +1 -2
  6. package/cli/compare.mjs +1 -1
  7. package/cli/completion.mjs +36 -2
  8. package/cli/doctor.mjs +11 -5
  9. package/cli/evidence-command-arguments.mjs +1 -1
  10. package/cli/evidence-command-runner.mjs +2 -2
  11. package/cli/evidence-signing.mjs +4 -4
  12. package/cli/export-evidence.mjs +9 -2
  13. package/cli/formatters/html.mjs +1 -1
  14. package/cli/formatters/json.mjs +13 -1
  15. package/cli/formatters/terminal.mjs +14 -4
  16. package/cli/investigation-plan.mjs +112 -0
  17. package/cli/local-mmdb-review.mjs +76 -0
  18. package/cli/lookalike-calibration.mjs +121 -0
  19. package/cli/lookup-command-runner.mjs +4 -1
  20. package/cli/lookup-plan.mjs +3 -3
  21. package/cli/lookup-reconcile.mjs +141 -0
  22. package/cli/lookup-timeline.mjs +1 -1
  23. package/cli/mail-review.mjs +48 -3
  24. package/cli/offline-evidence-review.mjs +159 -0
  25. package/cli/registry-doctor.mjs +125 -0
  26. package/cli/registry-support.mjs +1 -4
  27. package/cli/risk-calibration.mjs +117 -9
  28. package/cli/runner.mjs +342 -15
  29. package/cli/saved-lookup.mjs +2 -2
  30. package/cli/sharing-review.mjs +202 -0
  31. package/cli/source-reliability.mjs +1 -1
  32. package/docs/cli-reference.md +1110 -0
  33. package/docs/cli.md +224 -847
  34. package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
  35. package/frontend/src/lib/analysis/acquisition-due-diligence.js +6 -6
  36. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
  37. package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
  38. package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
  39. package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
  40. package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
  41. package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
  42. package/frontend/src/lib/analysis/case-migration-model.js +6 -1
  43. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  44. package/frontend/src/lib/analysis/case-record-core.js +5 -1
  45. package/frontend/src/lib/analysis/case-record-operations.js +6 -1
  46. package/frontend/src/lib/analysis/case-response-packet.js +7 -7
  47. package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
  48. package/frontend/src/lib/analysis/http-summary.js +2 -1
  49. package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
  50. package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
  51. package/frontend/src/lib/analysis/lookup-decision-support.js +480 -0
  52. package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
  53. package/frontend/src/lib/analysis/lookup-presentation.js +74 -0
  54. package/frontend/src/lib/analysis/lookup-response.js +2 -0
  55. package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
  56. package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
  57. package/frontend/src/lib/analysis/page-similarity.js +1 -1
  58. package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
  59. package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
  60. package/frontend/src/lib/analysis/scoring.js +8 -88
  61. package/frontend/src/lib/analysis/shortlist-model.js +8 -4
  62. package/frontend/src/lib/analysis/utils.js +7 -40
  63. package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
  64. package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
  65. package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
  66. package/frontend/src/lib/analysis/visualization-models.js +370 -0
  67. package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
  68. package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
  69. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
  70. package/frontend/src/lib/analysis/workspace-archive.js +2 -2
  71. package/lib/analyst-taxonomy.mjs +40 -0
  72. package/lib/availability.mjs +30 -10
  73. package/lib/browser-library-profile.mjs +15 -2
  74. package/lib/bulk-limits.mjs +13 -0
  75. package/lib/certificate-extension-profile.mjs +71 -0
  76. package/lib/credential-surface-profile.mjs +1 -1
  77. package/lib/ct-response-bounds.mjs +9 -0
  78. package/lib/ct-search.mjs +66 -12
  79. package/lib/dns-delegation-health.mjs +56 -5
  80. package/lib/dnssec-evidence-validation.mjs +235 -0
  81. package/lib/domain-assurance.mjs +368 -0
  82. package/lib/domain-control-manifest.mjs +382 -0
  83. package/lib/domain-posture.mjs +11 -10
  84. package/lib/encrypted-dns-contract.mjs +81 -0
  85. package/lib/evidence-export.mjs +17 -3
  86. package/lib/evidence-report-markdown.mjs +1 -1
  87. package/lib/evidence-report.mjs +4 -1
  88. package/lib/favicon.mjs +3 -8
  89. package/lib/generated/cisa-kev-catalog.mjs +1668 -0
  90. package/lib/html-signals.mjs +4 -0
  91. package/lib/http-evidence-bounds.mjs +4 -0
  92. package/lib/http-intelligence.mjs +2 -1
  93. package/lib/ip-prefix.mjs +87 -0
  94. package/lib/local-geoip-evidence.mjs +89 -0
  95. package/lib/lookup-response-contract.mjs +346 -0
  96. package/lib/observed-network-context.mjs +1 -1
  97. package/lib/opportunity-scoring.mjs +107 -0
  98. package/lib/outbound-identity.mjs +9 -0
  99. package/lib/outbound-request-bounds.mjs +9 -0
  100. package/lib/page-fingerprints.mjs +1 -1
  101. package/lib/perceptual-hash-comparison.mjs +34 -0
  102. package/lib/perceptual-hash.mjs +3 -31
  103. package/lib/project-metadata.mjs +8 -0
  104. package/lib/provider-policy-admission.mjs +49 -0
  105. package/lib/rdap-capabilities.mjs +6 -6
  106. package/lib/rdap-search-workbench.mjs +247 -0
  107. package/lib/rdap.mjs +7 -2
  108. package/lib/registry-access.mjs +1 -1
  109. package/lib/registry-capabilities.mjs +31 -1
  110. package/lib/registry-capability-catalogue.mjs +15 -14
  111. package/lib/registry-insights.mjs +6 -6
  112. package/lib/response-policy.mjs +81 -6
  113. package/lib/risk-scoring.mjs +206 -83
  114. package/lib/rpki-evidence.mjs +105 -0
  115. package/lib/safe-fetch.mjs +2 -1
  116. package/lib/scoring-evidence-quality.mjs +107 -0
  117. package/lib/security-txt.mjs +3 -3
  118. package/lib/semantic-version.mjs +54 -0
  119. package/lib/static-html-analysis.mjs +27 -0
  120. package/lib/threat-intelligence-manifests.mjs +1 -1
  121. package/lib/threatfox-intelligence.mjs +11 -6
  122. package/lib/tls-intelligence.mjs +13 -3
  123. package/lib/tlsa-evidence.mjs +194 -0
  124. package/lib/urlhaus-intelligence.mjs +11 -6
  125. package/lib/urlscan-intelligence.mjs +10 -5
  126. package/lib/website-security-posture.mjs +16 -9
  127. package/lib/website-technology.mjs +1 -1
  128. package/lib/whois-chain.mjs +14 -152
  129. package/lib/whois.mjs +2 -2
  130. package/package.json +6 -2
  131. package/third-party-notices.txt +911 -19
@@ -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 normalized publication.',
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 normalized entity inventory was capped.');
214
+ issues.push('The normalised entity inventory was capped.');
215
215
  if (parsed.statusesTruncated === true)
216
- issues.push('The normalized status inventory was capped.');
216
+ issues.push('The normalised status inventory was capped.');
217
217
  if (strings(parsed.fieldsTruncated, 20).length)
218
- issues.push('One or more normalized WHOIS fields were capped.');
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 normalized conflict.'
309
+ ? 'The sources differ in publication or disclosure without a normalised conflict.'
310
310
  : counts.equivalent
311
- ? 'The comparable fields are normalized as equivalent.'
311
+ ? 'The comparable fields are normalised as equivalent.'
312
312
  : 'No comparable fields were available.',
313
313
  },
314
314
  publications: [
@@ -1,8 +1,9 @@
1
1
  // Privacy-minimized interpretation of selected response policies. Header and
2
2
  // cookie values are read transiently from the already-selected homepage
3
3
  // response and reduced to fixed signal identifiers plus bounded counts.
4
- export const RESPONSE_POLICY_VERSION = 1;
4
+ export const RESPONSE_POLICY_VERSION = 2;
5
5
  export const MAX_RESPONSE_POLICY_HEADER_BYTES = 8 * 1024;
6
+ export const MAX_CSP_META_POLICIES = 4;
6
7
  export const MAX_RESPONSE_POLICY_DIRECTIVES = 64;
7
8
  export const MAX_RESPONSE_POLICY_TOKENS = 128;
8
9
  export const MAX_RESPONSE_COOKIES = 32;
@@ -56,7 +57,7 @@ function unavailableHeaderState(header) {
56
57
  }
57
58
  function analyzeCsp(header, signals) {
58
59
  if (header.state !== 'present' || !header.value) {
59
- return unavailableHeaderState(header);
60
+ return { state: unavailableHeaderState(header), inlineControl: 'unknown' };
60
61
  }
61
62
  const directives = new Map();
62
63
  let tokenCount = 0;
@@ -68,7 +69,7 @@ function analyzeCsp(header, signals) {
68
69
  const [rawName = '', ...rawTokens] = normalized.split(/\s+/u);
69
70
  const name = rawName.toLowerCase();
70
71
  if (!DIRECTIVE_NAME_RE.test(name))
71
- return 'malformed';
72
+ return { state: 'malformed', inlineControl: 'unknown' };
72
73
  if (directives.has(name))
73
74
  continue;
74
75
  if (directives.size >= MAX_RESPONSE_POLICY_DIRECTIVES) {
@@ -83,7 +84,7 @@ function analyzeCsp(header, signals) {
83
84
  directives.set(name, tokens);
84
85
  }
85
86
  if (!directives.size)
86
- return 'malformed';
87
+ return { state: 'malformed', inlineControl: 'unknown' };
87
88
  const defaultSources = directives.get('default-src');
88
89
  const scriptSources = directives.get('script-src') || defaultSources;
89
90
  if (!defaultSources)
@@ -92,6 +93,7 @@ function analyzeCsp(header, signals) {
92
93
  addSignal(signals, 'csp_base_uri_missing');
93
94
  if (!directives.has('object-src') && !defaultSources)
94
95
  addSignal(signals, 'csp_object_source_unbounded');
96
+ let inlineControl = scriptSources ? 'restricted' : 'uncontrolled';
95
97
  if (scriptSources) {
96
98
  if (scriptSources.some((token) => ['*', 'http:', 'https:', 'data:', 'blob:'].includes(token))) {
97
99
  addSignal(signals, 'csp_permissive_script_source');
@@ -101,9 +103,79 @@ function analyzeCsp(header, signals) {
101
103
  if (scriptSources.includes("'unsafe-inline'")
102
104
  && !scriptSources.some((token) => CSP_NONCE_OR_HASH_RE.test(token))) {
103
105
  addSignal(signals, 'csp_unsafe_inline');
106
+ inlineControl = 'unqualified';
107
+ }
108
+ }
109
+ return {
110
+ state: partial ? 'partial' : 'parsed',
111
+ inlineControl: partial ? 'unknown' : inlineControl,
112
+ };
113
+ }
114
+ export function analyzeCspMetaPolicies(value, sourceTruncated = false) {
115
+ const candidates = Array.isArray(value) ? value : [];
116
+ const policies = candidates.slice(0, MAX_CSP_META_POLICIES);
117
+ let policiesParsed = 0;
118
+ let inlineScriptConstrained = false;
119
+ let truncated = sourceTruncated || candidates.length > MAX_CSP_META_POLICIES;
120
+ for (const rawPolicy of policies) {
121
+ const policy = rawPolicy && typeof rawPolicy === 'object' && !Array.isArray(rawPolicy)
122
+ ? rawPolicy
123
+ : null;
124
+ if (!policy || typeof policy.content !== 'string') {
125
+ truncated = true;
126
+ continue;
127
+ }
128
+ const policyContent = policy.content;
129
+ const content = readHeader({ get: () => policyContent }, 'content-security-policy');
130
+ const analysis = analyzeCsp(content, []);
131
+ if (analysis.state !== 'parsed') {
132
+ truncated = true;
133
+ continue;
104
134
  }
135
+ policiesParsed += 1;
136
+ if (policy.beforeScript === true && analysis.inlineControl === 'restricted') {
137
+ inlineScriptConstrained = true;
138
+ }
139
+ }
140
+ return {
141
+ cspMetaPolicyVersion: 1,
142
+ policiesObserved: boundedCount(candidates.length, MAX_CSP_META_POLICIES),
143
+ policiesParsed,
144
+ inlineScriptConstrained,
145
+ truncated,
146
+ };
147
+ }
148
+ export function qualifyResponsePolicyWithCspMeta(value, meta) {
149
+ if (!value || !meta || meta.cspMetaPolicyVersion !== 1)
150
+ return value;
151
+ const constrained = meta.inlineScriptConstrained
152
+ && value.signals.some((signal) => signal.id === 'csp_unsafe_inline');
153
+ const signals = constrained
154
+ ? [
155
+ ...value.signals.filter((signal) => signal.id !== 'csp_unsafe_inline'),
156
+ { id: 'csp_inline_constrained_by_meta' },
157
+ ]
158
+ : [...value.signals];
159
+ const limitations = [...value.limitations];
160
+ if (meta.policiesObserved > 0) {
161
+ limitations.push(constrained
162
+ ? 'A bounded CSP meta policy observed before page scripts further constrained inline script allowed by the response header.'
163
+ : 'CSP meta policy observations did not qualify the selected response-header finding; policies after scripts, malformed policies, and capped policies remain non-authoritative for this comparison.');
105
164
  }
106
- return partial ? 'partial' : 'parsed';
165
+ if (meta.truncated)
166
+ limitations.push('CSP meta policy analysis reached a configured count, position, or byte boundary.');
167
+ return {
168
+ ...value,
169
+ signals: signals.slice(0, 16),
170
+ diagnostics: {
171
+ ...value.diagnostics,
172
+ signalCount: Math.min(signals.length, 16),
173
+ cspMetaPoliciesObserved: boundedCount(meta.policiesObserved, MAX_CSP_META_POLICIES),
174
+ cspMetaPoliciesParsed: boundedCount(meta.policiesParsed, MAX_CSP_META_POLICIES),
175
+ cspMetaPoliciesTruncated: meta.truncated,
176
+ },
177
+ limitations,
178
+ };
107
179
  }
108
180
  function analyzeHsts(header, signals) {
109
181
  if (header.state !== 'present' || !header.value) {
@@ -221,7 +293,7 @@ function analyzeCookies(headers, signals) {
221
293
  }
222
294
  export function analyzeResponsePolicyHeaders(headers) {
223
295
  const signals = [];
224
- const contentSecurityPolicy = analyzeCsp(readHeader(headers, 'content-security-policy'), signals);
296
+ const contentSecurityPolicy = analyzeCsp(readHeader(headers, 'content-security-policy'), signals).state;
225
297
  const strictTransportSecurity = analyzeHsts(readHeader(headers, 'strict-transport-security'), signals);
226
298
  const referrerPolicy = analyzeReferrerPolicy(readHeader(headers, 'referrer-policy'), signals);
227
299
  const cookies = analyzeCookies(headers, signals);
@@ -250,6 +322,9 @@ export function analyzeResponsePolicyHeaders(headers) {
250
322
  signalCount: Math.min(signals.length, 16),
251
323
  cookieCount: boundedCount(cookies.count, MAX_RESPONSE_COOKIES),
252
324
  cookiesTruncated: cookies.truncated,
325
+ cspMetaPoliciesObserved: 0,
326
+ cspMetaPoliciesParsed: 0,
327
+ cspMetaPoliciesTruncated: false,
253
328
  },
254
329
  limitations,
255
330
  };
@@ -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 6 preserves the grouped external-evidence factor and recognizes the
9
- // newly generated addition, plural, embedded-TLD, advanced Unicode, and
10
- // analyst-directed token-replacement families conservatively. Older scores
11
- // remain readable, while case and watchlist comparisons gate numeric changes
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: 10,
18
- for_sale: 5,
19
- expiring: 8,
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(r) {
74
- const state = r.availability ?? r.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 base = RISK_STATE_BASE[state] ?? 0;
78
- const factors = [{ label: `Base score for "${STATE_LABELS[state] || state}"`, delta: base }];
79
- let score = base;
80
- const contextualFamilies = new Set();
81
- const addContextualFactor = (family, label, delta) => {
82
- contextualFamilies.add(family);
83
- factors.push({ label, delta });
84
- score += delta;
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(r) {
147
- return explainRiskScore(r)?.score ?? null;
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,105 @@
1
+ import { parseIpPrefix, prefixContains } from "./ip-prefix.mjs";
2
+ const RPKI_EVIDENCE_SCHEMA = 'whoisleuth.rpki-route-review';
3
+ const RPKI_EVIDENCE_VERSION = 1;
4
+ const MAX_RPKI_RECORDS = 100_000;
5
+ const MAX_RPKI_MATCHES = 50;
6
+ function record(value) {
7
+ return value && typeof value === 'object' && !Array.isArray(value)
8
+ ? value
9
+ : null;
10
+ }
11
+ function normalizeAsn(value) {
12
+ const text = typeof value === 'string' ? value.trim().replace(/^AS/iu, '') : value;
13
+ const number = Number(text);
14
+ return Number.isInteger(number) && number >= 0 && number <= 4_294_967_295 ? number : null;
15
+ }
16
+ function normalizeAuthorization(value) {
17
+ const source = record(value);
18
+ if (!source)
19
+ return null;
20
+ const prefix = typeof source.prefix === 'string' ? source.prefix : typeof source.route === 'string' ? source.route : '';
21
+ const parsedPrefix = parseIpPrefix(prefix);
22
+ const asn = normalizeAsn(source.asn ?? source.asID ?? source.origin);
23
+ const maxLengthValue = source.maxLength ?? source.max_length ?? parsedPrefix?.length;
24
+ const maxLength = Number(maxLengthValue);
25
+ const maximum = parsedPrefix?.family === 4 ? 32 : 128;
26
+ if (!parsedPrefix || asn === null || !Number.isInteger(maxLength)
27
+ || maxLength < parsedPrefix.length || maxLength > maximum)
28
+ return null;
29
+ return Object.freeze({ prefix: `${parsedPrefix.address}/${parsedPrefix.length}`, parsedPrefix, maxLength, asn });
30
+ }
31
+ function authorizationRows(value) {
32
+ if (Array.isArray(value))
33
+ return value;
34
+ const source = record(value);
35
+ if (!source)
36
+ return null;
37
+ if (Array.isArray(source.roas))
38
+ return source.roas;
39
+ if (Array.isArray(source.vrps))
40
+ return source.vrps;
41
+ if (Array.isArray(source.validated_route_origins))
42
+ return source.validated_route_origins;
43
+ return null;
44
+ }
45
+ function reviewRpkiRoute(input) {
46
+ const route = parseIpPrefix(input.routePrefix);
47
+ const originAsn = normalizeAsn(input.originAsn);
48
+ const rows = authorizationRows(input.authorizations);
49
+ const baseLimitations = Object.freeze([
50
+ 'This offline review evaluates an explicitly supplied route prefix and origin ASN against an analyst-supplied VRP snapshot.',
51
+ 'It does not collect BGP announcements, establish route ownership, or prove that the snapshot was current or complete.',
52
+ ]);
53
+ if (!route || originAsn === null || !rows) {
54
+ return Object.freeze({
55
+ schema: RPKI_EVIDENCE_SCHEMA,
56
+ version: RPKI_EVIDENCE_VERSION,
57
+ state: 'invalid_input',
58
+ routePrefix: null,
59
+ originAsn,
60
+ coveringAuthorizationCount: 0,
61
+ matchingAuthorizationCount: 0,
62
+ rejectedCount: 0,
63
+ truncated: false,
64
+ matches: Object.freeze([]),
65
+ limitations: baseLimitations,
66
+ });
67
+ }
68
+ const truncated = rows.length > MAX_RPKI_RECORDS;
69
+ const normalized = rows.slice(0, MAX_RPKI_RECORDS).map(normalizeAuthorization);
70
+ const rejectedCount = normalized.filter((item) => item === null).length + Math.max(0, rows.length - MAX_RPKI_RECORDS);
71
+ const authorizations = normalized.filter((item) => item !== null);
72
+ const covering = authorizations.filter((item) => prefixContains(item.parsedPrefix, route));
73
+ const matches = covering
74
+ .sort((left, right) => right.parsedPrefix.length - left.parsedPrefix.length || left.asn - right.asn)
75
+ .slice(0, MAX_RPKI_MATCHES)
76
+ .map((item) => Object.freeze({
77
+ prefix: item.prefix,
78
+ maxLength: item.maxLength,
79
+ asn: item.asn,
80
+ state: item.asn !== originAsn ? 'asn_mismatch'
81
+ : route.length > item.maxLength ? 'max_length_exceeded'
82
+ : 'valid',
83
+ }));
84
+ const matchingAuthorizationCount = matches.filter((item) => item.state === 'valid').length;
85
+ const state = covering.length === 0
86
+ ? 'not_found'
87
+ : matchingAuthorizationCount > 0
88
+ ? truncated || rejectedCount > 0 || covering.length > MAX_RPKI_MATCHES ? 'partial'
89
+ : 'valid'
90
+ : 'invalid';
91
+ return Object.freeze({
92
+ schema: RPKI_EVIDENCE_SCHEMA,
93
+ version: RPKI_EVIDENCE_VERSION,
94
+ state,
95
+ routePrefix: `${route.address}/${route.length}`,
96
+ originAsn,
97
+ coveringAuthorizationCount: covering.length,
98
+ matchingAuthorizationCount,
99
+ rejectedCount,
100
+ truncated,
101
+ matches: Object.freeze(matches),
102
+ limitations: baseLimitations,
103
+ });
104
+ }
105
+ export { MAX_RPKI_RECORDS, RPKI_EVIDENCE_SCHEMA, RPKI_EVIDENCE_VERSION, reviewRpkiRoute, };
@@ -22,7 +22,8 @@ import { promises as dns } from 'node:dns';
22
22
  import * as net from 'node:net';
23
23
  import * as crypto from 'node:crypto';
24
24
  import { Agent, fetch as undiciFetch } from 'undici';
25
- const MAX_REDIRECTS = 5;
25
+ import { MAX_OUTBOUND_REDIRECTS } from "./outbound-request-bounds.mjs";
26
+ const MAX_REDIRECTS = MAX_OUTBOUND_REDIRECTS;
26
27
  const MAX_SAFE_FETCH_URL_LENGTH = 4096;
27
28
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
28
29
  async function defaultSafeFetchRequest(url, options) {