@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.
- package/README.md +2 -1
- package/cli/arguments.mjs +389 -1745
- package/cli/artifact-validation/case-response.mjs +55 -30
- package/cli/artifact-validation/signed-review.mjs +30 -12
- package/cli/bulk-checkpoint.mjs +34 -3
- package/cli/case-pack.mjs +39 -92
- package/cli/command-argument-grammar.mjs +211 -0
- package/cli/command-reference.mjs +200 -213
- package/cli/completion.mjs +5 -1
- package/cli/discovery-observation-snapshot.mjs +16 -3
- package/cli/discovery-scan-command-runner.mjs +19 -0
- package/cli/discovery-scan.mjs +6 -0
- package/cli/domain-control-observations.mjs +1 -1
- package/cli/inline-command-families.mjs +1 -0
- package/cli/input.mjs +116 -0
- package/cli/interchange-report.mjs +14 -0
- package/cli/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- package/cli/mail-header-review.mjs +467 -0
- package/cli/review-command-runner.mjs +25 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +7 -1
- package/docs/cli-reference.md +8 -1
- package/docs/cli.md +12 -1
- package/lib/bounded-json.mjs +9 -6
- package/lib/dns-convergence-review.mjs +1 -0
- package/lib/dns-intelligence.mjs +37 -3
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/domain-change-packet.mjs +42 -14
- package/lib/domain-control-flight-recorder.mjs +4 -1
- package/lib/domain-control-manifest.mjs +16 -16
- package/lib/domain-posture-parsers.mjs +114 -3
- package/lib/domain-posture.mjs +34 -3
- package/lib/evidence-export.mjs +60 -11
- package/lib/evidence-report.mjs +2 -2
- package/lib/html-signals.mjs +39 -33
- package/lib/local-geoip-evidence.mjs +19 -3
- package/lib/lookup-response.mjs +5 -4
- package/lib/lookup-source-progress.mjs +27 -4
- package/lib/nameserver-preflight-review.mjs +1 -0
- package/lib/rdap-capabilities.mjs +1 -1
- package/lib/registry-insights.mjs +35 -22
- package/lib/risk-calibration-summary.mjs +1 -3
- package/lib/security-txt.mjs +33 -1
- package/lib/static-html-analysis.mjs +69 -13
- package/lib/website-technology.mjs +79 -11
- package/lib/zone-intent-review.mjs +16 -6
- package/package.json +1 -1
- package/packages/cases/case-migration-model.mjs +71 -22
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +10 -21
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +6 -4
- package/packages/cases/case-record-projection.mjs +218 -0
- package/packages/cases/case-report.mjs +11 -6
- package/packages/cases/case-response-model.mjs +43 -6
- package/packages/cases/case-response-packet.mjs +78 -27
- package/packages/cases/case-storage-model.mjs +6 -2
- package/packages/comparison/comparison-ledger-bulk.mjs +9 -10
- package/packages/comparison/page-similarity.mjs +3 -1
- package/packages/contracts/case-portability.mjs +171 -51
- package/packages/contracts/cli-command-semantics.mjs +93 -0
- package/packages/contracts/risk-calibration.mjs +15 -10
- package/packages/contracts/workspace-portability.mjs +44 -7
- package/packages/evidence/domain-control-runtime.mjs +46 -6
- package/packages/interchange/external-findings-import.mjs +3 -2
- package/packages/investigation/lookup-asset-graph.mjs +35 -27
- package/packages/workspace/brand-profile-model.mjs +118 -2
- package/packages/workspace/bulk-review-model.mjs +13 -7
- package/packages/workspace/bulk-session-model.mjs +5 -4
- package/packages/workspace/detection-rule-model.mjs +70 -22
- package/packages/workspace/relationship-observation-model.mjs +4 -1
- package/packages/workspace/website-snapshot-model.mjs +106 -8
- package/packages/workspace/workspace-archive.mjs +2 -0
- 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
|
package/lib/bounded-json.mjs
CHANGED
|
@@ -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
|
|
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));
|
package/lib/dns-intelligence.mjs
CHANGED
|
@@ -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
|
|
193
|
-
const
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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 =
|
|
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:
|
|
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(
|
|
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
|
}
|
package/lib/domain-posture.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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 (
|
|
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.
|