@slicedearth/whoisleuth-cli 2.5.1 → 2.6.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 (122) hide show
  1. package/DISCLOSURE +1 -1
  2. package/cli/artifact-validation/case-response.mjs +51 -8
  3. package/cli/artifact-validation/lookup-evidence.mjs +15 -1
  4. package/cli/artifact-validation/signed-review.mjs +2 -51
  5. package/cli/artifact-verify.mjs +9 -2
  6. package/cli/bulk-command-runner.mjs +4 -6
  7. package/cli/case-command.mjs +79 -3
  8. package/cli/case-pack.mjs +7 -2
  9. package/cli/command-definition.mjs +2 -1
  10. package/cli/discovery-scan-command-runner.mjs +4 -6
  11. package/cli/domain-feed.mjs +61 -0
  12. package/cli/formatters/json.mjs +1 -13
  13. package/cli/indicator-set-command.mjs +1 -2
  14. package/cli/intake-command.mjs +43 -3
  15. package/cli/investigation-artifacts.mjs +2 -3
  16. package/cli/investigation-package-review.mjs +2 -0
  17. package/cli/local-mmdb-review.mjs +27 -4
  18. package/cli/lookup-command-runner.mjs +1 -0
  19. package/cli/offline-evidence-review.mjs +51 -5
  20. package/cli/review-arguments.mjs +3 -2
  21. package/cli/review-command-definitions.mjs +14 -8
  22. package/cli/review-command-runner.mjs +6 -3
  23. package/cli/risk-calibration.mjs +6 -4
  24. package/cli/selected-input-worker.mjs +12 -2
  25. package/cli/support-command-runner.mjs +3 -3
  26. package/cli/watchlist-review.mjs +43 -0
  27. package/cli/workflow-arguments.mjs +14 -0
  28. package/cli/workflow-command-definitions.mjs +26 -1
  29. package/cli/workflow-command-runner.mjs +32 -0
  30. package/docs/cli.md +99 -2
  31. package/docs/contextual-reviews.md +28 -5
  32. package/docs/infrastructure-observations.md +126 -0
  33. package/docs/offline-intake-context.md +213 -0
  34. package/lib/credential-form-attribution.mjs +136 -0
  35. package/lib/credential-surface-profile.mjs +6 -2
  36. package/lib/dns-delegation-health.mjs +23 -7
  37. package/lib/dns-intelligence.mjs +18 -4
  38. package/lib/domain-posture-analysis.mjs +53 -2
  39. package/lib/domain-posture-parsers.mjs +9 -0
  40. package/lib/domain-posture.mjs +29 -10
  41. package/lib/evidence-export-privacy.mjs +5 -2
  42. package/lib/evidence-report.mjs +1 -0
  43. package/lib/favicon.mjs +13 -4
  44. package/lib/html-signals.mjs +6 -0
  45. package/lib/lookup-child-profile-contract.mjs +28 -11
  46. package/lib/lookup-response-contract.mjs +1 -0
  47. package/lib/operation-budget.mjs +4 -0
  48. package/lib/service-binding-dns.mjs +19 -2
  49. package/lib/sslbl-certificates.generated.mjs +138 -137
  50. package/lib/static-html-analysis.mjs +32 -4
  51. package/lib/technology-evidence-role.mjs +1 -51
  52. package/lib/tls-intelligence.mjs +30 -5
  53. package/lib/website-enrichment.mjs +39 -23
  54. package/lib/whois-chain.mjs +12 -3
  55. package/lib/whois-common-formats.mjs +3 -1
  56. package/lib/whois-contacts.mjs +13 -2
  57. package/package.json +7 -5
  58. package/packages/analysis/risk-scoring.mjs +12 -9
  59. package/packages/cases/case-containment-handoff.mjs +195 -0
  60. package/packages/cases/case-evidence-links.mjs +16 -0
  61. package/packages/cases/case-evidence-model.mjs +14 -3
  62. package/packages/cases/case-object-coverage.mjs +33 -0
  63. package/packages/cases/case-packet-correction.mjs +106 -0
  64. package/packages/cases/case-recheck-model.mjs +35 -5
  65. package/packages/cases/case-record-operations.mjs +74 -9
  66. package/packages/cases/case-record-projection.mjs +15 -3
  67. package/packages/cases/case-record-version-input.mjs +23 -1
  68. package/packages/cases/case-report.mjs +30 -0
  69. package/packages/cases/case-requested-evidence.mjs +1 -1
  70. package/packages/cases/case-response-actions.mjs +57 -1
  71. package/packages/cases/case-response-model.mjs +7 -0
  72. package/packages/cases/case-response-object.mjs +119 -0
  73. package/packages/cases/case-response-outcomes.mjs +81 -14
  74. package/packages/cases/case-response-packet-render.mjs +22 -3
  75. package/packages/cases/case-response-packet.mjs +30 -2
  76. package/packages/cases/case-response-review-inputs.mjs +48 -7
  77. package/packages/cases/case-workflow-metadata.mjs +1 -9
  78. package/packages/contracts/analyst-interchange.mjs +7 -5
  79. package/packages/contracts/candidate-watch-review.mjs +54 -0
  80. package/packages/contracts/case-portability.mjs +260 -42
  81. package/packages/contracts/cli-command-semantics.mjs +3 -0
  82. package/packages/contracts/context-review.mjs +2 -1
  83. package/packages/contracts/external-observation-interchange.mjs +96 -28
  84. package/packages/contracts/lookup-evidence.mjs +3 -2
  85. package/packages/contracts/message-intake.mjs +7 -1
  86. package/packages/contracts/risk-calibration.mjs +44 -29
  87. package/packages/contracts/schema-lifecycle.mjs +3 -1
  88. package/packages/contracts/web-capture.mjs +1 -1
  89. package/packages/contracts/workspace-portability.mjs +86 -16
  90. package/packages/evidence/collection-quality.mjs +1 -1
  91. package/packages/evidence/credential-form-attribution.mjs +60 -0
  92. package/packages/evidence/image-regions.mjs +15 -0
  93. package/packages/evidence/record-identity.mjs +60 -16
  94. package/packages/evidence/technology-evidence-role.mjs +51 -0
  95. package/packages/interchange/external-findings-import.mjs +32 -17
  96. package/packages/interchange/json-output.mjs +14 -0
  97. package/packages/interchange/managed-indicator-export.mjs +3 -2
  98. package/packages/interchange/managed-indicator-set.mjs +13 -0
  99. package/packages/investigation/acquisition-decision-packet.mjs +78 -0
  100. package/packages/investigation/context-review.mjs +3 -3
  101. package/packages/investigation/document-intake.mjs +9 -3
  102. package/packages/investigation/domain-history-review.mjs +43 -11
  103. package/packages/investigation/domain-transition-review.mjs +2 -0
  104. package/packages/investigation/infrastructure-observation.mjs +234 -0
  105. package/packages/investigation/intake-context.mjs +284 -0
  106. package/packages/investigation/intake-indicators.mjs +116 -0
  107. package/packages/investigation/intake-phones.mjs +155 -0
  108. package/packages/investigation/intake-report.mjs +2 -1
  109. package/packages/investigation/investigation-manifest.mjs +33 -7
  110. package/packages/investigation/link-intake.mjs +38 -8
  111. package/packages/investigation/message-intake.mjs +55 -15
  112. package/packages/monitoring/domain-feed.mjs +321 -0
  113. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  114. package/packages/workspace/brand-keyword-campaign.mjs +231 -0
  115. package/packages/workspace/brand-profile-model.mjs +46 -2
  116. package/packages/workspace/bulk-session-model.mjs +4 -0
  117. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  118. package/packages/workspace/detection-rule-model.mjs +10 -2
  119. package/packages/workspace/shortlist-model.mjs +10 -2
  120. package/packages/workspace/watchlist-history.mjs +31 -4
  121. package/packages/workspace/watchlist-store.mjs +136 -7
  122. package/third-party-notices.txt +29 -29
@@ -4,6 +4,48 @@
4
4
  import { parse as parseDomain } from 'tldts';
5
5
  import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
6
6
  import { parseDmarcRecords, parseDmarcReportingAuthorization, parseReportingDestination, parseSpfRecords } from "./domain-posture-parsers.mjs";
7
+ const SPF_PERMISSIVE_PATH_ISSUE = 'Observed permissive +all evidence on a potentially reachable positive SPF include or redirect path; review sender authorisation.';
8
+ // This is a conservative universal-result check for unconditional all/include
9
+ // chains, not a sender evaluator. Conditional mechanisms remain unknown.
10
+ function spfStaticResult(policy) {
11
+ const parsed = policy.parsed;
12
+ if (!parsed?.valid)
13
+ return { everySender: null, permissive: false };
14
+ let uncertain = false, permissive = false;
15
+ const finish = (result) => ({
16
+ everySender: !uncertain ? result : null,
17
+ permissive,
18
+ });
19
+ for (const mechanism of parsed.mechanisms) {
20
+ if (mechanism.kind === 'all') {
21
+ if (mechanism.qualifier === '+')
22
+ permissive = true;
23
+ return finish(mechanism.qualifier);
24
+ }
25
+ if (mechanism.kind === 'include') {
26
+ const child = policy.children.get(mechanism.includeIndex);
27
+ const result = child ? spfStaticResult(child) : { everySender: null, permissive: false };
28
+ if (mechanism.qualifier === '+' && result.permissive)
29
+ permissive = true;
30
+ if (result.everySender === '+')
31
+ return finish(mechanism.qualifier);
32
+ if (result.everySender !== null)
33
+ continue; // Only child pass matches include.
34
+ uncertain = true;
35
+ }
36
+ else {
37
+ // Without sender evaluation, even a literal mechanism remains conditional.
38
+ uncertain = true;
39
+ }
40
+ }
41
+ if (parsed.redirect) {
42
+ const child = policy.children.get(parsed.includes.length);
43
+ const result = child ? spfStaticResult(child) : { everySender: null, permissive: false };
44
+ permissive ||= result.permissive;
45
+ return result.everySender === null ? { everySender: null, permissive } : finish(result.everySender);
46
+ }
47
+ return { everySender: null, permissive };
48
+ }
7
49
  const SPF_LOOKUP_LIMIT = 10;
8
50
  const SPF_VOID_LOOKUP_LIMIT = 2;
9
51
  const SPF_MAX_DEPTH = 5;
@@ -41,6 +83,7 @@ function branchFor(item, state, terminalPolicy, dnsLookupTerms, issues) {
41
83
  };
42
84
  }
43
85
  async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
86
+ const rootPolicy = { parsed: null, children: new Map() };
44
87
  const queue = [{
45
88
  domain: rootDomain,
46
89
  parent: null,
@@ -49,6 +92,7 @@ async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
49
92
  path: [rootDomain],
50
93
  records: rootQuery.records,
51
94
  error: rootQuery.error,
95
+ policy: rootPolicy,
52
96
  }];
53
97
  const branches = [];
54
98
  const issues = [];
@@ -100,6 +144,7 @@ async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
100
144
  branches.push(branchFor(item, parsed.valid ? 'success' : 'invalid', parsed.terminalPolicy, parsed.dnsLookupTerms, parsed.issues));
101
145
  if (!parsed.valid)
102
146
  continue;
147
+ item.policy.parsed = parsed;
103
148
  if (dnsLookupTerms > SPF_LOOKUP_LIMIT) {
104
149
  boundReached = true;
105
150
  issues.push(`The expanded policy declares more than ${SPF_LOOKUP_LIMIT} DNS-querying terms.`);
@@ -111,7 +156,9 @@ async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
111
156
  ...parsed.includes.map((domain) => ({ relation: 'include', domain })),
112
157
  ...(parsed.redirect ? [{ relation: 'redirect', domain: parsed.redirect }] : []),
113
158
  ];
114
- for (const dependency of dependencies) {
159
+ for (const [dependencyIndex, dependency] of dependencies.entries()) {
160
+ const policy = { parsed: null, children: new Map() };
161
+ item.policy.children.set(dependencyIndex, policy);
115
162
  const normalized = strictHostname(dependency.domain);
116
163
  if (!normalized) {
117
164
  branches.push(branchFor({
@@ -120,6 +167,7 @@ async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
120
167
  relation: dependency.relation,
121
168
  depth: item.depth + 1,
122
169
  path: item.path,
170
+ policy,
123
171
  }, 'invalid', null, 0, ['The SPF dependency is not a literal public hostname; macros are not expanded by this audit.']));
124
172
  continue;
125
173
  }
@@ -129,6 +177,7 @@ async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
129
177
  relation: dependency.relation,
130
178
  depth: item.depth + 1,
131
179
  path: [...item.path, normalized],
180
+ policy,
132
181
  });
133
182
  }
134
183
  }
@@ -145,6 +194,8 @@ async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
145
194
  : incomplete
146
195
  ? 'partial'
147
196
  : 'complete';
197
+ if (rootPolicy.parsed?.terminalPolicy !== 'pass' && spfStaticResult(rootPolicy).permissive)
198
+ issues.unshift(SPF_PERMISSIVE_PATH_ISSUE);
148
199
  return {
149
200
  version: 1,
150
201
  state,
@@ -293,4 +344,4 @@ function buildExternalDependencies({ domain, nameservers, mx, spfExpansion, dmar
293
344
  || left.target.localeCompare(right.target)
294
345
  || left.source.localeCompare(right.source)));
295
346
  }
296
- export { SPF_LOOKUP_LIMIT, SPF_MAX_DEPTH, SPF_VOID_LOOKUP_LIMIT, buildExternalDependencies, expandSpfPolicy, reportDestinationDomain, validateDmarcExternalReporting, };
347
+ export { SPF_PERMISSIVE_PATH_ISSUE, SPF_LOOKUP_LIMIT, SPF_MAX_DEPTH, SPF_VOID_LOOKUP_LIMIT, buildExternalDependencies, expandSpfPolicy, reportDestinationDomain, validateDmarcExternalReporting, };
@@ -48,6 +48,7 @@ function parseSpfRecords(records) {
48
48
  terminalPolicy: null,
49
49
  redirect: null,
50
50
  includes: [],
51
+ mechanisms: [],
51
52
  authorizingTerms: [],
52
53
  dnsLookupTerms: 0,
53
54
  issues: [],
@@ -175,6 +176,14 @@ function parseSpfRecords(records) {
175
176
  result.includes = terms
176
177
  .map((term) => term.match(/^[+?~-]?include:(.+)$/i)?.[1] || '')
177
178
  .filter(Boolean);
179
+ let includeIndex = 0;
180
+ result.mechanisms = terms.filter(term => !/^[+?~-]?[a-z][a-z0-9_.-]*=/iu.test(term)).map(term => {
181
+ const qualifier = (/^[+?~-]/u.test(term) ? term[0] : '+');
182
+ const body = /^[+?~-]/u.test(term) ? term.slice(1) : term;
183
+ const include = body.match(/^include:(.+)$/iu);
184
+ return { kind: /^all$/iu.test(body) ? 'all' : include ? 'include' : 'other', qualifier,
185
+ includeIndex: include ? includeIndex++ : null };
186
+ });
178
187
  if (terms.some((term) => /^[+?~-]?ptr(?::|$)/i.test(term))) {
179
188
  result.issues.push('The deprecated ptr mechanism should not be used.');
180
189
  }
@@ -12,7 +12,7 @@ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.
12
12
  import { DOMAIN_POSTURE_COMPARISON_VERSION, MAX_POSTURE_CHECK_RECORDS, POSTURE_CHECK_SOURCES } from "../packages/evidence/domain-posture-context.mjs";
13
13
  import { canonicalPostureRecords } from "../packages/evidence/domain-control-runtime.mjs";
14
14
  import { parseSpfRecords, parseDmarcRecords, parseMtaStsDnsRecords, parseMtaStsPolicy, parseTlsRptRecords, parseBimiRecords, parseDkimRecords, } from "./domain-posture-parsers.mjs";
15
- import { buildExternalDependencies, expandSpfPolicy, validateDmarcExternalReporting, } from "./domain-posture-analysis.mjs";
15
+ import { SPF_PERMISSIVE_PATH_ISSUE, buildExternalDependencies, expandSpfPolicy, validateDmarcExternalReporting, } from "./domain-posture-analysis.mjs";
16
16
  const DNS_TIMEOUT_MS = 6000;
17
17
  const POLICY_TIMEOUT_MS = 7000;
18
18
  const MAX_POLICY_BYTES = 64 * 1024;
@@ -156,7 +156,8 @@ function spfCheck(query, expansion) {
156
156
  remediation: 'Replace +all with an explicit sender allowlist and a restrictive terminal policy.',
157
157
  });
158
158
  }
159
- if (parsed.terminalPolicy === 'fail' && parsed.issues.length === 0 && (!expansion || expansion.state === 'complete')) {
159
+ const permissiveExpansion = expansion?.issues.includes(SPF_PERMISSIVE_PATH_ISSUE) ?? false;
160
+ if (parsed.terminalPolicy === 'fail' && parsed.issues.length === 0 && !permissiveExpansion && (!expansion || expansion.state === 'complete')) {
160
161
  return check('spf', 'SPF', 'pass', 'Restrictive fail-all policy', { detail: details.join(' '), records: parsed.records });
161
162
  }
162
163
  if (expansion && expansion.state !== 'complete') {
@@ -166,6 +167,12 @@ function spfCheck(query, expansion) {
166
167
  remediation: 'Review unresolved, invalid, cyclic, or budget-limited include and redirect branches before treating the policy as complete.',
167
168
  });
168
169
  }
170
+ if (permissiveExpansion) {
171
+ return check('spf', 'SPF', 'warning', 'SPF authorisation needs review', {
172
+ detail: details.join(' '), records: parsed.records,
173
+ remediation: 'Review the positive include or redirect path containing +all; complete collection does not establish restrictive sender authorisation.',
174
+ });
175
+ }
169
176
  if (parsed.terminalPolicy === 'redirect') {
170
177
  return check('spf', 'SPF', 'info', 'Policy delegates evaluation with redirect', {
171
178
  detail: details.join(' '), records: parsed.records,
@@ -301,6 +308,11 @@ function dnssecDelegationConsistencyCheck(input, registry) {
301
308
  }
302
309
  const value = String(input.value || '').toLowerCase();
303
310
  const hasDsRecords = registry.dsRecordCount > 0;
311
+ if (registry.dsDataTruncated) {
312
+ return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'info', 'Retained DS evidence is incomplete', {
313
+ detail: 'The normalised registry response reported truncated DS data, so delegation consistency is inconclusive.',
314
+ });
315
+ }
304
316
  if (value === 'signed' && hasDsRecords) {
305
317
  return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'pass', 'Signed state and retained DS records agree', {
306
318
  detail: `${registry.dsRecordCount} registry DS record${registry.dsRecordCount === 1 ? '' : 's'} retained.`,
@@ -311,11 +323,6 @@ function dnssecDelegationConsistencyCheck(input, registry) {
311
323
  detail: 'This is a consistency result, not a recommendation to leave DNSSEC disabled.',
312
324
  });
313
325
  }
314
- if (registry.dsDataTruncated) {
315
- return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'info', 'Retained DS evidence is incomplete', {
316
- detail: 'The normalised registry response reported truncated DS data, so apparent disagreement is inconclusive.',
317
- });
318
- }
319
326
  if (value === 'signed' && !hasDsRecords) {
320
327
  return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'warning', 'Signed state reported without retained DS records', {
321
328
  detail: 'This can reflect registry publication differences or incomplete upstream data and should be reviewed before changing delegation.',
@@ -360,13 +367,25 @@ function matchesMtaPattern(host, pattern) {
360
367
  }
361
368
  return normalizedHost === normalizedPattern;
362
369
  }
370
+ function mailPolicyApplicability(mxQuery) {
371
+ if (mxQuery.error)
372
+ return { hasMx: false, detail: 'Receiving-mail applicability is unknown because the MX lookup failed.' };
373
+ const records = completeMxRecords(mxQuery.records);
374
+ if (!records)
375
+ return { hasMx: false, detail: 'Receiving-mail applicability is unknown because the MX evidence is incomplete.' };
376
+ const { hasMx, hasNullMx } = classifyMxRecords(records);
377
+ return { hasMx, detail: hasMx ? '' : hasNullMx
378
+ ? 'Null MX explicitly declares that this domain does not accept inbound mail.'
379
+ : 'No MX records were observed. This alone does not establish that the domain cannot receive mail.' };
380
+ }
363
381
  function mtaStsCheck(dnsQuery, policyFetch, mxQuery) {
364
382
  if (dnsQuery.error)
365
383
  return queryFailureCheck('mta_sts', 'MTA-STS', dnsQuery.error);
366
384
  const dnsPolicy = parseMtaStsDnsRecords(dnsQuery.records);
367
- const hasMx = !mxQuery.error && classifyMxRecords(asMxRecords(mxQuery.records)).hasMx;
368
385
  if (dnsPolicy.records.length === 0) {
386
+ const { hasMx, detail } = mailPolicyApplicability(mxQuery);
369
387
  return check('mta_sts', 'MTA-STS', hasMx ? 'warning' : 'info', 'No MTA-STS policy advertised', {
388
+ detail,
370
389
  remediation: hasMx ? 'Publish _mta-sts and serve a valid policy over HTTPS to require authenticated TLS for inbound mail.' : '',
371
390
  });
372
391
  }
@@ -436,9 +455,9 @@ function tlsRptCheck(query, mxQuery) {
436
455
  return queryFailureCheck('tls_rpt', 'TLS-RPT', query.error);
437
456
  const parsed = parseTlsRptRecords(query.records);
438
457
  if (parsed.records.length === 0) {
439
- const hasMx = !mxQuery.error && classifyMxRecords(asMxRecords(mxQuery.records)).hasMx;
458
+ const { hasMx, detail } = mailPolicyApplicability(mxQuery);
440
459
  return check('tls_rpt', 'TLS-RPT', hasMx ? 'warning' : 'info', 'No SMTP TLS reporting policy', {
441
- detail: hasMx ? '' : 'The domain has no receiving mail exchanger, so SMTP TLS reporting is not currently actionable.',
460
+ detail,
442
461
  remediation: hasMx ? 'Publish a v=TLSRPTv1 record at _smtp._tls with a monitored rua destination.' : '',
443
462
  });
444
463
  }
@@ -13,7 +13,7 @@ const PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS = new Set([
13
13
  'expiresInDays', 'privacyProtected', 'dnssec', 'activityStatus',
14
14
  'websiteProbeStatus', 'websiteProbeDetail', 'http', 'deepScanComplete',
15
15
  'faviconHash', 'faviconPHash', 'pageTitle', 'hasPasswordField',
16
- 'phishingLanguageMatch', 'hasExternalFormAction', 'externalAssetHosts',
16
+ 'phishingLanguageMatch', 'hasExternalFormAction', 'hasExternalPasswordForm', 'externalAssetHosts',
17
17
  'pageIdentity', 'credentialSurfaceProfile', 'structuredDataIdentity',
18
18
  'technologyProfile', 'pageRoleProfile', 'clientBehaviorProfile',
19
19
  'securityPosture', 'dns', 'tls', 'hasMx', 'hasNullMx', 'mxHosts', 'hasSpf',
@@ -105,6 +105,7 @@ const LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS = new Set([
105
105
  // Credential, structured identity, role, behaviour, technology, and posture
106
106
  // projections. Only fixed categories/counts and bounded descriptions enter.
107
107
  'credentialSurfaceVersion', 'methods', 'actions', 'missing', 'get', 'post',
108
+ 'formAttribution', 'unassociatedInputs', 'index', 'destinations', 'relationship', 'origin',
108
109
  'dialog', 'other', 'sameOrigin', 'external', 'cleartext', 'unclassified',
109
110
  'inputs', 'classifiedCount', 'categories', 'password', 'email', 'username',
110
111
  'one_time_code', 'payment', 'inputsObserved', 'classifiedInputs',
@@ -322,6 +323,8 @@ function projectLookupEvidenceAvailabilityValue(value, path, state, depth, legac
322
323
  return value;
323
324
  if (typeof value === 'string') {
324
325
  const normalizedParent = normalizedEvidenceKey(path.at(-1) || '');
326
+ if (path.join('.') === 'credentialSurfaceProfile.formAttribution.forms.destinations.origin')
327
+ return portableOrigin(value);
325
328
  if (PORTABLE_URL_KEYS.has(normalizedParent))
326
329
  return portableUri(value, legacyUris);
327
330
  if (PORTABLE_ORIGIN_COLLECTION_KEYS.has(normalizedParent))
@@ -344,7 +347,7 @@ function projectLookupEvidenceAvailabilityValue(value, path, state, depth, legac
344
347
  const boundedCount = Number.isSafeInteger(item)
345
348
  && Number(item) >= 0
346
349
  && Number(item) <= LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS;
347
- const credentialCategoryCount = parentPath === 'credentialSurfaceProfile.inputs.categories'
350
+ const credentialCategoryCount = ['credentialSurfaceProfile.inputs.categories', 'credentialSurfaceProfile.formAttribution.forms.categories'].includes(parentPath)
348
351
  && boundedCount;
349
352
  const sanClassCount = parentPath === 'tls.certificate.subjectAltNames.classes'
350
353
  && LOOKUP_AVAILABILITY_TLS_SAN_CLASS_KEYS.has(key)
@@ -267,6 +267,7 @@ function buildLookupEvidenceReport(document, options = {}) {
267
267
  reportField('Redirects', http.redirectCount),
268
268
  reportField('Page title', availability.pageTitle),
269
269
  reportField('Password field observed', yesNoUnknown(availability.hasPasswordField)),
270
+ reportField('Password form declares external destination', yesNoUnknown(availability.hasExternalPasswordForm)),
270
271
  reportField('Authoritative nameserver evidence', listText(availability.nameservers)),
271
272
  reportField('Observed edge, CDN, reverse proxy or WAF', reportTechnologyNames(technologyFindings, 'observed_edge')),
272
273
  reportField('Application-platform indicators', reportTechnologyNames(technologyFindings, 'application_platform')),
package/lib/favicon.mjs CHANGED
@@ -146,13 +146,18 @@ function isFaviconImage(bytes) {
146
146
  }
147
147
  // Returns the favicon bytes for one candidate URL (a real fetch, or an inline
148
148
  // data: decode), or null if it can't be retrieved/used.
149
- async function fetchFaviconBytes(url, headers, fetcher, timeoutMs) {
149
+ async function fetchFaviconBytes(url, headers, fetcher, timeoutMs, signal) {
150
+ signal?.throwIfAborted();
150
151
  if (/^data:/i.test(url))
151
152
  return decodeDataUri(url);
152
153
  const controller = new AbortController();
153
154
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
154
155
  try {
155
- const res = await fetcher(url, { signal: controller.signal, headers });
156
+ const res = await fetcher(url, { signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal, headers });
157
+ if (signal?.aborted) {
158
+ await res.body?.cancel().catch(() => { });
159
+ signal.throwIfAborted();
160
+ }
156
161
  if (!res.ok) {
157
162
  // Not reading this body - release it explicitly instead of leaving an
158
163
  // unconsumed stream (and the connection it's tied to) open until
@@ -165,10 +170,12 @@ async function fetchFaviconBytes(url, headers, fetcher, timeoutMs) {
165
170
  // headers immediately and then trickle or stall the body forever, hanging
166
171
  // this worker with no deadline once disarmed.
167
172
  const { bytes, truncated } = await readBytesCapped(res, MAX_FAVICON_BYTES);
173
+ signal?.throwIfAborted();
168
174
  // A truncated or empty file can't be hashed meaningfully.
169
175
  return truncated || bytes.length === 0 ? null : bytes;
170
176
  }
171
177
  catch {
178
+ signal?.throwIfAborted();
172
179
  return null;
173
180
  }
174
181
  finally {
@@ -196,7 +203,8 @@ function buildFaviconCandidates(domain, html = '', options = {}) {
196
203
  add(`http://${domain}/favicon.svg`);
197
204
  return candidates.slice(0, MAX_FAVICON_CANDIDATES);
198
205
  }
199
- async function fetchFaviconHash(domain, { html = '', baseUrl, htmlAnalysis, fetcher = safeFetch, timeoutMs = FAVICON_FETCH_TIMEOUT_MS } = {}) {
206
+ async function fetchFaviconHash(domain, { html = '', baseUrl, htmlAnalysis, fetcher = safeFetch, timeoutMs = FAVICON_FETCH_TIMEOUT_MS, signal } = {}) {
207
+ signal?.throwIfAborted();
200
208
  const headers = whoisleuthRequestHeaders();
201
209
  const candidates = buildFaviconCandidates(domain, html, {
202
210
  ...(baseUrl !== undefined ? { baseUrl } : {}),
@@ -207,7 +215,8 @@ async function fetchFaviconHash(domain, { html = '', baseUrl, htmlAnalysis, fetc
207
215
  : FAVICON_FETCH_TIMEOUT_MS;
208
216
  for (const url of candidates) {
209
217
  // eslint-disable-next-line no-await-in-loop
210
- const bytes = await fetchFaviconBytes(url, headers, fetcher, requestTimeoutMs);
218
+ const bytes = await fetchFaviconBytes(url, headers, fetcher, requestTimeoutMs, signal);
219
+ signal?.throwIfAborted();
211
220
  if (bytes && isFaviconImage(bytes)) {
212
221
  return {
213
222
  hash: crypto.createHash('sha256').update(bytes).digest('hex'),
@@ -6,6 +6,8 @@
6
6
  import { isIP } from 'node:net';
7
7
  import { domainToASCII } from 'node:url';
8
8
  import { analyzeCredentialSurfaceProfile } from "./credential-surface-profile.mjs";
9
+ import { attributeCredentialForms } from "./credential-form-attribution.mjs";
10
+ import { externalPasswordFormObservation } from "../packages/evidence/credential-form-attribution.mjs";
9
11
  import { analyzeClientBehavior } from "./client-behavior-profile.mjs";
10
12
  import { createObservation } from "../packages/evidence/observation.mjs";
11
13
  import { createPageFingerprints } from "./page-fingerprints.mjs";
@@ -719,10 +721,12 @@ async function extractHtmlSignals(html, domain, options = {}) {
719
721
  const pageLanguageSignal = detectPageLanguageSignal(html, pageIdentity?.documentLanguage, htmlAnalysis);
720
722
  const domainSaleSignal = options.sourceTruncated !== true && domainSaleLandingPage(htmlAnalysis, domain)
721
723
  ? 'explicit domain-sale landing-page content' : null;
724
+ const formAttribution = attributeCredentialForms(htmlAnalysis, documentUrl, options.sourceTruncated);
722
725
  return {
723
726
  domainSaleSignal,
724
727
  pageTitle: htmlAnalysis.title,
725
728
  hasPasswordField: htmlAnalysis.forms.categories.password > 0,
729
+ hasExternalPasswordForm: externalPasswordFormObservation(formAttribution),
726
730
  phishingLanguageMatch: pageLanguageSignal?.label ?? null,
727
731
  hasExternalFormAction: pageIdentity
728
732
  ? pageIdentity.forms.externalActionOrigins.length > 0
@@ -732,6 +736,8 @@ async function extractHtmlSignals(html, domain, options = {}) {
732
736
  pageIdentity: pageIdentityOutput,
733
737
  credentialSurfaceProfile: includeCredentialSurfaceProfile && htmlAnalysis ? analyzeCredentialSurfaceProfile({
734
738
  htmlAnalysis,
739
+ formAttribution,
740
+ baseUrl: documentUrl,
735
741
  observedAt: options.observedAt,
736
742
  sourceTruncated: options.sourceTruncated,
737
743
  }) : null,
@@ -8,6 +8,7 @@ import { MAX_LOOKUP_TLS_ALT_NAMES, MAX_LOOKUP_TLS_CERTIFICATE_POLICIES, MAX_LOOK
8
8
  import { isJsonObject, normalizedDomain, validBoundedString, validHttpProvenanceUrl, validObservationDiagnostics, validOptionalNullableText, validStringArray, validUint, } from "./lookup-contract-primitives.mjs";
9
9
  import { TECHNOLOGY_EVIDENCE_ROLE_ORDER } from "./technology-evidence-role.mjs";
10
10
  import { MAX_HOMEPAGE_BYTES } from "./outbound-request-bounds.mjs";
11
+ import { CREDENTIAL_CATEGORY_NAMES, MAX_CREDENTIAL_FORMS, MAX_CREDENTIAL_INPUTS, externalPasswordFormObservation, validCredentialFormAttribution } from "../packages/evidence/credential-form-attribution.mjs";
11
12
  export const TECHNOLOGY_PROFILE_VERSION = 12;
12
13
  export const SUPPORTED_TECHNOLOGY_PROFILE_VERSIONS = Object.freeze([10, 11, TECHNOLOGY_PROFILE_VERSION]);
13
14
  export const MAX_TECHNOLOGY_FINDINGS = 24;
@@ -17,7 +18,7 @@ export const BROWSER_LIBRARY_PROFILE_VERSION = 2;
17
18
  export const MAX_LIBRARY_FINDINGS = 16;
18
19
  export const WEBSITE_SECURITY_POSTURE_VERSION = 3;
19
20
  export const MAX_SECURITY_POSTURE_FINDINGS = 32;
20
- export const CREDENTIAL_SURFACE_PROFILE_VERSION = 1;
21
+ export const CREDENTIAL_SURFACE_PROFILE_VERSION = 2;
21
22
  export const STRUCTURED_DATA_IDENTITY_VERSION = 1;
22
23
  export const MAX_STRUCTURED_DATA_ENTITIES = 16;
23
24
  export const MAX_STRUCTURED_DATA_SAME_AS_HOSTS = 12;
@@ -66,7 +67,7 @@ const SIMHASH64_RE = /^[a-f0-9]{16}$/u;
66
67
  const OID_RE = /^\d{1,10}(?:\.\d{1,10}){1,31}$/u;
67
68
  const CREDENTIAL_METHOD_KEYS = Object.freeze(['missing', 'get', 'post', 'dialog', 'other']);
68
69
  const CREDENTIAL_ACTION_KEYS = Object.freeze(['sameOrigin', 'external', 'missing', 'cleartext', 'unclassified']);
69
- const CREDENTIAL_CATEGORY_KEYS = Object.freeze(['password', 'email', 'username', 'one_time_code', 'payment']);
70
+ const CREDENTIAL_CATEGORY_KEYS = CREDENTIAL_CATEGORY_NAMES;
70
71
  const TLS_NAME_KEYS = Object.freeze([
71
72
  'commonNames', 'organizations', 'organizationalUnits', 'countries', 'localities', 'states',
72
73
  ]);
@@ -683,28 +684,37 @@ function securityPostureContractState(value) {
683
684
  return 'supported';
684
685
  }
685
686
  function credentialSurfaceContractState(value) {
686
- const versionState = childVersionState(value, 'credentialSurfaceVersion', [CREDENTIAL_SURFACE_PROFILE_VERSION]);
687
+ const versionState = childVersionState(value, 'credentialSurfaceVersion', [1, CREDENTIAL_SURFACE_PROFILE_VERSION]);
687
688
  if (versionState !== 'supported')
688
689
  return versionState;
689
690
  const profile = value;
690
691
  const forms = profile.forms;
691
692
  const inputs = profile.inputs;
692
- if (!hasExactKeys(profile, [...OBSERVATION_FIELDS, 'credentialSurfaceVersion', 'forms', 'inputs'])
693
+ if (!hasExactKeys(profile, [...OBSERVATION_FIELDS, 'credentialSurfaceVersion', 'forms', 'inputs', ...(profile.credentialSurfaceVersion === 2 ? ['formAttribution'] : [])])
693
694
  || !validProfileObservation(profile, new Set(['success', 'partial']), new Set(['html']))
694
695
  || !isJsonObject(forms)
695
696
  || !hasExactKeys(forms, ['count', 'methods', 'actions'])
696
- || !validUint(forms.count, 50)
697
- || !validCredentialCountRecord(forms.methods, CREDENTIAL_METHOD_KEYS, 50)
698
- || !validCredentialCountRecord(forms.actions, CREDENTIAL_ACTION_KEYS, 50)
697
+ || !validUint(forms.count, MAX_CREDENTIAL_FORMS)
698
+ || !validCredentialCountRecord(forms.methods, CREDENTIAL_METHOD_KEYS, MAX_CREDENTIAL_FORMS)
699
+ || !validCredentialCountRecord(forms.actions, CREDENTIAL_ACTION_KEYS, MAX_CREDENTIAL_FORMS)
699
700
  || CREDENTIAL_METHOD_KEYS.reduce((sum, key) => sum + Number(forms.methods[key]), 0) !== forms.count
700
701
  || CREDENTIAL_ACTION_KEYS.reduce((sum, key) => sum + Number(forms.actions[key]), 0) !== forms.count
701
702
  || !isJsonObject(inputs)
702
703
  || !hasExactKeys(inputs, ['count', 'classifiedCount', 'categories'])
703
- || !validUint(inputs.count, 500)
704
- || !validUint(inputs.classifiedCount, 500)
704
+ || !validUint(inputs.count, MAX_CREDENTIAL_INPUTS)
705
+ || !validUint(inputs.classifiedCount, MAX_CREDENTIAL_INPUTS)
705
706
  || Number(inputs.classifiedCount) > Number(inputs.count)
706
- || !validCredentialCountRecord(inputs.categories, CREDENTIAL_CATEGORY_KEYS, 500))
707
+ || !validCredentialCountRecord(inputs.categories, CREDENTIAL_CATEGORY_KEYS, MAX_CREDENTIAL_INPUTS))
707
708
  return 'invalid';
709
+ if (profile.credentialSurfaceVersion === 2 && (!validCredentialFormAttribution(profile.formAttribution)
710
+ || profile.formAttribution.forms.length !== forms.count
711
+ || (profile.complete === true && !profile.formAttribution.complete)))
712
+ return 'invalid';
713
+ if (profile.credentialSurfaceVersion === 2 && validCredentialFormAttribution(profile.formAttribution)) {
714
+ const attribution = profile.formAttribution;
715
+ if (CREDENTIAL_CATEGORY_KEYS.some(key => attribution.forms.reduce((sum, form) => sum + form.categories[key], 0) > Number(inputs.categories[key])))
716
+ return 'invalid';
717
+ }
708
718
  return 'supported';
709
719
  }
710
720
  function structuredDataContractState(value) {
@@ -819,6 +829,13 @@ function sanitizeLookupChildProfiles(value) {
819
829
  replaceChild('tls', 'TLS profile', 'tls', tlsProfileContractState);
820
830
  replaceChild('securityPosture', 'Security-posture profile', 'derived', securityPostureContractState);
821
831
  replaceChild('credentialSurfaceProfile', 'Credential-surface profile', 'html', credentialSurfaceContractState);
832
+ if (availability.hasExternalPasswordForm !== undefined && availability.credentialSurfaceProfile != null) {
833
+ const credential = availability.credentialSurfaceProfile;
834
+ if (!isJsonObject(credential) || credential.credentialSurfaceVersion !== 2
835
+ || availability.hasExternalPasswordForm !== externalPasswordFormObservation(credential.formAttribution)) {
836
+ mutableAvailability().hasExternalPasswordForm = null;
837
+ }
838
+ }
822
839
  replaceChild('structuredDataIdentity', 'Structured-data identity profile', 'html', structuredDataContractState);
823
840
  replaceChild('pageRoleProfile', 'Page-role profile', 'derived', pageRoleContractState);
824
841
  replaceChild('clientBehaviorProfile', 'Client-behaviour profile', 'derived', clientBehaviorContractState);
@@ -864,4 +881,4 @@ function sanitizeLookupChildProfiles(value) {
864
881
  }
865
882
  return output;
866
883
  }
867
- export { sanitizeLookupChildProfiles, technologyProfileContractState, validSecurityPostureFinding, validTlsChainCertificate, validTlsFinding, };
884
+ export { sanitizeLookupChildProfiles, credentialSurfaceContractState, technologyProfileContractState, validSecurityPostureFinding, validTlsChainCertificate, validTlsFinding, };
@@ -646,6 +646,7 @@ function validAvailabilityScalars(value) {
646
646
  && (value.hasNullMx === undefined || value.hasNullMx === null || typeof value.hasNullMx === 'boolean')
647
647
  && (value.hasSpf === undefined || value.hasSpf === null || typeof value.hasSpf === 'boolean')
648
648
  && (value.hasDmarc === undefined || value.hasDmarc === null || typeof value.hasDmarc === 'boolean')
649
+ && (value.hasExternalPasswordForm === undefined || value.hasExternalPasswordForm === null || typeof value.hasExternalPasswordForm === 'boolean')
649
650
  && validOptionalStringArray(value.mxHosts, MAX_LOOKUP_DNS_RECORDS_PER_TYPE, 253);
650
651
  }
651
652
  function validLookupDomainIdentity(value) {
@@ -34,6 +34,7 @@ const OPERATION_FEATURES = Object.freeze({
34
34
  AVAILABILITY_FAST: 'availability_fast',
35
35
  AVAILABILITY_DEEP: 'availability_deep',
36
36
  CERTIFICATE_TRANSPARENCY: 'certificate_transparency',
37
+ DOMAIN_FEED_SEARCH: 'domain_feed_search',
37
38
  DOMAIN_POSTURE: 'domain_posture',
38
39
  });
39
40
  const OPERATION_FEATURE_CLASSES = Object.freeze({
@@ -47,6 +48,7 @@ const OPERATION_FEATURE_CLASSES = Object.freeze({
47
48
  [OPERATION_FEATURES.AVAILABILITY_FAST]: OPERATION_CLASSES.REGISTRY_LIGHT,
48
49
  [OPERATION_FEATURES.AVAILABILITY_DEEP]: OPERATION_CLASSES.REGISTRY_DEEP,
49
50
  [OPERATION_FEATURES.CERTIFICATE_TRANSPARENCY]: OPERATION_CLASSES.CERTIFICATE_SEARCH,
51
+ [OPERATION_FEATURES.DOMAIN_FEED_SEARCH]: OPERATION_CLASSES.CERTIFICATE_SEARCH,
50
52
  [OPERATION_FEATURES.DOMAIN_POSTURE]: OPERATION_CLASSES.POSTURE_AUDIT,
51
53
  });
52
54
  // The browser currently runs at most 12 fast Bulk workers, 4 deep Bulk
@@ -87,6 +89,8 @@ function operationFeatureFor(feature, { fast = false, compact = false } = {}) {
87
89
  return OPERATION_FEATURES.WHOIS;
88
90
  if (feature === 'certificate_transparency')
89
91
  return OPERATION_FEATURES.CERTIFICATE_TRANSPARENCY;
92
+ if (feature === 'domain_feed_search')
93
+ return OPERATION_FEATURES.DOMAIN_FEED_SEARCH;
90
94
  if (feature === 'domain_posture')
91
95
  return OPERATION_FEATURES.DOMAIN_POSTURE;
92
96
  return null;
@@ -526,6 +526,8 @@ function defaultTcpExchange(query, resolver, options) {
526
526
  }
527
527
  });
528
528
  socket.once('connect', () => {
529
+ if (settled)
530
+ return;
529
531
  const length = Buffer.alloc(2);
530
532
  length.writeUInt16BE(query.length, 0);
531
533
  socket.write(Buffer.concat([length, query]));
@@ -534,6 +536,7 @@ function defaultTcpExchange(query, resolver, options) {
534
536
  }
535
537
  function defaultDnsExchange(query, resolver, options) {
536
538
  return new Promise((resolve, reject) => {
539
+ options.signal?.throwIfAborted();
537
540
  const socket = createSocket(resolver.family === 6 ? 'udp6' : 'udp4');
538
541
  let settled = false;
539
542
  const timer = setTimeout(() => {
@@ -544,6 +547,7 @@ function defaultDnsExchange(query, resolver, options) {
544
547
  return;
545
548
  settled = true;
546
549
  clearTimeout(timer);
550
+ options.signal?.removeEventListener('abort', cancelled);
547
551
  try {
548
552
  socket.close();
549
553
  }
@@ -555,6 +559,12 @@ function defaultDnsExchange(query, resolver, options) {
555
559
  else
556
560
  resolve(response);
557
561
  };
562
+ const cancelled = () => finish(options.signal?.reason ?? new Error('DNS UDP query cancelled'));
563
+ options.signal?.addEventListener('abort', cancelled, { once: true });
564
+ if (options.signal?.aborted) {
565
+ cancelled();
566
+ return;
567
+ }
558
568
  socket.once('error', (error) => finish(error));
559
569
  socket.once('message', (message) => {
560
570
  if (message.length > MAX_DNS_MESSAGE_BYTES) {
@@ -565,6 +575,8 @@ function defaultDnsExchange(query, resolver, options) {
565
575
  }
566
576
  });
567
577
  socket.connect(resolver.port, resolver.address, () => {
578
+ if (settled)
579
+ return;
568
580
  socket.send(query, (error) => {
569
581
  if (error)
570
582
  finish(error);
@@ -592,6 +604,7 @@ function missingResponseError(rcode) {
592
604
  return new ServiceBindingDnsError(`DNS resolver returned ${names[rcode] || `rcode ${rcode}`}`, 'EDNSFAIL');
593
605
  }
594
606
  async function resolveServiceBindingRecords(nameValue, type, options = {}) {
607
+ options.signal?.throwIfAborted();
595
608
  const name = normalizeQueryName(nameValue);
596
609
  const transactionId = options.transactionId ?? randomInt(0x1_0000);
597
610
  const query = buildServiceBindingDnsQuery(name, type, transactionId);
@@ -608,17 +621,20 @@ async function resolveServiceBindingRecords(nameValue, type, options = {}) {
608
621
  const deadline = Date.now() + timeoutMs;
609
622
  let lastError = null;
610
623
  for (const resolver of resolvers) {
624
+ options.signal?.throwIfAborted();
611
625
  const udpTimeoutMs = deadline - Date.now();
612
626
  if (udpTimeoutMs <= 0)
613
627
  break;
614
628
  try {
615
- let response = await exchange(query, resolver, { timeoutMs: udpTimeoutMs });
629
+ let response = await exchange(query, resolver, { timeoutMs: udpTimeoutMs, ...(options.signal ? { signal: options.signal } : {}) });
630
+ options.signal?.throwIfAborted();
616
631
  if (isTruncatedDnsResponse(response, transactionId)) {
617
632
  const tcpTimeoutMs = deadline - Date.now();
618
633
  if (tcpTimeoutMs <= 0) {
619
634
  throw new ServiceBindingDnsError('DNS service-binding query timed out', 'ETIMEOUT');
620
635
  }
621
- response = await (options.tcpExchange || defaultTcpExchange)(query, resolver, { timeoutMs: tcpTimeoutMs });
636
+ response = await (options.tcpExchange || defaultTcpExchange)(query, resolver, { timeoutMs: tcpTimeoutMs, ...(options.signal ? { signal: options.signal } : {}) });
637
+ options.signal?.throwIfAborted();
622
638
  }
623
639
  const parsed = parseServiceBindingDnsResponse(response, { transactionId, name, type });
624
640
  if (parsed.truncatedResponse)
@@ -631,6 +647,7 @@ async function resolveServiceBindingRecords(nameValue, type, options = {}) {
631
647
  return { records: parsed.records, truncated: parsed.truncated };
632
648
  }
633
649
  catch (error) {
650
+ options.signal?.throwIfAborted();
634
651
  lastError = error;
635
652
  const code = error && typeof error === 'object' ? String(error.code || '') : '';
636
653
  if (code === 'ENODATA' || code === 'ENOTFOUND')