@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
@@ -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
  }
@@ -0,0 +1,54 @@
1
+ // Dependency-free semantic-version validation shared by release tooling,
2
+ // runtime identity, and the frontend build. Callers provide a bounded label so
3
+ // errors retain their local context without reimplementing SemVer parsing.
4
+ export const MAX_SEMANTIC_VERSION_LENGTH = 128;
5
+ function fail(label, detail) {
6
+ throw new TypeError(`${label} ${detail}`);
7
+ }
8
+ function validateIdentifierList(value, label, kind, forbidNumericLeadingZero) {
9
+ const identifiers = value.split('.');
10
+ if (identifiers.some((identifier) => identifier.length === 0)) {
11
+ fail(label, `${kind} contains an empty identifier.`);
12
+ }
13
+ for (const identifier of identifiers) {
14
+ if (!/^[0-9A-Za-z-]+$/u.test(identifier)) {
15
+ fail(label, `${kind} contains an invalid identifier.`);
16
+ }
17
+ if (forbidNumericLeadingZero && /^[0-9]+$/u.test(identifier) && identifier.length > 1 && identifier.startsWith('0')) {
18
+ fail(label, `${kind} numeric identifiers must not contain leading zeroes.`);
19
+ }
20
+ }
21
+ }
22
+ export function normalizeBoundedSemanticVersion(value, label = 'Semantic') {
23
+ if (typeof value !== 'string'
24
+ || value.length === 0
25
+ || value.length > MAX_SEMANTIC_VERSION_LENGTH
26
+ || value.trim() !== value) {
27
+ fail(label, 'version must be a bounded semantic-version string.');
28
+ }
29
+ const buildParts = value.split('+');
30
+ if (buildParts.length > 2)
31
+ fail(label, 'version contains more than one build-metadata separator.');
32
+ const [precedence, buildMetadata] = buildParts;
33
+ if (!precedence)
34
+ fail(label, 'version must include numeric precedence.');
35
+ const prereleaseSeparator = precedence.indexOf('-');
36
+ const core = prereleaseSeparator === -1 ? precedence : precedence.slice(0, prereleaseSeparator);
37
+ const prerelease = prereleaseSeparator === -1 ? undefined : precedence.slice(prereleaseSeparator + 1);
38
+ const coreParts = core.split('.');
39
+ if (coreParts.length !== 3 || coreParts.some((part) => !/^(?:0|[1-9][0-9]*)$/u.test(part))) {
40
+ fail(label, 'version must contain major, minor, and patch numbers without leading zeroes.');
41
+ }
42
+ if (prerelease !== undefined)
43
+ validateIdentifierList(prerelease, label, 'prerelease', true);
44
+ if (buildMetadata !== undefined)
45
+ validateIdentifierList(buildMetadata, label, 'build metadata', false);
46
+ return value;
47
+ }
48
+ export function normalizeBoundedStableSemanticVersion(value, label = 'Stable semantic') {
49
+ const version = normalizeBoundedSemanticVersion(value, label);
50
+ if (version.includes('-') || version.includes('+')) {
51
+ fail(label, 'version must not include prerelease or build identifiers.');
52
+ }
53
+ return version;
54
+ }
@@ -3,6 +3,7 @@
3
3
  // so deeply nested hostile markup cannot create an unbounded element tree.
4
4
  // Only normalized start-tag markup and capped script indicators are retained.
5
5
  import { Tokenizer, TokenizerMode } from 'parse5';
6
+ import { MAX_CSP_META_POLICIES, MAX_RESPONSE_POLICY_HEADER_BYTES, } from "./response-policy.mjs";
6
7
  const MAX_STATIC_HTML_CHARS = 300_000;
7
8
  const MAX_STATIC_HTML_TAGS = 8_192;
8
9
  const MAX_STATIC_STRUCTURE_TOKENS = 4_096;
@@ -192,6 +193,7 @@ function analyzeStaticHtml(value, options = {}) {
192
193
  const markup = [];
193
194
  const structureTokens = [];
194
195
  const scripts = [];
196
+ const cspMetaPolicies = [];
195
197
  const forms = {
196
198
  formsObserved: 0,
197
199
  inputsObserved: 0,
@@ -209,6 +211,9 @@ function analyzeStaticHtml(value, options = {}) {
209
211
  let structureLimitReached = false;
210
212
  let scriptLimitReached = false;
211
213
  let inlineLimitReached = false;
214
+ let cspMetaLimitReached = false;
215
+ let insideExplicitHead = false;
216
+ let scriptElementSeen = false;
212
217
  function appendInlineScript(chars) {
213
218
  if (!activeInlineScript || !chars)
214
219
  return;
@@ -242,6 +247,23 @@ function analyzeStaticHtml(value, options = {}) {
242
247
  }
243
248
  tagsExamined += 1;
244
249
  appendStructureToken(token.selfClosing || VOID_TAGS.has(tagName) ? `${tagName}/` : tagName);
250
+ if (tagName === 'head')
251
+ insideExplicitHead = true;
252
+ if (tagName === 'meta' && insideExplicitHead) {
253
+ const httpEquiv = attributeValue(token.attrs, 'http-equiv', 64);
254
+ if (httpEquiv.value === 'content-security-policy') {
255
+ const content = attributeValue(token.attrs, 'content', MAX_RESPONSE_POLICY_HEADER_BYTES);
256
+ if (content.value === null || !content.value) {
257
+ cspMetaLimitReached = cspMetaLimitReached || content.present || content.truncated;
258
+ }
259
+ else if (cspMetaPolicies.length >= MAX_CSP_META_POLICIES) {
260
+ cspMetaLimitReached = true;
261
+ }
262
+ else {
263
+ cspMetaPolicies.push({ content: content.value, beforeScript: !scriptElementSeen });
264
+ }
265
+ }
266
+ }
245
267
  if (markup.length < MAX_TECHNOLOGY_TAGS) {
246
268
  const serialized = serializedStartTag(tagName, token.attrs);
247
269
  if (serialized.limitReached)
@@ -287,6 +309,7 @@ function analyzeStaticHtml(value, options = {}) {
287
309
  activeInlineScript = null;
288
310
  return;
289
311
  }
312
+ scriptElementSeen = true;
290
313
  if (scripts.length >= MAX_SCRIPT_ELEMENTS) {
291
314
  scriptLimitReached = true;
292
315
  activeInlineScript = null;
@@ -305,6 +328,8 @@ function analyzeStaticHtml(value, options = {}) {
305
328
  const tagName = token.tagName.toLowerCase();
306
329
  if (tagName === 'script')
307
330
  activeInlineScript = null;
331
+ if (tagName === 'head')
332
+ insideExplicitHead = false;
308
333
  if (!VOID_TAGS.has(tagName))
309
334
  appendStructureToken(`/${tagName}`);
310
335
  },
@@ -327,6 +352,8 @@ function analyzeStaticHtml(value, options = {}) {
327
352
  markup: markup.join('\n'),
328
353
  structureTokens,
329
354
  scripts,
355
+ cspMetaPolicies,
356
+ cspMetaLimitReached,
330
357
  forms,
331
358
  inputLimitReached,
332
359
  tagLimitReached,
@@ -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) {
@@ -3,6 +3,8 @@
3
3
  // sample. The adapter is disabled unless explicitly enabled and credentialed.
4
4
  import { abusechAuthKey } from "./abusech-auth.mjs";
5
5
  import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
6
+ import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
7
+ import { providerPolicyAdmission } from "./provider-policy-admission.mjs";
6
8
  import { createThreatIntelligenceResult, defineThreatIntelligenceProvider, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-contract.mjs";
7
9
  const THREATFOX_SEARCH_ENDPOINT = 'https://threatfox-api.abuse.ch/api/v1/';
8
10
  const THREATFOX_MAX_RESULTS = 20;
@@ -46,15 +48,18 @@ function threatfoxConfiguration(env = process.env) {
46
48
  const source = env && typeof env === 'object' ? env : {};
47
49
  const enabled = enabledValue(source.WHOISLEUTH_ENABLE_THREATFOX);
48
50
  const authKey = abusechAuthKey(source);
51
+ const policy = providerPolicyAdmission(THREATFOX_PROVIDER.terms, source);
49
52
  return {
50
53
  enabled,
51
- configured: enabled && authKey !== null,
54
+ configured: enabled && policy.allowed && authKey !== null,
52
55
  authKey: enabled ? authKey : null,
53
56
  reason: !enabled
54
57
  ? 'Malware-IOC intelligence is not enabled for this deployment.'
55
- : authKey
56
- ? null
57
- : 'Malware-IOC intelligence is enabled but its API credential is unavailable or malformed.',
58
+ : !policy.allowed
59
+ ? policy.reason
60
+ : authKey
61
+ ? null
62
+ : 'Malware-IOC intelligence is enabled but its API credential is unavailable or malformed.',
58
63
  };
59
64
  }
60
65
  function boundedText(value, maxLength) {
@@ -192,7 +197,7 @@ function createThreatfoxIntelligenceAdapter(dependencies = {}) {
192
197
  accept: 'application/json',
193
198
  'auth-key': configuration.authKey,
194
199
  'content-type': 'application/json',
195
- 'user-agent': 'WHOISleuth/1.0',
200
+ ...whoisleuthRequestHeaders(),
196
201
  },
197
202
  body,
198
203
  signal: AbortSignal.timeout(THREATFOX_PROVIDER.limits.timeoutMs),
@@ -277,7 +282,7 @@ function createThreatfoxIntelligenceAdapter(dependencies = {}) {
277
282
  truncated,
278
283
  detail: findings.length
279
284
  ? `Found ${findings.length} bounded malware-IOC record${findings.length === 1 ? '' : 's'} for this domain${truncated ? '; additional provider records may exist' : ''}.`
280
- : '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.',
281
286
  upstreamStatus,
282
287
  findings,
283
288
  limitations: [
@@ -12,7 +12,8 @@ import * as tls from 'node:tls';
12
12
  import { domainToASCII } from 'node:url';
13
13
  import { createObservation } from "./observation.mjs";
14
14
  import { isPrivateAddress, resolvePublicAddresses } from "./safe-fetch.mjs";
15
- const TLS_PROFILE_VERSION = 2;
15
+ import { parseCertificateExtensionProfile, } from "./certificate-extension-profile.mjs";
16
+ const TLS_PROFILE_VERSION = 3;
16
17
  const TLS_PORT = 443;
17
18
  const TLS_TIMEOUT_MS = 5000;
18
19
  const MAX_RESOLVED_ADDRESSES = 64;
@@ -486,6 +487,15 @@ function normalizeCertificate(value, state, options = {}) {
486
487
  };
487
488
  certificate.extendedKeyUsage = certificatePurposes(parsedCertificate?.keyUsage || record.ext_key_usage, state);
488
489
  certificate.authorityInformationAccess = authorityInformationAccess(parsedCertificate?.infoAccess || record.infoAccess, state);
490
+ const extensionProfile = parseCertificateExtensionProfile(record.raw);
491
+ if (extensionProfile.parsed || extensionProfile.partial) {
492
+ certificate.extensionProfile = extensionProfile;
493
+ if (extensionProfile.partial)
494
+ state.discarded += 1;
495
+ if (extensionProfile.certificatePolicies.truncated || extensionProfile.crlDistributionPoints.truncated) {
496
+ state.truncated = true;
497
+ }
498
+ }
489
499
  }
490
500
  return certificate;
491
501
  }
@@ -562,7 +572,7 @@ function tlsFindings(profile) {
562
572
  if (profile.validity.status === 'not_yet_valid')
563
573
  findings.push({ id: 'certificate_not_yet_valid', tone: 'warning', label: 'Certificate not yet valid', detail: 'The leaf certificate validity period had not started at observation time.' });
564
574
  if (profile.authorization.authorized === false)
565
- findings.push({ id: 'certificate_unauthorized', tone: 'warning', label: 'Certificate not authorized', detail: 'The runtime trust store did not authorize the observed certificate chain. This can reflect an incomplete chain, private CA, self-signed certificate, expiry, or another validation failure.' });
575
+ findings.push({ id: 'certificate_unauthorized', tone: 'warning', label: 'Certificate not authorised', detail: 'The runtime trust store did not authorise the observed certificate chain. This can reflect an incomplete chain, private CA, self-signed certificate, expiry, or another validation failure.' });
566
576
  if (profile.hostname.matches === false)
567
577
  findings.push({ id: 'hostname_mismatch', tone: 'warning', label: 'Hostname mismatch', detail: 'The observed leaf certificate did not match the SNI hostname.' });
568
578
  if (profile.certificate?.subjectAltNames?.dnsNames.some((name) => name.startsWith('*.')))
@@ -631,7 +641,7 @@ function buildTlsObservation(handshake = {}, options = {}) {
631
641
  truncated: state.truncated,
632
642
  limitations: [
633
643
  'This is a point-in-time TLS handshake to one validated public address; other addresses or edge locations may present different results.',
634
- 'Authorization reflects the certificate authorities and verification behavior available to this runtime.',
644
+ 'Authorisation reflects the certificate authorities and verification behaviour available to this runtime.',
635
645
  'The profile records the negotiated connection only and does not enumerate supported TLS versions or cipher suites.',
636
646
  ...(state.truncated ? ['One or more certificate fields or chain entries reached a retention limit.'] : []),
637
647
  ],
@@ -0,0 +1,194 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { createHash } from 'node:crypto';
3
+ import { domainToASCII } from 'node:url';
4
+ const TLSA_EVIDENCE_SCHEMA = 'whoisleuth.tlsa-evidence';
5
+ const TLSA_EVIDENCE_VERSION = 1;
6
+ const MAX_TLSA_RECORDS = 50;
7
+ const MAX_CERTIFICATE_BYTES = 256 * 1024;
8
+ const MAX_AUTHORITY_MATERIALS = 10;
9
+ function record(value) {
10
+ return value && typeof value === 'object' && !Array.isArray(value)
11
+ ? value
12
+ : null;
13
+ }
14
+ function integer(value, minimum, maximum) {
15
+ return Number.isInteger(value) && Number(value) >= minimum && Number(value) <= maximum
16
+ ? Number(value)
17
+ : null;
18
+ }
19
+ function hex(value) {
20
+ if (typeof value !== 'string')
21
+ return null;
22
+ const normalized = value.replace(/\s+/gu, '').toLowerCase();
23
+ return normalized && normalized.length % 2 === 0 && /^[0-9a-f]+$/u.test(normalized) ? normalized : null;
24
+ }
25
+ function normalizeTlsaRecord(value) {
26
+ const source = record(value);
27
+ if (!source)
28
+ return null;
29
+ const usage = integer(source.usage, 0, 3);
30
+ const selector = integer(source.selector, 0, 1);
31
+ const matchingType = integer(source.matchingType, 0, 2);
32
+ const associationData = hex(source.associationData);
33
+ if (usage === null || selector === null || matchingType === null || !associationData)
34
+ return null;
35
+ if (matchingType === 1 && associationData.length !== 64)
36
+ return null;
37
+ if (matchingType === 2 && associationData.length !== 128)
38
+ return null;
39
+ if (matchingType === 0 && associationData.length > MAX_CERTIFICATE_BYTES * 2)
40
+ return null;
41
+ return Object.freeze({ usage, selector, matchingType, associationData });
42
+ }
43
+ function decodeBoundedBase64(value) {
44
+ if (typeof value !== 'string' || value.length > MAX_CERTIFICATE_BYTES * 2 || !/^[A-Za-z0-9+/]+={0,2}$/u.test(value))
45
+ return null;
46
+ const decoded = Buffer.from(value, 'base64');
47
+ return decoded.length > 0 && decoded.length <= MAX_CERTIFICATE_BYTES && decoded.toString('base64') === value ? decoded : null;
48
+ }
49
+ function normalizeTlsaServiceName(value) {
50
+ if (typeof value !== 'string' || value.length > 270)
51
+ return null;
52
+ const match = /^_(\d{1,5})\._(tcp|udp)\.(.+)$/iu.exec(value.trim().replace(/\.$/u, ''));
53
+ if (!match)
54
+ return null;
55
+ const port = Number(match[1]);
56
+ const transport = match[2]?.toLowerCase();
57
+ const hostname = domainToASCII(String(match[3] ?? '').toLowerCase());
58
+ if (!Number.isInteger(port) || port < 1 || port > 65_535 || (transport !== 'tcp' && transport !== 'udp'))
59
+ return null;
60
+ if (!hostname || hostname.length > 253 || hostname.split('.').some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label)))
61
+ return null;
62
+ return Object.freeze({ ownerName: `_${port}._${transport}.${hostname}`, port, transport, hostname });
63
+ }
64
+ function normalizeCertificateMaterial(value) {
65
+ const source = record(value);
66
+ if (!source)
67
+ return null;
68
+ const certificateDer = decodeBoundedBase64(source.certificateDerBase64);
69
+ const spkiDer = decodeBoundedBase64(source.spkiDerBase64);
70
+ return certificateDer || spkiDer ? Object.freeze({ certificateDer, spkiDer }) : null;
71
+ }
72
+ function selectedValues(recordValue, materials) {
73
+ return materials.flatMap((material) => {
74
+ const selected = recordValue.selector === 0 ? material.certificateDer : material.spkiDer;
75
+ if (!selected)
76
+ return [];
77
+ if (recordValue.matchingType === 0)
78
+ return [selected.toString('hex')];
79
+ if (recordValue.matchingType === 1)
80
+ return [createHash('sha256').update(selected).digest('hex')];
81
+ return [createHash('sha512').update(selected).digest('hex')];
82
+ });
83
+ }
84
+ function normalizeDnssecState(value) {
85
+ return value === 'validated' || value === 'insecure' || value === 'bogus' ? value : 'unavailable';
86
+ }
87
+ function normalizePkixValidationState(value) {
88
+ return value === 'validated' || value === 'failed' ? value : 'unavailable';
89
+ }
90
+ function analyzeTlsaEvidence(input) {
91
+ const service = normalizeTlsaServiceName(input.serviceName);
92
+ const dnssecState = normalizeDnssecState(input.dnssecState);
93
+ const pkixValidationState = normalizePkixValidationState(input.pkixValidationState);
94
+ const leafMaterial = normalizeCertificateMaterial({
95
+ certificateDerBase64: input.certificateDerBase64,
96
+ spkiDerBase64: input.spkiDerBase64,
97
+ });
98
+ const rawAuthorityMaterials = Array.isArray(input.authorityMaterials) ? input.authorityMaterials : [];
99
+ const normalizedAuthorityMaterials = rawAuthorityMaterials
100
+ .slice(0, MAX_AUTHORITY_MATERIALS)
101
+ .map(normalizeCertificateMaterial);
102
+ const authorityMaterials = normalizedAuthorityMaterials
103
+ .filter((item) => item !== null);
104
+ const authorityMaterialRejectedCount = normalizedAuthorityMaterials.length - authorityMaterials.length;
105
+ const authorityMaterialTruncated = rawAuthorityMaterials.length > MAX_AUTHORITY_MATERIALS;
106
+ const raw = Array.isArray(input.records) ? input.records : null;
107
+ if (!raw || !service) {
108
+ return Object.freeze({
109
+ schema: TLSA_EVIDENCE_SCHEMA,
110
+ version: TLSA_EVIDENCE_VERSION,
111
+ state: 'invalid',
112
+ dnssecState,
113
+ pkixValidationState,
114
+ service,
115
+ authorityMaterialCount: authorityMaterials.length,
116
+ authorityMaterialRejectedCount,
117
+ authorityMaterialTruncated,
118
+ records: Object.freeze([]),
119
+ rejectedCount: 0,
120
+ truncated: false,
121
+ limitations: Object.freeze([
122
+ ...(!raw ? ['TLSA evidence must be supplied as a bounded record array.'] : []),
123
+ ...(!service ? ['TLSA evidence must be bound to a valid _port._transport.hostname service name.'] : []),
124
+ ]),
125
+ });
126
+ }
127
+ const truncated = raw.length > MAX_TLSA_RECORDS;
128
+ const normalized = raw.slice(0, MAX_TLSA_RECORDS).map(normalizeTlsaRecord);
129
+ const rejectedCount = normalized.filter((item) => item === null).length + Math.max(0, raw.length - MAX_TLSA_RECORDS);
130
+ const records = normalized.filter((item) => item !== null);
131
+ const results = records.map((item) => {
132
+ const materials = item.usage === 0 || item.usage === 2
133
+ ? authorityMaterials
134
+ : leafMaterial ? [leafMaterial] : [];
135
+ const selected = selectedValues(item, materials);
136
+ if (!selected.length) {
137
+ const role = item.usage === 0 || item.usage === 2 ? 'authority' : 'leaf';
138
+ return Object.freeze({ ...item, state: 'unavailable', reason: `No bounded ${role} ${item.selector === 0 ? 'certificate' : 'SPKI'} bytes were supplied for comparison.` });
139
+ }
140
+ const matched = selected.includes(item.associationData);
141
+ return Object.freeze({
142
+ ...item,
143
+ state: matched ? 'matched' : 'different',
144
+ reason: matched ? 'The supplied certificate material matched this TLSA association.' : 'The supplied certificate material differed from this TLSA association.',
145
+ });
146
+ });
147
+ const hasMatch = results.some((item) => item.state === 'matched');
148
+ const hasDifferent = results.some((item) => item.state === 'different');
149
+ const hasUnavailable = results.some((item) => item.state === 'unavailable');
150
+ const limitations = [
151
+ 'This offline comparison does not connect to the target, retrieve DNS, negotiate SMTP STARTTLS, or validate a DNSSEC chain.',
152
+ 'A TLSA match is usable as DANE evidence only when the DNSSEC state was independently validated for the same observation.',
153
+ 'PKIX-TA and PKIX-EE usages require an independently validated PKIX path; association matching alone does not establish that prerequisite.',
154
+ ];
155
+ if (dnssecState !== 'validated' && hasMatch) {
156
+ limitations.push('Certificate material matched, but DNSSEC was not validated, so the result remains partial.');
157
+ }
158
+ const matchedPkixDependent = results.some((item) => item.state === 'matched' && (item.usage === 0 || item.usage === 1));
159
+ const matchedDaneOnly = results.some((item) => item.state === 'matched' && (item.usage === 2 || item.usage === 3));
160
+ if (matchedPkixDependent && pkixValidationState !== 'validated') {
161
+ limitations.push('A PKIX-dependent association matched, but a validated PKIX path was not supplied, so the result is not complete.');
162
+ }
163
+ if (authorityMaterialRejectedCount > 0 || authorityMaterialTruncated) {
164
+ limitations.push('Authority certificate material was malformed or exceeded the review bound, so an unmatched trust-anchor association remains partial.');
165
+ }
166
+ const trustedMatch = dnssecState === 'validated'
167
+ && (matchedDaneOnly || matchedPkixDependent && pkixValidationState === 'validated');
168
+ const state = results.length === 0
169
+ ? 'unavailable'
170
+ : hasDifferent && !hasMatch && rejectedCount === 0 && !truncated
171
+ && authorityMaterialRejectedCount === 0 && !authorityMaterialTruncated
172
+ ? 'different'
173
+ : hasMatch && (dnssecState === 'bogus' || matchedPkixDependent && pkixValidationState === 'failed' && !matchedDaneOnly)
174
+ ? 'untrusted'
175
+ : trustedMatch && !hasUnavailable && rejectedCount === 0 && !truncated
176
+ ? 'matched'
177
+ : 'partial';
178
+ return Object.freeze({
179
+ schema: TLSA_EVIDENCE_SCHEMA,
180
+ version: TLSA_EVIDENCE_VERSION,
181
+ state,
182
+ dnssecState,
183
+ pkixValidationState,
184
+ service,
185
+ authorityMaterialCount: authorityMaterials.length,
186
+ authorityMaterialRejectedCount,
187
+ authorityMaterialTruncated,
188
+ records: Object.freeze(results),
189
+ rejectedCount,
190
+ truncated,
191
+ limitations: Object.freeze(limitations),
192
+ });
193
+ }
194
+ export { MAX_TLSA_RECORDS, MAX_AUTHORITY_MATERIALS, TLSA_EVIDENCE_SCHEMA, TLSA_EVIDENCE_VERSION, analyzeTlsaEvidence, normalizeTlsaRecord, normalizeTlsaServiceName, };
@@ -6,6 +6,8 @@
6
6
  import { classifyQuery } from "./classify.mjs";
7
7
  import { abusechAuthKey } from "./abusech-auth.mjs";
8
8
  import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
9
+ import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
10
+ import { providerPolicyAdmission } from "./provider-policy-admission.mjs";
9
11
  import { createThreatIntelligenceResult, defineThreatIntelligenceProvider, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-contract.mjs";
10
12
  const URLHAUS_HOST_ENDPOINT = 'https://urlhaus-api.abuse.ch/v1/host/';
11
13
  const URLHAUS_MAX_RESULTS = 20;
@@ -53,15 +55,18 @@ function urlhausConfiguration(env = process.env) {
53
55
  const source = env && typeof env === 'object' ? env : {};
54
56
  const enabled = enabledValue(source.WHOISLEUTH_ENABLE_URLHAUS);
55
57
  const authKey = abusechAuthKey(source);
58
+ const policy = providerPolicyAdmission(URLHAUS_PROVIDER.terms, source);
56
59
  return {
57
60
  enabled,
58
- configured: enabled && authKey !== null,
61
+ configured: enabled && policy.allowed && authKey !== null,
59
62
  authKey: enabled ? authKey : null,
60
63
  reason: !enabled
61
64
  ? 'Malware-host intelligence is not enabled for this deployment.'
62
- : authKey
63
- ? null
64
- : 'Malware-host intelligence is enabled but its API credential is unavailable or malformed.',
65
+ : !policy.allowed
66
+ ? policy.reason
67
+ : authKey
68
+ ? null
69
+ : 'Malware-host intelligence is enabled but its API credential is unavailable or malformed.',
65
70
  };
66
71
  }
67
72
  function boundedText(value, maxLength) {
@@ -231,7 +236,7 @@ function createUrlhausIntelligenceAdapter(dependencies = {}) {
231
236
  accept: 'application/json',
232
237
  'auth-key': configuration.authKey,
233
238
  'content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
234
- 'user-agent': 'WHOISleuth/1.0',
239
+ ...whoisleuthRequestHeaders(),
235
240
  },
236
241
  body,
237
242
  signal: AbortSignal.timeout(URLHAUS_PROVIDER.limits.timeoutMs),
@@ -328,7 +333,7 @@ function createUrlhausIntelligenceAdapter(dependencies = {}) {
328
333
  truncated,
329
334
  detail: findings.length
330
335
  ? `Found ${findings.length} bounded malware-distribution record${findings.length === 1 ? '' : 's'} for this host${truncated ? '; additional provider records may exist' : ''}.`
331
- : 'The provider reported this host but no returned record could be safely normalized.',
336
+ : 'The provider reported this host but no returned record could be safely normalised.',
332
337
  upstreamStatus,
333
338
  findings,
334
339
  limitations: [
@@ -4,6 +4,8 @@
4
4
  // key; credentials are sent only in a header to the fixed HTTPS endpoint.
5
5
  import { classifyQuery } from "./classify.mjs";
6
6
  import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
7
+ import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
8
+ import { providerPolicyAdmission } from "./provider-policy-admission.mjs";
7
9
  import { createThreatIntelligenceResult, defineThreatIntelligenceProvider, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-contract.mjs";
8
10
  const URLSCAN_SEARCH_ENDPOINT = 'https://urlscan.io/api/v1/search/';
9
11
  const URLSCAN_SEARCH_DAYS = 90;
@@ -57,15 +59,18 @@ function urlscanConfiguration(env = process.env) {
57
59
  const source = env && typeof env === 'object' ? env : {};
58
60
  const enabled = enabledValue(source.WHOISLEUTH_ENABLE_URLSCAN);
59
61
  const apiKey = normalizedApiKey(source.URLSCAN_API_KEY);
62
+ const policy = providerPolicyAdmission(URLSCAN_PROVIDER.terms, source);
60
63
  return {
61
64
  enabled,
62
- configured: enabled && apiKey !== null,
65
+ configured: enabled && policy.allowed && apiKey !== null,
63
66
  apiKey: enabled ? apiKey : null,
64
67
  reason: !enabled
65
68
  ? 'Archived URLscan verdict search is not enabled for this deployment.'
66
- : apiKey
67
- ? null
68
- : 'Archived URLscan verdict search is enabled but its API credential is unavailable or malformed.',
69
+ : !policy.allowed
70
+ ? policy.reason
71
+ : apiKey
72
+ ? null
73
+ : 'Archived URLscan verdict search is enabled but its API credential is unavailable or malformed.',
69
74
  };
70
75
  }
71
76
  function boundedText(value, maxLength) {
@@ -230,7 +235,7 @@ function createUrlscanIntelligenceAdapter(dependencies = {}) {
230
235
  headers: {
231
236
  accept: 'application/json',
232
237
  'api-key': configuration.apiKey,
233
- 'user-agent': 'WHOISleuth/1.0',
238
+ ...whoisleuthRequestHeaders(),
234
239
  },
235
240
  signal: AbortSignal.timeout(URLSCAN_PROVIDER.limits.timeoutMs),
236
241
  }, { maxRedirects: 0 });