@slicedearth/whoisleuth-cli 2.2.0 → 2.3.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 (77) hide show
  1. package/README.md +2 -1
  2. package/cli/arguments.mjs +389 -1745
  3. package/cli/artifact-validation/case-response.mjs +55 -30
  4. package/cli/artifact-validation/signed-review.mjs +30 -12
  5. package/cli/bulk-checkpoint.mjs +34 -3
  6. package/cli/case-pack.mjs +39 -92
  7. package/cli/command-argument-grammar.mjs +211 -0
  8. package/cli/command-reference.mjs +200 -213
  9. package/cli/completion.mjs +5 -1
  10. package/cli/discovery-observation-snapshot.mjs +16 -3
  11. package/cli/discovery-scan-command-runner.mjs +19 -0
  12. package/cli/discovery-scan.mjs +6 -0
  13. package/cli/domain-control-observations.mjs +1 -1
  14. package/cli/inline-command-families.mjs +1 -0
  15. package/cli/input.mjs +116 -0
  16. package/cli/interchange-report.mjs +14 -0
  17. package/cli/lookalike-calibration.mjs +7 -2
  18. package/cli/lookup-brief.mjs +4 -2
  19. package/cli/lookup-browser-view.mjs +1 -1
  20. package/cli/mail-header-review.mjs +467 -0
  21. package/cli/review-command-runner.mjs +25 -0
  22. package/cli/risk-calibration.mjs +19 -17
  23. package/cli/runner.mjs +7 -1
  24. package/docs/cli-reference.md +8 -1
  25. package/docs/cli.md +12 -1
  26. package/lib/bounded-json.mjs +9 -6
  27. package/lib/dns-convergence-review.mjs +1 -0
  28. package/lib/dns-intelligence.mjs +37 -3
  29. package/lib/dnssec-chain-validation.mjs +6 -0
  30. package/lib/domain-change-packet.mjs +42 -14
  31. package/lib/domain-control-flight-recorder.mjs +4 -1
  32. package/lib/domain-control-manifest.mjs +16 -16
  33. package/lib/domain-posture-parsers.mjs +114 -3
  34. package/lib/domain-posture.mjs +34 -3
  35. package/lib/evidence-export.mjs +60 -11
  36. package/lib/evidence-report.mjs +2 -2
  37. package/lib/html-signals.mjs +39 -33
  38. package/lib/local-geoip-evidence.mjs +19 -3
  39. package/lib/lookup-response.mjs +5 -4
  40. package/lib/lookup-source-progress.mjs +27 -4
  41. package/lib/nameserver-preflight-review.mjs +1 -0
  42. package/lib/rdap-capabilities.mjs +1 -1
  43. package/lib/registry-insights.mjs +35 -22
  44. package/lib/risk-calibration-summary.mjs +1 -3
  45. package/lib/security-txt.mjs +33 -1
  46. package/lib/static-html-analysis.mjs +69 -13
  47. package/lib/website-technology.mjs +79 -11
  48. package/lib/zone-intent-review.mjs +16 -6
  49. package/package.json +1 -1
  50. package/packages/cases/case-migration-model.mjs +71 -22
  51. package/packages/cases/case-record-contracts.mjs +5 -20
  52. package/packages/cases/case-record-core.mjs +10 -21
  53. package/packages/cases/case-record-decisions.mjs +72 -0
  54. package/packages/cases/case-record-model.mjs +1 -0
  55. package/packages/cases/case-record-operations.mjs +6 -4
  56. package/packages/cases/case-record-projection.mjs +218 -0
  57. package/packages/cases/case-report.mjs +11 -6
  58. package/packages/cases/case-response-model.mjs +43 -6
  59. package/packages/cases/case-response-packet.mjs +78 -27
  60. package/packages/cases/case-storage-model.mjs +6 -2
  61. package/packages/comparison/comparison-ledger-bulk.mjs +9 -10
  62. package/packages/comparison/page-similarity.mjs +3 -1
  63. package/packages/contracts/case-portability.mjs +171 -51
  64. package/packages/contracts/cli-command-semantics.mjs +93 -0
  65. package/packages/contracts/risk-calibration.mjs +15 -10
  66. package/packages/contracts/workspace-portability.mjs +44 -7
  67. package/packages/evidence/domain-control-runtime.mjs +46 -6
  68. package/packages/interchange/external-findings-import.mjs +3 -2
  69. package/packages/investigation/lookup-asset-graph.mjs +35 -27
  70. package/packages/workspace/brand-profile-model.mjs +118 -2
  71. package/packages/workspace/bulk-review-model.mjs +13 -7
  72. package/packages/workspace/bulk-session-model.mjs +5 -4
  73. package/packages/workspace/detection-rule-model.mjs +70 -22
  74. package/packages/workspace/relationship-observation-model.mjs +4 -1
  75. package/packages/workspace/website-snapshot-model.mjs +106 -8
  76. package/packages/workspace/workspace-archive.mjs +2 -0
  77. package/cli/evidence-command-arguments.mjs +0 -156
package/docs/cli.md CHANGED
@@ -75,6 +75,7 @@ whoisleuth bulk domains.txt --csv
75
75
  whoisleuth discover example.test --preset common --jsonl
76
76
  whoisleuth verify-artifact lookup.json --json --strict-exit
77
77
  whoisleuth compare lookup.json --json
78
+ whoisleuth mail-headers message.eml --json
78
79
  whoisleuth brief lookup.json
79
80
  ```
80
81
 
@@ -89,7 +90,7 @@ application:
89
90
 
90
91
  | Group | Common commands |
91
92
  | --- | --- |
92
- | Investigate | `lookup`, `bulk`, `discover`, `ct-search`, `posture`, `http`, `tls`, `compare`, `brief` |
93
+ | Investigate | `lookup`, `bulk`, `discover`, `ct-search`, `posture`, `http`, `tls`, `compare`, `mail-headers`, `brief` |
93
94
  | Respond | `case-pack`, `change-packet`, `sharing-review`, `export` (local handoff from browser-created Cases) |
94
95
  | Assure | `dnssec-validate`, `mail-transport`, `domain-control`, `assurance`, `workflow-plan`, `diff`, `inspect-archive`, `verify-artifact` |
95
96
  | Utilities | `doctor`, `commands`, `completion`, `manual`, `registry-scaffold` |
@@ -122,6 +123,16 @@ their focused help. They do not use the hosted login or hosted usage controls.
122
123
  `lookup --plan` lists planned source families and disclosure targets before
123
124
  collection. `doctor` is offline unless `--network` is selected.
124
125
 
126
+ ### Message-header review
127
+
128
+ `mail-headers` parses only the bounded header block from a selected message file
129
+ or standard input. It extracts domain-only identity, reported SPF, DKIM, DMARC
130
+ and ARC states, exact-domain alignment, and the bounded `Received` route in its
131
+ reported order. It makes no request and does not retain address local parts,
132
+ display names, subject, body, attachments, or raw header values in its output.
133
+ Authentication states are header claims, not an independent DNS or
134
+ cryptographic validation, and alignment differences can be legitimate.
135
+
125
136
  ## Output and automation
126
137
 
127
138
  Terminal text is the default. Commands expose JSON, JSONL, CSV, Markdown, HTML
@@ -98,7 +98,7 @@ export function assertBoundedJsonStructure(value, label = 'JSON value', limits =
98
98
  };
99
99
  visit(value, 0);
100
100
  }
101
- export function scanBoundedJson(raw, limits = {}) {
101
+ export function scanBoundedJson(raw, limits = {}, observeString) {
102
102
  const maximumDepth = limits.maximumDepth ?? MAX_BOUNDED_JSON_DEPTH;
103
103
  const maximumKeys = limits.maximumKeys ?? MAX_BOUNDED_JSON_KEYS;
104
104
  const maximumValues = limits.maximumValues ?? MAX_BOUNDED_JSON_VALUES;
@@ -118,7 +118,7 @@ export function scanBoundedJson(raw, limits = {}) {
118
118
  if (character === '"') {
119
119
  index += 1;
120
120
  try {
121
- return JSON.parse(raw.slice(start, index));
121
+ return { value: JSON.parse(raw.slice(start, index)), offset: start };
122
122
  }
123
123
  catch {
124
124
  syntaxError();
@@ -160,7 +160,7 @@ export function scanBoundedJson(raw, limits = {}) {
160
160
  }
161
161
  index += match[0].length;
162
162
  };
163
- const value = (depth) => {
163
+ const value = (depth, propertyKey) => {
164
164
  values += 1;
165
165
  if (values > maximumValues) {
166
166
  throw new TypeError(`Artefact JSON exceeds the ${maximumValues}-value limit.`);
@@ -171,7 +171,8 @@ export function scanBoundedJson(raw, limits = {}) {
171
171
  whitespace();
172
172
  const character = raw[index];
173
173
  if (character === '"') {
174
- stringToken();
174
+ const parsedString = stringToken();
175
+ observeString?.({ ...parsedString, kind: 'value', ...(propertyKey === undefined ? {} : { propertyKey }) });
175
176
  return;
176
177
  }
177
178
  if (character === '{') {
@@ -185,7 +186,8 @@ export function scanBoundedJson(raw, limits = {}) {
185
186
  while (index < raw.length) {
186
187
  if (raw[index] !== '"')
187
188
  syntaxError();
188
- const key = stringToken();
189
+ const parsedKey = stringToken();
190
+ const key = parsedKey.value;
189
191
  if (seen.size >= maximumContainerItems) {
190
192
  throw new TypeError(`Artefact JSON contains a container with more than ${maximumContainerItems} items.`);
191
193
  }
@@ -198,11 +200,12 @@ export function scanBoundedJson(raw, limits = {}) {
198
200
  if (!isSafeJsonObjectKey(key))
199
201
  throw new TypeError('Artefact JSON contains an unsafe object key.');
200
202
  seen.add(key);
203
+ observeString?.({ ...parsedKey, kind: 'key' });
201
204
  whitespace();
202
205
  if (raw[index] !== ':')
203
206
  syntaxError();
204
207
  index += 1;
205
- value(depth + 1);
208
+ value(depth + 1, key);
206
209
  whitespace();
207
210
  if (raw[index] === '}') {
208
211
  index += 1;
@@ -160,6 +160,7 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
160
160
  });
161
161
  });
162
162
  const reasons = Object.freeze([
163
+ ...(rows.length === 0 ? ['No comparable owner and record-type scope was supplied or observed.'] : []),
163
164
  ...rows.filter((row) => row.state !== 'converged').map((row) => `${row.owner} ${row.type} is ${row.state}.`),
164
165
  ...latestByObserver.filter((item) => item.state !== 'observed').map((item) => `${item.observer} latest evidence is ${item.state}.`),
165
166
  ].slice(0, 200));
@@ -189,8 +189,13 @@ function normalizeCaa(records) {
189
189
  for (const record of Array.isArray(records) ? records : []) {
190
190
  const entry = record && typeof record === 'object' ? record : {};
191
191
  const critical = Number(entry.critical);
192
- const tag = String(entry.tag || '').trim().toLowerCase();
193
- const value = String(entry.value || '').trim();
192
+ const nodeTags = ['issue', 'issuewild', 'iodef'].filter((tag) => Object.hasOwn(entry, tag));
193
+ const hasPair = Object.hasOwn(entry, 'tag') || Object.hasOwn(entry, 'value');
194
+ const tag = hasPair
195
+ ? String(entry.tag || '').trim().toLowerCase()
196
+ : nodeTags.length === 1 ? nodeTags[0] : '';
197
+ const rawValue = hasPair ? entry.value : tag ? entry[tag] : undefined;
198
+ const value = typeof rawValue === 'string' ? rawValue.trim() : '';
194
199
  if (!Number.isInteger(critical) || critical < 0 || critical > 255 || !/^[a-z0-9-]{1,15}$/.test(tag) || !value || value.length > MAX_POLICY_LENGTH || /[\u0000-\u001f\u007f]/.test(value)) {
195
200
  discarded += 1;
196
201
  continue;
@@ -511,6 +516,35 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
511
516
  records: result.records,
512
517
  };
513
518
  }
519
+ if (result.truncated || result.discarded > 0) {
520
+ return {
521
+ ...createObservation({
522
+ status: 'partial',
523
+ observedAt: (options.observedAt || (() => new Date().toISOString()))(),
524
+ scanMode: 'deep',
525
+ source: 'dns',
526
+ durationMs: Math.max(0, now() - started),
527
+ complete: false,
528
+ truncated: result.truncated,
529
+ limitations,
530
+ diagnostics: {
531
+ tree: {
532
+ status: 'partial',
533
+ detail: `The CAA answer at ${owner} contained discarded or truncated records, so parent inheritance was not evaluated.`,
534
+ count: queries.length,
535
+ truncated: result.truncated,
536
+ discarded: result.discarded,
537
+ },
538
+ },
539
+ }),
540
+ policyVersion: 1,
541
+ queryLimit,
542
+ queriedOwners: queries,
543
+ effectiveOwner: null,
544
+ inherited: null,
545
+ records: [],
546
+ };
547
+ }
514
548
  }
515
549
  return {
516
550
  ...createObservation({
@@ -706,7 +740,7 @@ async function collectReverseDnsIntelligence(address, options = {}) {
706
740
  const timeoutMs = options.timeoutMs || DNS_TIMEOUT_MS;
707
741
  const now = options.now || Date.now;
708
742
  const started = now();
709
- const ptr = await query(() => (options.resolver || dns.resolvePtr)(address), normalizePtr, timeoutMs);
743
+ const ptr = await query(() => (options.resolver || dns.reverse)(address), normalizePtr, timeoutMs);
710
744
  const incomplete = ptr.status === 'error' || ptr.truncated || ptr.discarded > 0;
711
745
  return {
712
746
  ...createObservation({
@@ -718,6 +718,9 @@ function validateNegativeProof(response, name, missingType, keys, zone, now, req
718
718
  const verification = verifyRrset(response, exactNsec.owner, DNS_TYPE_NSEC, keys, zone, now);
719
719
  if (verification.state !== 'valid')
720
720
  return verification;
721
+ if (exactNsec.data.types.has(DNS_TYPE_CNAME)) {
722
+ return { state: 'unsupported', detail: 'The validated NSEC owner is an alias; the target RRset cannot be treated as authenticated absence without a validated alias walk.' };
723
+ }
721
724
  const delegation = exactNsec.data.types.has(DNS_TYPE_NS) && !exactNsec.data.types.has(DNS_TYPE_SOA);
722
725
  if (!exactNsec.data.types.has(missingType) && (!requireDelegation || delegation)) {
723
726
  return { state: 'valid', detail: 'A validated NSEC record proved the requested RRset absent.' };
@@ -758,6 +761,9 @@ function validateNegativeProof(response, name, missingType, keys, zone, now, req
758
761
  sawBogus = true;
759
762
  continue;
760
763
  }
764
+ if (exact && record.data.types.has(DNS_TYPE_CNAME)) {
765
+ return { state: 'unsupported', detail: 'The validated NSEC3 owner is an alias; the target RRset cannot be treated as authenticated absence without a validated alias walk.' };
766
+ }
761
767
  if (exact && record.data.types.has(missingType))
762
768
  return { state: 'bogus', detail: 'The validated NSEC3 bitmap contains the RR type reported as absent.' };
763
769
  if (requireDelegation && exact && (!record.data.types.has(DNS_TYPE_NS) || record.data.types.has(DNS_TYPE_SOA)))
@@ -38,23 +38,50 @@ function changeSummary(before, after) {
38
38
  const beforeRows = rows(before);
39
39
  const afterRows = rows(after);
40
40
  const keys = [...new Set([...beforeRows.keys(), ...afterRows.keys()])].sort();
41
- return Object.freeze(keys.slice(0, 500).flatMap((key) => {
41
+ const authorityCollectionComplete = (review) => {
42
+ const representative = review.authoritativeRecordMatrix[0];
43
+ return Boolean(representative
44
+ && representative.observations.length >= 2
45
+ && representative.observations.every((item) => item.state === 'observed'));
46
+ };
47
+ const rowEvidence = (review, row) => {
48
+ if (!row)
49
+ return Object.freeze({
50
+ state: authorityCollectionComplete(review) ? 'complete' : 'unavailable',
51
+ values: Object.freeze([]),
52
+ });
53
+ if (row.observations.some((item) => item.state !== 'observed')) {
54
+ return Object.freeze({ state: 'partial', values: Object.freeze([]) });
55
+ }
56
+ if (row.state === 'different')
57
+ return Object.freeze({ state: 'inconsistent', values: Object.freeze([]) });
58
+ if (row.state !== 'aligned')
59
+ return Object.freeze({ state: 'insufficient', values: Object.freeze([]) });
60
+ return Object.freeze({
61
+ state: 'complete',
62
+ values: Object.freeze([...new Set(row.observations.flatMap((item) => item.values))].sort()),
63
+ });
64
+ };
65
+ const changed = [];
66
+ for (const key of keys.slice(0, 500)) {
42
67
  const left = beforeRows.get(key);
43
68
  const right = afterRows.get(key);
44
- const leftValues = left?.observations.flatMap((item) => item.values) ?? [];
45
- const rightValues = right?.observations.flatMap((item) => item.values) ?? [];
46
- const beforeValues = [...new Set(leftValues)].sort();
47
- const afterValues = [...new Set(rightValues)].sort();
48
- if (JSON.stringify(beforeValues) === JSON.stringify(afterValues))
49
- return [];
50
69
  const [owner, type] = key.split('\u0000');
51
- return [Object.freeze({
52
- owner: owner ?? '',
53
- type: type ?? '',
54
- beforeValues: Object.freeze(beforeValues),
55
- afterValues: Object.freeze(afterValues),
56
- })];
57
- }));
70
+ const beforeEvidence = rowEvidence(before, left);
71
+ const afterEvidence = rowEvidence(after, right);
72
+ if (beforeEvidence.state !== 'complete' || afterEvidence.state !== 'complete') {
73
+ continue;
74
+ }
75
+ if (JSON.stringify(beforeEvidence.values) === JSON.stringify(afterEvidence.values))
76
+ continue;
77
+ changed.push(Object.freeze({
78
+ owner: owner ?? '',
79
+ type: type ?? '',
80
+ beforeValues: beforeEvidence.values,
81
+ afterValues: afterEvidence.values,
82
+ }));
83
+ }
84
+ return Object.freeze(changed);
58
85
  }
59
86
  export async function buildDomainChangePacket(inputRaw, generatedAtValue = new Date().toISOString()) {
60
87
  const input = record(inputRaw, 'Domain change packet input');
@@ -99,6 +126,7 @@ export async function buildDomainChangePacket(inputRaw, generatedAtValue = new D
99
126
  limitations: Object.freeze([
100
127
  'This packet is assembled only from analyst-supplied observations and planning metadata and makes no network request or configuration change.',
101
128
  'A ready result means the supplied bounded checks passed; it does not prove control, propagation completion, successful recovery, or absence of unobserved dependencies.',
129
+ 'Authoritative record-set changes are reported only when both sides contain complete consistent observations; gate reasons describe incomplete comparisons.',
102
130
  'The integrity digest detects later changes to this packet but does not authenticate its author. Use a separately managed signing key when signer authentication is required.',
103
131
  ]),
104
132
  });
@@ -267,8 +267,11 @@ function values(value, label) {
267
267
  const output = new Set();
268
268
  for (let index = 0; index < input.length; index += 1) {
269
269
  const normalized = text(input[index], `${label}[${index}]`, MAX_FLIGHT_RECORDER_VALUE_LENGTH).toLowerCase();
270
- if (normalized && output.size < MAX_FLIGHT_RECORDER_VALUES)
270
+ if (normalized)
271
271
  output.add(normalized);
272
+ if (output.size > MAX_FLIGHT_RECORDER_VALUES) {
273
+ throw new TypeError(`${label} must contain at most ${MAX_FLIGHT_RECORDER_VALUES} unique normalized values.`);
274
+ }
272
275
  }
273
276
  return [...output].sort(ordinalCompare);
274
277
  }
@@ -115,15 +115,13 @@ function hostnameList(value) {
115
115
  return [...new Set(value
116
116
  .map(domain)
117
117
  .filter((item) => item !== null))]
118
- .sort()
119
- .slice(0, MAX_DOMAIN_CONTROL_RECORDS);
118
+ .sort();
120
119
  }
121
120
  function textList(value, maximum = 300) {
122
121
  return [...new Set(value
123
122
  .map((item) => boundedText(item, maximum)?.toLowerCase() ?? null)
124
123
  .filter((item) => item !== null))]
125
- .sort()
126
- .slice(0, MAX_DOMAIN_CONTROL_RECORDS);
124
+ .sort();
127
125
  }
128
126
  function spkiFingerprint(value) {
129
127
  if (typeof value !== 'string'
@@ -191,21 +189,22 @@ function boundedDataArray(value, maximum) {
191
189
  function fieldValues(field, value) {
192
190
  if (field === 'nameservers') {
193
191
  const normalized = value.map(domain);
194
- return normalized.some((item) => item === null)
195
- ? null
196
- : hostnameList(normalized);
192
+ if (normalized.some((item) => item === null))
193
+ return null;
194
+ const values = hostnameList(normalized);
195
+ return values.length <= MAX_DOMAIN_CONTROL_RECORDS ? values : null;
197
196
  }
198
197
  if (field === 'mx' || field === 'caa' || field === 'ds') {
199
198
  const normalized = value.flatMap((item) => canonicalDomainControlRecordList([item], field));
200
- return normalized.length === value.length
201
- ? [...new Set(normalized)].sort().slice(0, MAX_DOMAIN_CONTROL_RECORDS)
202
- : null;
199
+ const values = [...new Set(normalized)].sort();
200
+ return normalized.length === value.length && values.length <= MAX_DOMAIN_CONTROL_RECORDS ? values : null;
203
201
  }
204
202
  if (field === 'tlsSpkiSha256') {
205
203
  const normalized = value.map(spkiFingerprint);
206
- return normalized.some((item) => item === null)
207
- ? null
208
- : [...new Set(normalized)].sort().slice(0, MAX_DOMAIN_CONTROL_RECORDS);
204
+ if (normalized.some((item) => item === null))
205
+ return null;
206
+ const values = [...new Set(normalized)].sort();
207
+ return values.length <= MAX_DOMAIN_CONTROL_RECORDS ? values : null;
209
208
  }
210
209
  if (field === 'registrarLock') {
211
210
  return value.every((item) => item === 'required' || item === 'not_required')
@@ -213,9 +212,10 @@ function fieldValues(field, value) {
213
212
  : null;
214
213
  }
215
214
  const normalized = value.map((item) => boundedText(item, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH)?.toLowerCase() ?? null);
216
- return normalized.some((item) => item === null)
217
- ? null
218
- : textList(normalized, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH);
215
+ if (normalized.some((item) => item === null))
216
+ return null;
217
+ const values = textList(normalized, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH);
218
+ return values.length <= MAX_DOMAIN_CONTROL_RECORDS ? values : null;
219
219
  }
220
220
  function normalizeObservation(value) {
221
221
  const source = record(value);
@@ -2,6 +2,7 @@
2
2
  // Kept separate from DNS/network I/O so policy edge cases can be tested with
3
3
  // synthetic records and the Express/Netlify paths share identical behavior.
4
4
  import { createPublicKey } from 'node:crypto';
5
+ import { isIP } from 'node:net';
5
6
  function joinTxtRecords(records) {
6
7
  if (!Array.isArray(records))
7
8
  return [];
@@ -46,6 +47,7 @@ function parseSpfRecords(records) {
46
47
  terminalPolicy: null,
47
48
  redirect: null,
48
49
  includes: [],
50
+ authorizingTerms: [],
49
51
  dnsLookupTerms: 0,
50
52
  issues: [],
51
53
  };
@@ -61,6 +63,107 @@ function parseSpfRecords(records) {
61
63
  if (!record)
62
64
  return result;
63
65
  const terms = record.split(/\s+/).slice(1).filter(Boolean);
66
+ let invalidSyntax = false;
67
+ const modifierNames = new Set();
68
+ const literalDomain = (value, label) => {
69
+ if (!value) {
70
+ result.issues.push(`${label} requires a domain-spec.`);
71
+ invalidSyntax = true;
72
+ return false;
73
+ }
74
+ if (value.includes('%')) {
75
+ result.issues.push(`${label} uses SPF macros that this bounded audit does not evaluate.`);
76
+ return true;
77
+ }
78
+ const hostname = value.toLowerCase().replace(/\.$/u, '');
79
+ if (hostname.length > 253 || !hostname.includes('.') || !hostname.split('.').every((part) => /^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/u.test(part))) {
80
+ result.issues.push(`${label} has an invalid literal domain-spec.`);
81
+ return false;
82
+ }
83
+ return true;
84
+ };
85
+ for (const term of terms) {
86
+ const qualifier = /^[+?~-]/u.test(term) ? term[0] : '+';
87
+ const body = qualifier === '+' && term[0] !== '+' ? term : term.slice(1);
88
+ const lower = body.toLowerCase();
89
+ const modifier = body.match(/^([a-z][a-z0-9_.-]*)=(.*)$/iu);
90
+ if (modifier) {
91
+ const name = modifier[1].toLowerCase();
92
+ const value = modifier[2];
93
+ if (modifierNames.has(name)) {
94
+ result.issues.push(`Duplicate SPF modifier: ${name}.`);
95
+ invalidSyntax = true;
96
+ }
97
+ modifierNames.add(name);
98
+ if (name === 'redirect') {
99
+ literalDomain(value, 'The redirect modifier');
100
+ if (qualifier !== '+') {
101
+ result.issues.push('SPF modifiers cannot have a qualifier.');
102
+ invalidSyntax = true;
103
+ }
104
+ result.authorizingTerms.push(term);
105
+ }
106
+ else if (name === 'exp') {
107
+ literalDomain(value, 'The exp modifier');
108
+ }
109
+ else {
110
+ result.issues.push(`The ${name} SPF modifier is not evaluated by this bounded audit.`);
111
+ }
112
+ continue;
113
+ }
114
+ if (lower === 'all')
115
+ continue;
116
+ const include = body.match(/^include:(.*)$/iu);
117
+ if (include) {
118
+ literalDomain(include[1], 'The include mechanism');
119
+ if (qualifier !== '-')
120
+ result.authorizingTerms.push(term);
121
+ continue;
122
+ }
123
+ const exists = body.match(/^exists:(.*)$/iu);
124
+ if (exists) {
125
+ literalDomain(exists[1], 'The exists mechanism');
126
+ if (qualifier !== '-')
127
+ result.authorizingTerms.push(term);
128
+ continue;
129
+ }
130
+ const ptr = body.match(/^ptr(?::(.*))?$/iu);
131
+ if (ptr) {
132
+ if (ptr[1] !== undefined)
133
+ literalDomain(ptr[1], 'The ptr mechanism');
134
+ if (qualifier !== '-')
135
+ result.authorizingTerms.push(term);
136
+ continue;
137
+ }
138
+ const address = body.match(/^(ip4|ip6):(.*)$/iu);
139
+ if (address) {
140
+ const family = address[1].toLowerCase() === 'ip4' ? 4 : 6;
141
+ const [literal, prefix, ...extra] = address[2].split('/');
142
+ const maximumPrefix = family === 4 ? 32 : 128;
143
+ if (extra.length || isIP(literal || '') !== family || (prefix !== undefined && (!/^\d+$/u.test(prefix) || Number(prefix) > maximumPrefix))) {
144
+ result.issues.push(`The ${address[1].toLowerCase()} mechanism has an invalid address or prefix length.`);
145
+ invalidSyntax = true;
146
+ }
147
+ if (qualifier !== '-')
148
+ result.authorizingTerms.push(term);
149
+ continue;
150
+ }
151
+ const hostMechanism = body.match(/^(a|mx)(?::([^/]+))?(?:\/(\d+))?(?:\/\/(\d+))?$/iu);
152
+ if (hostMechanism) {
153
+ if (hostMechanism[2] !== undefined)
154
+ literalDomain(hostMechanism[2], `The ${hostMechanism[1].toLowerCase()} mechanism`);
155
+ if ((hostMechanism[3] !== undefined && Number(hostMechanism[3]) > 32)
156
+ || (hostMechanism[4] !== undefined && Number(hostMechanism[4]) > 128)) {
157
+ result.issues.push(`The ${hostMechanism[1].toLowerCase()} mechanism has an invalid prefix length.`);
158
+ invalidSyntax = true;
159
+ }
160
+ if (qualifier !== '-')
161
+ result.authorizingTerms.push(term);
162
+ continue;
163
+ }
164
+ result.issues.push(`Unsupported or malformed SPF term: ${term}.`);
165
+ invalidSyntax = true;
166
+ }
64
167
  const allIndex = terms.findIndex((term) => /^[+?~-]?all$/i.test(term));
65
168
  const allTerm = allIndex === -1 ? null : terms[allIndex];
66
169
  const redirectTerm = terms.find((term) => /^redirect=/i.test(term));
@@ -89,7 +192,7 @@ function parseSpfRecords(records) {
89
192
  result.terminalPolicy = 'none';
90
193
  result.issues.push('No all mechanism or redirect modifier defines a terminal policy.');
91
194
  }
92
- result.valid = true;
195
+ result.valid = !invalidSyntax;
93
196
  return result;
94
197
  }
95
198
  function parseDmarcRecords(records) {
@@ -321,8 +424,14 @@ function parseDkimRecords(selector, records) {
321
424
  }
322
425
  else {
323
426
  try {
427
+ const decodedKey = Buffer.from(encodedKey, 'base64');
428
+ if (result.keyType === 'ed25519' && decodedKey.length !== 32) {
429
+ throw new Error('Ed25519 DKIM keys use an exact 32-byte raw public key.');
430
+ }
324
431
  const key = createPublicKey({
325
- key: Buffer.from(encodedKey, 'base64'),
432
+ key: result.keyType === 'ed25519'
433
+ ? Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), decodedKey])
434
+ : decodedKey,
326
435
  format: 'der',
327
436
  type: 'spki',
328
437
  });
@@ -341,7 +450,9 @@ function parseDkimRecords(selector, records) {
341
450
  }
342
451
  catch {
343
452
  result.keyParseState = 'invalid';
344
- result.issues.push('The DKIM public key could not be parsed as SubjectPublicKeyInfo.');
453
+ result.issues.push(result.keyType === 'ed25519'
454
+ ? 'The Ed25519 DKIM public key must be exactly 32 raw bytes.'
455
+ : 'The DKIM public key could not be parsed as SubjectPublicKeyInfo.');
345
456
  }
346
457
  }
347
458
  }
@@ -23,6 +23,22 @@ function errorRecord(value) {
23
23
  function asMxRecords(records) {
24
24
  return records;
25
25
  }
26
+ function completeMxRecords(records) {
27
+ const normalized = [];
28
+ for (const raw of records) {
29
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
30
+ return null;
31
+ const value = raw;
32
+ const exchange = typeof value.exchange === 'string' ? value.exchange.trim().toLowerCase().replace(/\.+$/u, '') : null;
33
+ const priority = Number(value.priority);
34
+ if (exchange === null || !Number.isInteger(priority) || priority < 0 || priority > 65_535)
35
+ return null;
36
+ if (exchange && !normalizeAuditDomain(exchange))
37
+ return null;
38
+ normalized.push({ exchange, priority });
39
+ }
40
+ return normalized;
41
+ }
26
42
  function trimTerminalDots(value) {
27
43
  let end = value.length;
28
44
  while (end > 0 && value.charCodeAt(end - 1) === 46)
@@ -356,7 +372,22 @@ function mtaStsCheck(dnsQuery, policyFetch, mxQuery) {
356
372
  remediation: 'Configure the policy host to return Content-Type: text/plain.',
357
373
  });
358
374
  }
359
- const mxHosts = mxQuery.error ? [] : classifyMxRecords(asMxRecords(mxQuery.records)).mxHosts;
375
+ if (mxQuery.error) {
376
+ return check('mta_sts', 'MTA-STS', 'info', 'Policy is valid but MX coverage could not be evaluated', {
377
+ detail: mxQuery.error,
378
+ records,
379
+ remediation: 'Retry the MX lookup before concluding that the policy covers every current mail exchanger.',
380
+ });
381
+ }
382
+ const reviewedMx = completeMxRecords(mxQuery.records);
383
+ if (!reviewedMx) {
384
+ return check('mta_sts', 'MTA-STS', 'info', 'Policy is valid but MX coverage is incomplete', {
385
+ detail: 'One or more MX records were malformed or outside the bounded hostname and priority contract.',
386
+ records,
387
+ remediation: 'Retry and review the MX evidence before concluding that the policy covers every current mail exchanger.',
388
+ });
389
+ }
390
+ const mxHosts = classifyMxRecords(reviewedMx).mxHosts;
360
391
  const unmatched = mxHosts.filter((host) => !policy.mx.some((pattern) => matchesMtaPattern(host, pattern)));
361
392
  if (unmatched.length) {
362
393
  return check('mta_sts', 'MTA-STS', 'danger', 'Policy does not cover every published MX host', {
@@ -507,7 +538,7 @@ function defensiveMailProfileCheck(profile, input) {
507
538
  const mx = input.mx.error ? null : classifyMxRecords(asMxRecords(input.mx.records));
508
539
  const requirements = {
509
540
  nullMx: mx?.hasNullMx === true,
510
- restrictiveSpf: spf?.valid === true && spf.terminalPolicy === 'fail' && spf.dnsLookupTerms === 0,
541
+ restrictiveSpf: spf?.valid === true && spf.terminalPolicy === 'fail' && spf.authorizingTerms.length === 0,
511
542
  rejectingDmarc: dmarc?.valid === true
512
543
  && dmarc.policy === 'reject'
513
544
  && dmarc.subdomainPolicy === 'reject'
@@ -620,7 +651,7 @@ async function fetchMtaStsPolicy(domain, fetcher = safeFetch) {
620
651
  signal: controller.signal,
621
652
  headers: whoisleuthRequestHeaders({ Accept: 'text/plain' }),
622
653
  }, 0);
623
- if (!res.ok) {
654
+ if (res.status !== 200) {
624
655
  // Not reading this body - release it explicitly instead of leaving an
625
656
  // unconsumed stream (and the connection it's tied to) open until
626
657
  // undici's own idle-timeout eventually notices.