@slicedearth/whoisleuth-cli 1.36.1 → 1.43.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.
Files changed (139) hide show
  1. package/README.md +32 -2
  2. package/bin/whoisleuth.mjs +52 -24
  3. package/cli/archive-inspect.mjs +37 -2
  4. package/cli/arguments.mjs +493 -157
  5. package/cli/artifact-verify.mjs +91 -15
  6. package/cli/bulk-checkpoint.mjs +7 -11
  7. package/cli/bulk-command-runner.mjs +19 -2
  8. package/cli/case-pack.mjs +136 -0
  9. package/cli/ci-report.mjs +73 -0
  10. package/cli/collection-preflight.mjs +59 -0
  11. package/cli/command-reference.mjs +395 -0
  12. package/cli/compare.mjs +1 -1
  13. package/cli/completion.mjs +49 -11
  14. package/cli/config-profile.mjs +180 -0
  15. package/cli/ct-event-intake.mjs +159 -0
  16. package/cli/discovery-scan-command-runner.mjs +15 -1
  17. package/cli/doctor.mjs +11 -5
  18. package/cli/domain-control-monitor.mjs +104 -0
  19. package/cli/domain-control-observations.mjs +235 -0
  20. package/cli/evidence-command-arguments.mjs +12 -1
  21. package/cli/evidence-command-runner.mjs +3 -2
  22. package/cli/evidence-signing.mjs +4 -4
  23. package/cli/external-observation-mapping.mjs +162 -0
  24. package/cli/fail-policy.mjs +64 -0
  25. package/cli/formatters/html.mjs +2 -2
  26. package/cli/formatters/json.mjs +13 -1
  27. package/cli/formatters/terminal.mjs +7 -3
  28. package/cli/investigation-manifest.mjs +113 -0
  29. package/cli/investigation-plan.mjs +2 -2
  30. package/cli/investigation-run.mjs +112 -0
  31. package/cli/local-mmdb-review.mjs +76 -0
  32. package/cli/lookalike-calibration.mjs +121 -0
  33. package/cli/lookup-brief.mjs +116 -0
  34. package/cli/lookup-command-runner.mjs +12 -2
  35. package/cli/lookup-diff.mjs +17 -19
  36. package/cli/lookup-plan.mjs +3 -3
  37. package/cli/lookup-reconcile.mjs +141 -0
  38. package/cli/lookup-timeline.mjs +1 -1
  39. package/cli/mail-review.mjs +1 -1
  40. package/cli/network-command-runner.mjs +4 -1
  41. package/cli/offline-evidence-review.mjs +125 -10
  42. package/cli/open-asset-model-bridge.mjs +169 -0
  43. package/cli/registry-cohort.mjs +121 -0
  44. package/cli/registry-doctor.mjs +220 -0
  45. package/cli/registry-fixture-scaffold.mjs +73 -0
  46. package/cli/risk-calibration.mjs +117 -9
  47. package/cli/runner.mjs +561 -280
  48. package/cli/saved-lookup.mjs +2 -2
  49. package/cli/sharing-review.mjs +202 -0
  50. package/cli/source-reliability.mjs +1 -1
  51. package/docs/cli-reference.md +1336 -0
  52. package/docs/cli.md +259 -940
  53. package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
  54. package/frontend/src/lib/analysis/acquisition-due-diligence.js +7 -7
  55. package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
  56. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
  57. package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
  58. package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
  59. package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
  60. package/frontend/src/lib/analysis/case-migration-model.js +6 -1
  61. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  62. package/frontend/src/lib/analysis/case-record-core.js +5 -1
  63. package/frontend/src/lib/analysis/case-record-operations.js +6 -1
  64. package/frontend/src/lib/analysis/case-report.js +573 -0
  65. package/frontend/src/lib/analysis/case-response-model.js +30 -1
  66. package/frontend/src/lib/analysis/case-response-packet.js +7 -7
  67. package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
  68. package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
  69. package/frontend/src/lib/analysis/external-findings-import.js +320 -0
  70. package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
  71. package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
  72. package/frontend/src/lib/analysis/lookup-decision-support.js +487 -0
  73. package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
  74. package/frontend/src/lib/analysis/lookup-presentation.js +64 -0
  75. package/frontend/src/lib/analysis/lookup-response.js +2 -0
  76. package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
  77. package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
  78. package/frontend/src/lib/analysis/page-similarity.js +1 -1
  79. package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
  80. package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
  81. package/frontend/src/lib/analysis/scoring.js +9 -89
  82. package/frontend/src/lib/analysis/shortlist-model.js +8 -4
  83. package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
  84. package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
  85. package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
  86. package/frontend/src/lib/analysis/visualization-models.js +369 -0
  87. package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
  88. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
  89. package/frontend/src/lib/analysis/workspace-archive.js +4 -2
  90. package/lib/abusech-auth.mjs +1 -1
  91. package/lib/analyst-taxonomy.mjs +40 -0
  92. package/lib/availability.mjs +2 -2
  93. package/lib/bounded-contract-normalizers.mjs +33 -0
  94. package/lib/browser-library-profile.mjs +15 -2
  95. package/lib/certificate-extension-profile.mjs +71 -0
  96. package/lib/credential-surface-profile.mjs +1 -1
  97. package/lib/dns-convergence-review.mjs +183 -0
  98. package/lib/dns-delegation-health.mjs +196 -3
  99. package/lib/domain-assurance.mjs +368 -0
  100. package/lib/domain-change-packet.mjs +133 -0
  101. package/lib/domain-change-review.mjs +307 -0
  102. package/lib/domain-control-flight-recorder.mjs +268 -0
  103. package/lib/domain-control-manifest.mjs +50 -7
  104. package/lib/domain-portfolio-review.mjs +151 -0
  105. package/lib/domain-posture.mjs +9 -9
  106. package/lib/encrypted-dns-contract.mjs +1 -1
  107. package/lib/evidence-export.mjs +1 -1
  108. package/lib/evidence-report-markdown.mjs +1 -1
  109. package/lib/evidence-report.mjs +1 -1
  110. package/lib/generated/cisa-kev-catalog.mjs +1668 -0
  111. package/lib/html-signals.mjs +5 -11
  112. package/lib/ip-prefix.mjs +34 -1
  113. package/lib/local-geoip-evidence.mjs +2 -2
  114. package/lib/lookup-response-contract.mjs +346 -0
  115. package/lib/nameserver-preflight-review.mjs +159 -0
  116. package/lib/observed-network-context.mjs +1 -1
  117. package/lib/opportunity-scoring.mjs +107 -0
  118. package/lib/page-fingerprints.mjs +1 -1
  119. package/lib/page-language-signals.mjs +124 -0
  120. package/lib/rdap-capabilities.mjs +6 -6
  121. package/lib/rdap-search-workbench.mjs +96 -6
  122. package/lib/registry-access.mjs +1 -1
  123. package/lib/registry-capability-catalogue.mjs +4 -4
  124. package/lib/registry-insights.mjs +6 -6
  125. package/lib/risk-scoring.mjs +247 -83
  126. package/lib/scoring-evidence-quality.mjs +107 -0
  127. package/lib/security-txt.mjs +3 -3
  128. package/lib/static-html-analysis.mjs +29 -1
  129. package/lib/threat-intelligence-manifests.mjs +1 -1
  130. package/lib/threatfox-intelligence.mjs +1 -1
  131. package/lib/tls-intelligence.mjs +13 -3
  132. package/lib/trust-store-comparison.mjs +146 -0
  133. package/lib/urlhaus-intelligence.mjs +1 -1
  134. package/lib/website-security-posture.mjs +5 -5
  135. package/lib/website-technology.mjs +25 -11
  136. package/lib/whois-chain.mjs +1 -1
  137. package/lib/zone-intent-review.mjs +534 -0
  138. package/package.json +5 -1
  139. package/third-party-notices.txt +911 -19
@@ -1,23 +1,36 @@
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,
20
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,
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));
21
34
  const HIGH_CONTEXT_MUTATIONS = new Set([
22
35
  'unicode_homoglyph',
23
36
  'unicode_homoglyph_depth_2',
@@ -49,7 +62,7 @@ export function normalizeRiskModelVersion(value) {
49
62
  }
50
63
  function formatAge(days) {
51
64
  if (days < 60)
52
- return `${days}d old`;
65
+ return `${Math.round(days)}d old`;
53
66
  const years = days / 365.25;
54
67
  return years < 1 ? `${Math.round(days / 30)}mo old` : `${years.toFixed(1)}y old`;
55
68
  }
@@ -68,83 +81,234 @@ function mutationContext(mutationTypes) {
68
81
  }
69
82
  return null;
70
83
  }
84
+ function hasBoolean(value) {
85
+ return typeof value === 'boolean';
86
+ }
87
+ function scoreQuality(input) {
88
+ const observedFamilies = ['registration'];
89
+ const expectedFamilies = ['registration', 'operational-support'];
90
+ if (Array.isArray(input.mutationTypes) || hasBoolean(input.idnReferenceMatch))
91
+ observedFamilies.push('domain-resemblance');
92
+ if (input.hasActiveBrandProfile === true)
93
+ expectedFamilies.push('domain-resemblance', 'brand-presentation');
94
+ if ([input.faviconMatch, input.faviconNearMatch, input.reusesOfficialAssets, input.pageBaselineMatch].some(hasBoolean)) {
95
+ observedFamilies.push('brand-presentation');
96
+ }
97
+ if ([input.hasPasswordField, input.hasExternalFormAction].some(hasBoolean) || typeof input.phishingLanguageMatch === 'string') {
98
+ observedFamilies.push('credential-lure');
99
+ }
100
+ if (input.threatIntelligence !== undefined && input.threatIntelligence !== null)
101
+ observedFamilies.push('external-intelligence');
102
+ if ([input.hasMx, input.hasSpf, input.hasDmarc].some(hasBoolean)
103
+ || typeof input.activityStatus === 'string'
104
+ || typeof input.domainAgeDays === 'number')
105
+ observedFamilies.push('operational-support');
106
+ if (input.scanDepth === 'deep')
107
+ expectedFamilies.push('credential-lure');
108
+ return buildScoreEvidenceQuality(input, {
109
+ expectedDepth: 'deep',
110
+ observedFamilies,
111
+ expectedFamilies,
112
+ });
113
+ }
71
114
  // Risk prioritizes a registered lookalike/typosquat domain for analyst review.
72
115
  // It is a heuristic indicator, never a maliciousness or safety verdict.
73
- export function explainRiskScore(r) {
74
- const state = r.availability ?? r.state;
116
+ function explainRiskScoreInternal(input, excludedFamily) {
117
+ const state = input.availability ?? input.state;
75
118
  if (typeof state !== 'string' || !RISK_STATES.has(state))
76
119
  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;
120
+ const factors = [];
121
+ const familyTotals = new Map();
122
+ const add = (family, label, requestedDelta) => {
123
+ if (family === excludedFamily)
124
+ return 0;
125
+ const current = familyTotals.get(family) ?? 0;
126
+ const cap = FAMILY_CAPS[family] ?? Number.POSITIVE_INFINITY;
127
+ const delta = Math.max(0, Math.min(requestedDelta, Math.max(0, cap - current)));
128
+ const capped = delta !== requestedDelta;
129
+ factors.push({ family, label: capped ? `${label} (family cap reached)` : label, delta });
130
+ familyTotals.set(family, current + delta);
131
+ return delta;
132
+ };
133
+ add('registration', `Base context for “${STATE_LABELS[state] ?? state}”`, RISK_STATE_BASE[state] ?? 0);
134
+ const mutation = mutationContext(input.mutationTypes);
135
+ if (mutation)
136
+ add('domain-resemblance', mutation.label, mutation.delta);
137
+ if (input.idnReferenceMatch === true)
138
+ add('domain-resemblance', 'IDN skeleton matches an official Brand Profile domain', 20);
139
+ if (input.faviconMatch === true)
140
+ add('brand-presentation', 'Favicon matches an official Brand Profile site', 18);
141
+ else if (input.faviconNearMatch === true)
142
+ add('brand-presentation', 'Favicon resembles an official Brand Profile site', 14);
143
+ if (input.reusesOfficialAssets === true)
144
+ add('brand-presentation', 'Official asset-host relationship observed', 6);
145
+ if (input.pageBaselineMatch === true)
146
+ add('brand-presentation', 'Multiple independently compared page-identity components match the official baseline', 14);
147
+ if (typeof input.phishingLanguageMatch === 'string' && input.phishingLanguageMatch.trim()) {
148
+ add('credential-lure', 'Suspicious urgency language observed', 8);
149
+ }
150
+ if (input.hasPasswordField === true && input.hasExternalFormAction === true) {
151
+ add('credential-lure', 'Password form submits to an external origin', 10);
152
+ }
153
+ else if (input.hasPasswordField === true) {
154
+ add('credential-lure', 'Login/password form present', 5);
155
+ }
156
+ else if (input.hasExternalFormAction === true) {
157
+ add('credential-lure', 'External form destination observed without a password field', 0);
158
+ }
159
+ const external = calibrateExternalIntelligenceRisk(input.threatIntelligence);
160
+ if (external.factor)
161
+ add('external-intelligence', external.factor.label, external.contribution);
162
+ const primaryFamilies = ['domain-resemblance', 'brand-presentation', 'credential-lure', 'external-intelligence']
163
+ .filter((family) => (familyTotals.get(family) ?? 0) > 0);
164
+ if (primaryFamilies.length >= 2) {
165
+ add('corroboration', `Corroborating context across ${primaryFamilies.length} independent evidence families`, primaryFamilies.length >= 3 ? 18 : 10);
166
+ }
167
+ const hasIndependentContext = primaryFamilies.length > 0;
168
+ const supporting = (label, delta) => {
169
+ add('operational-support', hasIndependentContext ? label : `${label}; neutral without independent suspicious context`, hasIndependentContext ? delta : 0);
170
+ };
171
+ if (input.activityStatus === 'active')
172
+ supporting('Active website observed', 4);
173
+ if (input.hasMx === true)
174
+ supporting('Mail server configured', 4);
175
+ if (input.hasSpf === true && input.hasDmarc === true)
176
+ supporting('SPF and DMARC records present', 1);
177
+ else if (input.hasSpf === true || input.hasDmarc === true)
178
+ supporting('SPF or DMARC record present', 0);
179
+ if (typeof input.domainAgeDays === 'number' && Number.isFinite(input.domainAgeDays) && input.domainAgeDays >= 0) {
180
+ if (input.domainAgeDays < 90)
181
+ supporting(`Recently registered (${formatAge(input.domainAgeDays)})`, 6);
182
+ else if (input.domainAgeDays < 365)
183
+ supporting(`Registered under a year ago (${formatAge(input.domainAgeDays)})`, 2);
184
+ }
185
+ if (typeof input.privacyProtected === 'boolean') {
186
+ add('registration', 'Registration privacy is neutral context', 0);
187
+ }
188
+ const rawScore = Math.round(factors.reduce((total, factor) => total + factor.delta, 0));
189
+ const score = Math.max(0, Math.min(100, rawScore));
190
+ const familyOrder = [
191
+ 'registration', 'domain-resemblance', 'brand-presentation', 'credential-lure',
192
+ 'external-intelligence', 'corroboration', 'operational-support',
193
+ ];
194
+ return {
195
+ modelVersion: RISK_MODEL_VERSION,
196
+ score,
197
+ rawScore,
198
+ capped: score !== rawScore,
199
+ factors,
200
+ families: familyOrder
201
+ .filter((id) => familyTotals.has(id))
202
+ .map((id) => ({ id, contribution: familyTotals.get(id) ?? 0, cap: FAMILY_CAPS[id] ?? null })),
203
+ evidenceQuality: scoreQuality(input),
85
204
  };
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
205
  }
146
- export function computeRiskScore(r) {
147
- return explainRiskScore(r)?.score ?? null;
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
+ }
237
+ export function computeRiskScore(input) {
238
+ return explainRiskScore(input)?.score ?? null;
239
+ }
240
+ // Retained only for deterministic offline comparison of reviewed calibration
241
+ // datasets. Runtime lookups always use the current model above.
242
+ export function explainRiskScoreV6(input) {
243
+ const state = input.availability ?? input.state;
244
+ if (typeof state !== 'string' || !RISK_STATES.has(state))
245
+ return null;
246
+ const legacyBase = { registered: 10, for_sale: 5, expiring: 8 };
247
+ const factors = [];
248
+ const totals = new Map();
249
+ const add = (family, label, delta) => {
250
+ factors.push({ family, label, delta });
251
+ totals.set(family, (totals.get(family) ?? 0) + delta);
252
+ };
253
+ add('registration', `Base score for “${STATE_LABELS[state] ?? state}”`, legacyBase[state] ?? 0);
254
+ const contextual = new Set();
255
+ const context = mutationContext(input.mutationTypes);
256
+ if (context) {
257
+ add('domain-resemblance', context.label, context.delta);
258
+ contextual.add('domain-resemblance');
259
+ }
260
+ if (input.faviconMatch === true) {
261
+ add('brand-presentation', 'Favicon matches an official Brand Profile site', 18);
262
+ contextual.add('brand-presentation');
263
+ }
264
+ else if (input.faviconNearMatch === true) {
265
+ add('brand-presentation', 'Favicon resembles an official Brand Profile site', 14);
266
+ contextual.add('brand-presentation');
267
+ }
268
+ if (input.reusesOfficialAssets === true) {
269
+ add('brand-presentation', 'Official asset-host relationship observed', 6);
270
+ contextual.add('brand-presentation');
271
+ }
272
+ if (typeof input.phishingLanguageMatch === 'string' && input.phishingLanguageMatch.trim()) {
273
+ add('credential-lure', 'Suspicious urgency language observed', 8);
274
+ contextual.add('credential-lure');
275
+ }
276
+ if (input.hasPasswordField === true) {
277
+ add('credential-lure', 'Login/password form present', 5);
278
+ contextual.add('credential-lure');
279
+ }
280
+ if (contextual.size >= 2)
281
+ add('corroboration', `Corroborating context across ${contextual.size} distinct evidence families`, contextual.size >= 3 ? 20 : 10);
282
+ const external = calibrateExternalIntelligenceRisk(input.threatIntelligence);
283
+ if (external.factor)
284
+ add('external-intelligence', external.factor.label, external.contribution);
285
+ if (input.activityStatus === 'active')
286
+ add('operational-support', 'Active site in use', 8);
287
+ if (input.hasMx === true)
288
+ add('operational-support', 'Mail server configured', 8);
289
+ if (input.hasSpf === true && input.hasDmarc === true)
290
+ add('operational-support', 'SPF and DMARC records present', 3);
291
+ else if (input.hasSpf === true || input.hasDmarc === true)
292
+ add('operational-support', 'SPF or DMARC record present', 1);
293
+ if (input.privacyProtected === true)
294
+ add('registration', 'WHOIS privacy protected', 3);
295
+ if (typeof input.domainAgeDays === 'number' && Number.isFinite(input.domainAgeDays) && input.domainAgeDays >= 0) {
296
+ if (input.domainAgeDays < 90)
297
+ add('operational-support', `Recently registered (${formatAge(input.domainAgeDays)})`, 10);
298
+ else if (input.domainAgeDays < 365)
299
+ add('operational-support', `Registered under a year ago (${formatAge(input.domainAgeDays)})`, 4);
300
+ }
301
+ const rawScore = Math.round(factors.reduce((total, factor) => total + factor.delta, 0));
302
+ const score = Math.max(0, Math.min(100, rawScore));
303
+ return {
304
+ modelVersion: 6,
305
+ score,
306
+ rawScore,
307
+ capped: score !== rawScore,
308
+ factors,
309
+ families: [...totals].map(([id, contribution]) => ({ id, contribution, cap: null })),
310
+ evidenceQuality: scoreQuality(input),
311
+ };
148
312
  }
149
313
  export function riskTone(score) {
150
314
  if (score === null)
@@ -159,6 +323,6 @@ export function formatScoreBreakdown(explained, separator = '\n') {
159
323
  if (!explained)
160
324
  return '';
161
325
  const parts = explained.factors.map((factor) => `${factor.label} ${factor.delta >= 0 ? '+' : ''}${Math.round(factor.delta)}`);
162
- parts.push(`Total ${explained.score}`);
326
+ parts.push(`Total ${explained.score} · Risk model v${explained.modelVersion} · evidence ${explained.evidenceQuality.state}`);
163
327
  return parts.join(separator);
164
328
  }
@@ -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
+ }
@@ -202,7 +202,7 @@ function parseSecurityTxt(text, options = {}) {
202
202
  const limitations = [
203
203
  ...(signedPayload.signed ? ['A cleartext signature was present but was not cryptographically verified.'] : []),
204
204
  ...(canonicalMatches === false ? ['The fetched URL was not listed by the file as a Canonical location.'] : []),
205
- ...(valuesTruncated ? ['The response or normalized field set exceeded a collection bound.'] : []),
205
+ ...(valuesTruncated ? ['The response or normalised field set exceeded a collection bound.'] : []),
206
206
  ];
207
207
  const detail = state === 'malformed'
208
208
  ? 'The file did not contain at least one valid Contact field and exactly one valid Expires field.'
@@ -314,7 +314,7 @@ async function collectSecurityTxt(hostname, dependencies = {}) {
314
314
  }
315
315
  if (response.status === 404 || response.status === 410) {
316
316
  await response.body?.cancel().catch(() => { });
317
- return emptyResult('absent', 'No security.txt file was published at the standardized location for this hostname.', {
317
+ return emptyResult('absent', 'No security.txt file was published at the standardised location for this hostname.', {
318
318
  observedAt, requestedUrl, finalUrl: result.finalUrl, httpStatus: response.status, durationMs: now() - startedAt,
319
319
  });
320
320
  }
@@ -328,7 +328,7 @@ async function collectSecurityTxt(hostname, dependencies = {}) {
328
328
  const charset = contentType.match(/;\s*charset\s*=\s*"?([^;"\s]+)/iu)?.[1]?.toLowerCase() || null;
329
329
  if (!/^text\/plain(?:\s*;|$)/iu.test(contentType) || (charset !== null && charset !== 'utf-8')) {
330
330
  await response.body?.cancel().catch(() => { });
331
- return emptyResult('unsupported', 'The standardized endpoint did not return a UTF-8 text/plain security.txt document.', {
331
+ return emptyResult('unsupported', 'The standardised endpoint did not return a UTF-8 text/plain security.txt document.', {
332
332
  observedAt, requestedUrl, finalUrl: result.finalUrl, httpStatus: response.status, durationMs: now() - startedAt,
333
333
  });
334
334
  }
@@ -15,6 +15,7 @@ const MAX_SCRIPT_REFERENCE_LENGTH = 2_048;
15
15
  const MAX_SCRIPT_MEDIA_TYPE_LENGTH = 120;
16
16
  const MAX_INLINE_SCRIPT_CHARS = 32_768;
17
17
  const MAX_INLINE_SCRIPT_TOTAL_CHARS = 65_536;
18
+ const MAX_STATIC_VISIBLE_TEXT_CHARS = MAX_STATIC_HTML_CHARS;
18
19
  const MAX_STATIC_FORMS = 50;
19
20
  const MAX_STATIC_INPUTS = 500;
20
21
  const MAX_FORM_ATTRIBUTE_LENGTH = 2_048;
@@ -48,6 +49,7 @@ const VOID_TAGS = new Set([
48
49
  'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta',
49
50
  'param', 'source', 'track', 'wbr',
50
51
  ]);
52
+ const NON_VISIBLE_TEXT_TAGS = new Set(['script', 'style', 'template']);
51
53
  function attributeValue(attributes, name, maximumLength = MAX_FORM_ATTRIBUTE_LENGTH) {
52
54
  const attribute = attributes
53
55
  .slice(0, MAX_ATTRIBUTES_PER_TAG)
@@ -190,7 +192,9 @@ function analyzeStaticHtml(value, options = {}) {
190
192
  const inputLimitReached = supplied.length > MAX_STATIC_HTML_CHARS;
191
193
  const html = supplied.slice(0, MAX_STATIC_HTML_CHARS);
192
194
  const baseUrl = safeBaseUrl(options.baseUrl);
195
+ const includeVisibleText = options.includeVisibleText === true;
193
196
  const markup = [];
197
+ const visibleTextParts = [];
194
198
  const structureTokens = [];
195
199
  const scripts = [];
196
200
  const cspMetaPolicies = [];
@@ -211,9 +215,12 @@ function analyzeStaticHtml(value, options = {}) {
211
215
  let structureLimitReached = false;
212
216
  let scriptLimitReached = false;
213
217
  let inlineLimitReached = false;
218
+ let visibleTextLimitReached = false;
214
219
  let cspMetaLimitReached = false;
215
220
  let insideExplicitHead = false;
216
221
  let scriptElementSeen = false;
222
+ let nonVisibleTextDepth = 0;
223
+ let visibleTextCharacters = 0;
217
224
  function appendInlineScript(chars) {
218
225
  if (!activeInlineScript || !chars)
219
226
  return;
@@ -234,12 +241,26 @@ function analyzeStaticHtml(value, options = {}) {
234
241
  }
235
242
  structureTokens.push(value);
236
243
  }
244
+ function appendVisibleText(chars) {
245
+ if (!includeVisibleText || nonVisibleTextDepth > 0 || !chars)
246
+ return;
247
+ const remaining = MAX_STATIC_VISIBLE_TEXT_CHARS - visibleTextCharacters;
248
+ const retainedLength = Math.max(0, Math.min(chars.length, remaining));
249
+ if (retainedLength > 0) {
250
+ visibleTextParts.push(chars.slice(0, retainedLength));
251
+ visibleTextCharacters += retainedLength;
252
+ }
253
+ if (retainedLength < chars.length)
254
+ visibleTextLimitReached = true;
255
+ }
237
256
  const handler = {
238
257
  onStartTag(token) {
239
258
  const tagName = token.tagName.toLowerCase();
240
259
  const rawTextMode = RAW_TEXT_MODES[tagName];
241
260
  if (!token.selfClosing && rawTextMode !== undefined)
242
261
  tokenizer.state = rawTextMode;
262
+ if (!token.selfClosing && NON_VISIBLE_TEXT_TAGS.has(tagName))
263
+ nonVisibleTextDepth += 1;
243
264
  if (tagsExamined >= MAX_STATIC_HTML_TAGS) {
244
265
  tagLimitReached = true;
245
266
  activeInlineScript = null;
@@ -332,15 +353,20 @@ function analyzeStaticHtml(value, options = {}) {
332
353
  insideExplicitHead = false;
333
354
  if (!VOID_TAGS.has(tagName))
334
355
  appendStructureToken(`/${tagName}`);
356
+ if (NON_VISIBLE_TEXT_TAGS.has(tagName) && nonVisibleTextDepth > 0)
357
+ nonVisibleTextDepth -= 1;
335
358
  },
336
359
  onCharacter(token) {
337
360
  appendInlineScript(token.chars);
361
+ appendVisibleText(token.chars);
338
362
  },
339
363
  onNullCharacter(token) {
340
364
  appendInlineScript(token.chars);
365
+ appendVisibleText(token.chars);
341
366
  },
342
367
  onWhitespaceCharacter(token) {
343
368
  appendInlineScript(token.chars);
369
+ appendVisibleText(token.chars);
344
370
  },
345
371
  onComment() { },
346
372
  onDoctype() { },
@@ -350,6 +376,7 @@ function analyzeStaticHtml(value, options = {}) {
350
376
  tokenizer.write(html, true);
351
377
  return {
352
378
  markup: markup.join('\n'),
379
+ visibleText: visibleTextParts.join(''),
353
380
  structureTokens,
354
381
  scripts,
355
382
  cspMetaPolicies,
@@ -360,8 +387,9 @@ function analyzeStaticHtml(value, options = {}) {
360
387
  structureLimitReached,
361
388
  scriptLimitReached,
362
389
  inlineLimitReached,
390
+ visibleTextLimitReached,
363
391
  tagsExamined,
364
392
  inlineCharactersExamined,
365
393
  };
366
394
  }
367
- export { MAX_ATTRIBUTES_PER_TAG, MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_SCRIPT_ELEMENTS, MAX_SCRIPT_MEDIA_TYPE_LENGTH, MAX_STATIC_FORMS, MAX_STATIC_HTML_CHARS, MAX_STATIC_INPUTS, MAX_STATIC_HTML_TAGS, MAX_STATIC_STRUCTURE_TOKENS, MAX_TAG_LENGTH, MAX_TECHNOLOGY_TAGS, analyzeStaticHtml, };
395
+ export { MAX_ATTRIBUTES_PER_TAG, MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_SCRIPT_ELEMENTS, MAX_SCRIPT_MEDIA_TYPE_LENGTH, MAX_STATIC_FORMS, MAX_STATIC_HTML_CHARS, MAX_STATIC_INPUTS, MAX_STATIC_HTML_TAGS, MAX_STATIC_STRUCTURE_TOKENS, MAX_STATIC_VISIBLE_TEXT_CHARS, MAX_TAG_LENGTH, MAX_TECHNOLOGY_TAGS, analyzeStaticHtml, };
@@ -352,7 +352,7 @@ function normalizeConnectorOutputs(value) {
352
352
  throw new TypeError('Connector outputs must be unique');
353
353
  }
354
354
  if (!entities.length) {
355
- throw new TypeError('Connector outputs require at least one normalized entity type');
355
+ throw new TypeError('Connector outputs require at least one normalised entity type');
356
356
  }
357
357
  const entityTypes = new Set(entities);
358
358
  for (const relationship of relationships) {
@@ -282,7 +282,7 @@ function createThreatfoxIntelligenceAdapter(dependencies = {}) {
282
282
  truncated,
283
283
  detail: findings.length
284
284
  ? `Found ${findings.length} bounded malware-IOC record${findings.length === 1 ? '' : 's'} for this domain${truncated ? '; additional provider records may exist' : ''}.`
285
- : 'The provider reported a match but no returned record could be safely normalized.',
285
+ : 'The provider reported a match but no returned record could be safely normalised.',
286
286
  upstreamStatus,
287
287
  findings,
288
288
  limitations: [