@slicedearth/whoisleuth-cli 1.45.0 → 1.47.4
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/DISCLOSURE +7 -3
- package/NOTICE +5 -4
- package/README.md +59 -6
- package/bin/whoisleuth.mjs +5 -3
- package/cli/arguments.mjs +256 -9
- package/cli/artifact-structure.mjs +1833 -0
- package/cli/artifact-verify.mjs +242 -49
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +2 -3
- package/cli/bulk-command-runner.mjs +2 -5
- package/cli/case-pack.mjs +819 -34
- package/cli/command-reference.mjs +48 -24
- package/cli/completion.mjs +16 -8
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discovery-scan-command-runner.mjs +4 -5
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +21 -15
- package/cli/domain-control-observations.mjs +25 -2
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +73 -45
- package/cli/export-evidence.mjs +11 -4
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/terminal.mjs +3 -0
- package/cli/input.mjs +57 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-run.mjs +71 -23
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookup-command-runner.mjs +2 -2
- package/cli/lookup-diff.mjs +9 -0
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +28 -8
- package/cli/page-compare.mjs +8 -5
- package/cli/registry-cohort.mjs +385 -61
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +376 -0
- package/cli/risk-calibration.mjs +7 -5
- package/cli/runner.mjs +147 -185
- package/cli/saved-lookup.mjs +26 -3
- package/cli/sharing-review.mjs +12 -6
- package/docs/cli-reference.md +444 -70
- package/docs/cli.md +24 -5
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
- package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +29 -5
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
- package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
- package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
- package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
- package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
- package/frontend/src/lib/analysis/bulk-sort.js +4 -0
- package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +8 -0
- package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
- package/frontend/src/lib/analysis/case-migration-model.js +83 -31
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-model.js +2 -0
- package/frontend/src/lib/analysis/case-record-operations.js +42 -12
- package/frontend/src/lib/analysis/case-report.js +39 -6
- package/frontend/src/lib/analysis/case-response-model.js +66 -7
- package/frontend/src/lib/analysis/case-response-packet.js +15 -5
- package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
- package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
- package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
- package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
- package/frontend/src/lib/analysis/domain-control-records.js +88 -0
- package/frontend/src/lib/analysis/external-findings-import.js +70 -6
- package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
- package/frontend/src/lib/analysis/investigation-guide.js +52 -10
- package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
- package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
- package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +61 -6
- package/frontend/src/lib/analysis/lookup-presentation.js +62 -0
- package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
- package/frontend/src/lib/analysis/utils.js +26 -3
- package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
- package/frontend/src/lib/analysis/visualization-models.js +63 -94
- package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
- package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
- package/frontend/src/lib/analysis/workspace-archive.js +104 -14
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +14 -12
- package/lib/bounded-file.mjs +13 -0
- package/lib/bounded-json-response.mjs +17 -4
- package/lib/bounded-json.mjs +249 -0
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/cryptographic-assurance.mjs +155 -0
- package/lib/ct-search.mjs +87 -47
- package/lib/dns-intelligence.mjs +3 -2
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1053 -0
- package/lib/dnssec-evidence-validation.mjs +10 -3
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-manifest.mjs +17 -129
- package/lib/domain-posture.mjs +7 -4
- package/lib/evidence-export.mjs +751 -78
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/favicon.mjs +8 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +2 -5
- package/lib/interchange-fidelity-registry.mjs +122 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup-response-contract.mjs +691 -3
- package/lib/lookup.mjs +2 -2
- package/lib/observation.mjs +7 -6
- package/lib/outbound-identity.mjs +2 -3
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +21 -0
- package/lib/provider-policy-admission.mjs +9 -4
- package/lib/rdap-normalization.mjs +18 -5
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/risk-calibration-summary.mjs +338 -0
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +59 -10
- package/lib/security-txt.mjs +26 -3
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +784 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/threat-intelligence-runtime.mjs +3 -11
- package/lib/threat-intelligence-types.mjs +18 -1
- package/lib/tls-intelligence.mjs +11 -5
- package/lib/tlsa-evidence.mjs +37 -7
- package/package.json +1 -1
- package/third-party-notices.txt +95 -5817
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
export const LOOKUP_CLAIM_READINESS_VERSION = 2;
|
|
2
|
+
export const LOOKUP_CLAIM_IDS = [
|
|
3
|
+
'registration-state',
|
|
4
|
+
'current-web-observation',
|
|
5
|
+
'brand-resemblance',
|
|
6
|
+
'controlled-change',
|
|
7
|
+
'incident-response',
|
|
8
|
+
'network-context',
|
|
9
|
+
];
|
|
10
|
+
export const LOOKUP_CLAIM_REQUIREMENT_IDS = [
|
|
11
|
+
'authority-aware-availability',
|
|
12
|
+
'registry-rdap',
|
|
13
|
+
'registry-whois',
|
|
14
|
+
'registry-control-selection',
|
|
15
|
+
'dns-observation',
|
|
16
|
+
'http-observation',
|
|
17
|
+
'tls-observation',
|
|
18
|
+
'page-identity-observation',
|
|
19
|
+
'reviewed-brand-profile',
|
|
20
|
+
'reviewed-case-recipient',
|
|
21
|
+
'authoritative-rdap',
|
|
22
|
+
];
|
|
23
|
+
const MAX_TEXT = 320;
|
|
24
|
+
const MAX_DIAGNOSTICS = 12;
|
|
25
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/gu;
|
|
26
|
+
const LIMITED_STATES = new Set(['partial', 'unavailable', 'unknown']);
|
|
27
|
+
const ABSENT_STATES = new Set(['skipped', 'unsupported']);
|
|
28
|
+
export const LOOKUP_CLAIM_REQUIREMENTS = Object.freeze({
|
|
29
|
+
'authority-aware-availability': Object.freeze({ id: 'authority-aware-availability', label: 'Authority-aware availability decision', evidenceId: 'availability', mode: 'network_collection', href: '#registry' }),
|
|
30
|
+
'registry-rdap': Object.freeze({ id: 'registry-rdap', label: 'Registry RDAP evidence', evidenceId: 'rdap', mode: 'network_collection', href: '#registry' }),
|
|
31
|
+
'registry-whois': Object.freeze({ id: 'registry-whois', label: 'Registry WHOIS evidence', evidenceId: 'whois', mode: 'network_collection', href: '#registry' }),
|
|
32
|
+
'registry-control-selection': Object.freeze({ id: 'registry-control-selection', label: 'Registry control evidence selected by the availability authority', evidenceId: null, mode: 'network_collection', href: '#registry' }),
|
|
33
|
+
'dns-observation': Object.freeze({ id: 'dns-observation', label: 'DNS observation', evidenceId: 'dns', mode: 'network_collection', href: '#evidence-dns' }),
|
|
34
|
+
'http-observation': Object.freeze({ id: 'http-observation', label: 'HTTP observation', evidenceId: 'http', mode: 'network_collection', href: '#evidence-http' }),
|
|
35
|
+
'tls-observation': Object.freeze({ id: 'tls-observation', label: 'TLS observation', evidenceId: 'tls', mode: 'network_collection', href: '#evidence-tls' }),
|
|
36
|
+
'page-identity-observation': Object.freeze({ id: 'page-identity-observation', label: 'Page identity observation', evidenceId: 'page-identity', mode: 'network_collection', href: '#evidence-page' }),
|
|
37
|
+
'reviewed-brand-profile': Object.freeze({ id: 'reviewed-brand-profile', label: 'Reviewed Brand Profile', evidenceId: null, mode: 'local_review', href: '#case-response' }),
|
|
38
|
+
'reviewed-case-recipient': Object.freeze({ id: 'reviewed-case-recipient', label: 'Reviewed case and recipient route', evidenceId: null, mode: 'local_review', href: '#case-response' }),
|
|
39
|
+
'authoritative-rdap': Object.freeze({ id: 'authoritative-rdap', label: 'Authoritative RDAP evidence', evidenceId: 'rdap', mode: 'network_collection', href: '#registry' }),
|
|
40
|
+
});
|
|
41
|
+
function record(value) {
|
|
42
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
43
|
+
? value
|
|
44
|
+
: {};
|
|
45
|
+
}
|
|
46
|
+
function text(value, maximum = MAX_TEXT) {
|
|
47
|
+
return String(value ?? '')
|
|
48
|
+
.replace(CONTROL_CHARACTERS, ' ')
|
|
49
|
+
.replace(/\s+/gu, ' ')
|
|
50
|
+
.trim()
|
|
51
|
+
.slice(0, maximum);
|
|
52
|
+
}
|
|
53
|
+
function timestamp(value) {
|
|
54
|
+
if (typeof value !== 'string')
|
|
55
|
+
return null;
|
|
56
|
+
const parsed = Date.parse(value);
|
|
57
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
58
|
+
}
|
|
59
|
+
function evaluateRequirements(requirements) {
|
|
60
|
+
const missing = requirements.filter((requirement) => requirement.state !== 'complete');
|
|
61
|
+
const limitations = missing.flatMap((requirement) => {
|
|
62
|
+
const detail = requirement.limitations[0] || requirement.state.replaceAll('_', ' ');
|
|
63
|
+
return [`${requirement.label}: ${detail}`];
|
|
64
|
+
}).slice(0, 8);
|
|
65
|
+
if (!missing.length) {
|
|
66
|
+
return {
|
|
67
|
+
state: 'ready',
|
|
68
|
+
requiredEvidence: requirements.map((item) => item.label),
|
|
69
|
+
missingEvidence: [],
|
|
70
|
+
requiredEvidenceIds: requirements.map((item) => item.id),
|
|
71
|
+
missingEvidenceIds: [],
|
|
72
|
+
limitations,
|
|
73
|
+
requirements,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const observedCount = requirements.filter((requirement) => !ABSENT_STATES.has(requirement.state) && requirement.state !== 'unknown').length;
|
|
77
|
+
const hasLimited = requirements.some((requirement) => LIMITED_STATES.has(requirement.state));
|
|
78
|
+
return {
|
|
79
|
+
state: observedCount > 0 && hasLimited ? 'limited' : 'not_ready',
|
|
80
|
+
requiredEvidence: requirements.map((item) => item.label),
|
|
81
|
+
missingEvidence: missing.map((item) => item.label),
|
|
82
|
+
requiredEvidenceIds: requirements.map((item) => item.id),
|
|
83
|
+
missingEvidenceIds: missing.map((item) => item.id),
|
|
84
|
+
limitations,
|
|
85
|
+
requirements,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function requirement(id, coverage, override) {
|
|
89
|
+
const definition = LOOKUP_CLAIM_REQUIREMENTS[id];
|
|
90
|
+
const evidence = definition.evidenceId ? coverage.get(definition.evidenceId) : undefined;
|
|
91
|
+
const state = override?.state ?? evidence?.state ?? 'unknown';
|
|
92
|
+
const limitations = override?.limitation
|
|
93
|
+
? [override.limitation]
|
|
94
|
+
: evidence?.limitations.length
|
|
95
|
+
? evidence.limitations.slice(0, 8)
|
|
96
|
+
: state === 'complete'
|
|
97
|
+
? []
|
|
98
|
+
: [evidence?.statusLabel || 'Not supplied'];
|
|
99
|
+
return Object.freeze({ ...definition, state, limitations: Object.freeze(limitations) });
|
|
100
|
+
}
|
|
101
|
+
function readinessEntry(input) {
|
|
102
|
+
const evaluated = evaluateRequirements(input.requirements);
|
|
103
|
+
return { id: input.id, label: input.label, conclusion: input.conclusion, href: input.href, ...evaluated };
|
|
104
|
+
}
|
|
105
|
+
function comparisonDiagnostics(comparison, sourceLabel, observedAt) {
|
|
106
|
+
const source = record(comparison);
|
|
107
|
+
const fields = Array.isArray(source.fields) ? source.fields.slice(0, 32) : [];
|
|
108
|
+
const registryTime = timestamp(observedAt.registry);
|
|
109
|
+
const comparedTime = timestamp(observedAt.compared);
|
|
110
|
+
const timeGapHours = registryTime !== null && comparedTime !== null
|
|
111
|
+
? Math.round(Math.abs(registryTime - comparedTime) / 3_600_000)
|
|
112
|
+
: null;
|
|
113
|
+
const output = [];
|
|
114
|
+
for (const item of fields) {
|
|
115
|
+
const field = record(item);
|
|
116
|
+
if (text(field.status, 64) !== 'conflict')
|
|
117
|
+
continue;
|
|
118
|
+
const label = text(field.label, 100) || 'Registration field';
|
|
119
|
+
const normalizedLabel = label.toLowerCase();
|
|
120
|
+
let hypothesis = 'Different source publication scope or update timing';
|
|
121
|
+
let detail = `Registry RDAP and ${sourceLabel} published different normalized values. Review the exact values and source timestamps.`;
|
|
122
|
+
const basis = ['Material normalized-value difference'];
|
|
123
|
+
if (timeGapHours !== null && timeGapHours >= 1) {
|
|
124
|
+
hypothesis = 'Collection-time or publication-lag difference';
|
|
125
|
+
detail = `The sources were observed about ${timeGapHours} hour${timeGapHours === 1 ? '' : 's'} apart. A change or publication delay could explain the difference, but this is not established.`;
|
|
126
|
+
basis.push('Different observation times');
|
|
127
|
+
}
|
|
128
|
+
else if (/contact|registrant|administrative|technical|abuse/u.test(normalizedLabel)) {
|
|
129
|
+
hypothesis = 'Disclosure or source-role difference';
|
|
130
|
+
detail = `Registration contact publications can differ by source role, disclosure policy, privacy service, or update timing. The available evidence does not identify which explanation applies.`;
|
|
131
|
+
basis.push('Contact-related field');
|
|
132
|
+
}
|
|
133
|
+
else if (/status|nameserver|name server|dnssec/u.test(normalizedLabel)) {
|
|
134
|
+
hypothesis = 'Registry and delegated-service publication difference';
|
|
135
|
+
detail = `Operational and registry publications can update at different times or represent different layers. Directly review the current authoritative evidence before treating this as a configuration change.`;
|
|
136
|
+
basis.push('Operational registration field');
|
|
137
|
+
}
|
|
138
|
+
else if (/registrar|sponsor/u.test(normalizedLabel)) {
|
|
139
|
+
hypothesis = 'Sponsorship publication or transfer timing difference';
|
|
140
|
+
detail = `A transfer, reseller relationship, source normalisation, or publication delay could explain the difference. None is proven by this comparison alone.`;
|
|
141
|
+
basis.push('Registrar-related field');
|
|
142
|
+
}
|
|
143
|
+
output.push({
|
|
144
|
+
id: `${sourceLabel === 'WHOIS' ? 'whois' : 'registrar-rdap'}-${normalizedLabel.replace(/[^a-z0-9]+/gu, '-').replace(/^-|-$/gu, '') || output.length}`,
|
|
145
|
+
field: label,
|
|
146
|
+
hypothesis,
|
|
147
|
+
detail,
|
|
148
|
+
basis,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return output;
|
|
152
|
+
}
|
|
153
|
+
export function buildLookupClaimReadiness(input) {
|
|
154
|
+
const coverage = new Map(input.coverage.entries.map((entry) => [entry.id, entry]));
|
|
155
|
+
const targetType = text(input.targetType, 20);
|
|
156
|
+
const settledAvailability = !['', 'unknown', 'inconclusive', 'error'].includes(text(input.availabilityState, 40));
|
|
157
|
+
const observedAvailability = requirement('authority-aware-availability', coverage);
|
|
158
|
+
const registrationRequirements = [settledAvailability
|
|
159
|
+
? observedAvailability
|
|
160
|
+
: Object.freeze({
|
|
161
|
+
...observedAvailability,
|
|
162
|
+
state: observedAvailability.state === 'complete' ? 'partial' : observedAvailability.state,
|
|
163
|
+
limitations: Object.freeze([
|
|
164
|
+
...observedAvailability.limitations,
|
|
165
|
+
'The authority-aware availability state is not settled.',
|
|
166
|
+
].slice(0, 8)),
|
|
167
|
+
})];
|
|
168
|
+
const availabilitySource = text(input.availabilitySource, 24).toLowerCase();
|
|
169
|
+
const controlledRegistrationRequirements = availabilitySource === 'rdap'
|
|
170
|
+
? [requirement('registry-rdap', coverage)]
|
|
171
|
+
: availabilitySource === 'whois'
|
|
172
|
+
? [requirement('registry-whois', coverage)]
|
|
173
|
+
: [requirement('registry-control-selection', coverage, {
|
|
174
|
+
state: 'unknown',
|
|
175
|
+
limitation: 'The availability authority did not select a registry control source.',
|
|
176
|
+
})];
|
|
177
|
+
const webRequirements = [requirement('http-observation', coverage), requirement('tls-observation', coverage)];
|
|
178
|
+
const entries = [];
|
|
179
|
+
if (targetType === 'domain') {
|
|
180
|
+
entries.push(readinessEntry({
|
|
181
|
+
id: 'registration-state',
|
|
182
|
+
label: 'Registration-state statement',
|
|
183
|
+
conclusion: 'Whether an authority-aware registered, available, or inconclusive statement can be made.',
|
|
184
|
+
requirements: registrationRequirements,
|
|
185
|
+
href: '#registry',
|
|
186
|
+
}));
|
|
187
|
+
entries.push(readinessEntry({
|
|
188
|
+
id: 'current-web-observation',
|
|
189
|
+
label: 'Current website statement',
|
|
190
|
+
conclusion: 'Whether the current HTTP and TLS observation is complete enough to describe what responded.',
|
|
191
|
+
requirements: webRequirements,
|
|
192
|
+
href: '#web-evidence',
|
|
193
|
+
}));
|
|
194
|
+
if (input.task === 'brand' || input.hasActiveProfile) {
|
|
195
|
+
entries.push(readinessEntry({
|
|
196
|
+
id: 'brand-resemblance',
|
|
197
|
+
label: 'Brand-resemblance assessment',
|
|
198
|
+
conclusion: 'Whether reviewed brand context and page identity evidence support a bounded resemblance assessment.',
|
|
199
|
+
requirements: [
|
|
200
|
+
requirement('page-identity-observation', coverage),
|
|
201
|
+
requirement('reviewed-brand-profile', coverage, {
|
|
202
|
+
state: input.profileSourceState === 'unavailable'
|
|
203
|
+
? 'unavailable'
|
|
204
|
+
: input.hasActiveProfile === true
|
|
205
|
+
? 'complete'
|
|
206
|
+
: 'unknown',
|
|
207
|
+
...(input.profileSourceState === 'unavailable'
|
|
208
|
+
? { limitation: 'The browser-local Brand Profile source is unavailable.' }
|
|
209
|
+
: input.hasActiveProfile === true
|
|
210
|
+
? {}
|
|
211
|
+
: { limitation: 'No reviewed Brand Profile is active.' }),
|
|
212
|
+
}),
|
|
213
|
+
],
|
|
214
|
+
href: '#evidence-page',
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
if (input.task === 'owned' || input.task === 'acquisition') {
|
|
218
|
+
entries.push(readinessEntry({
|
|
219
|
+
id: 'controlled-change',
|
|
220
|
+
label: 'Controlled-change planning',
|
|
221
|
+
conclusion: 'Whether registration and DNS evidence are complete enough to prepare a reviewed change plan.',
|
|
222
|
+
requirements: [...registrationRequirements, ...controlledRegistrationRequirements, requirement('dns-observation', coverage)],
|
|
223
|
+
href: '#evidence-dns',
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
226
|
+
if (input.task === 'incident') {
|
|
227
|
+
entries.push(readinessEntry({
|
|
228
|
+
id: 'incident-response',
|
|
229
|
+
label: 'Incident response handoff',
|
|
230
|
+
conclusion: 'Whether current web evidence and a reviewed recipient route are present for a response packet.',
|
|
231
|
+
requirements: [
|
|
232
|
+
requirement('http-observation', coverage),
|
|
233
|
+
requirement('page-identity-observation', coverage),
|
|
234
|
+
requirement('reviewed-case-recipient', coverage, {
|
|
235
|
+
state: input.hasCaseSection === true && (input.responseRecipientCount ?? 0) > 0 ? 'complete' : 'unknown',
|
|
236
|
+
...(input.hasCaseSection === true && (input.responseRecipientCount ?? 0) > 0
|
|
237
|
+
? {}
|
|
238
|
+
: { limitation: 'A reviewed case and recipient route are not both available.' }),
|
|
239
|
+
}),
|
|
240
|
+
],
|
|
241
|
+
href: '#case-response',
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else if (targetType === 'ip' || targetType === 'ipv4' || targetType === 'ipv6' || targetType === 'asn') {
|
|
246
|
+
entries.push(readinessEntry({
|
|
247
|
+
id: 'network-context',
|
|
248
|
+
label: 'Network registration statement',
|
|
249
|
+
conclusion: 'Whether the observed network registration context is complete enough to describe the resource.',
|
|
250
|
+
requirements: [requirement('authoritative-rdap', coverage)],
|
|
251
|
+
href: '#registry',
|
|
252
|
+
}));
|
|
253
|
+
}
|
|
254
|
+
const disagreements = [
|
|
255
|
+
...comparisonDiagnostics(input.registryComparison, 'WHOIS', {
|
|
256
|
+
registry: input.observedAt?.registry,
|
|
257
|
+
compared: input.observedAt?.whois,
|
|
258
|
+
}),
|
|
259
|
+
...comparisonDiagnostics(input.registrarPublicationComparison, 'Registrar RDAP', {
|
|
260
|
+
registry: input.observedAt?.registry,
|
|
261
|
+
compared: input.observedAt?.registrar,
|
|
262
|
+
}),
|
|
263
|
+
].slice(0, MAX_DIAGNOSTICS);
|
|
264
|
+
const counts = {
|
|
265
|
+
ready: 0,
|
|
266
|
+
limited: 0,
|
|
267
|
+
not_ready: 0,
|
|
268
|
+
};
|
|
269
|
+
for (const entry of entries)
|
|
270
|
+
counts[entry.state] += 1;
|
|
271
|
+
return {
|
|
272
|
+
version: LOOKUP_CLAIM_READINESS_VERSION,
|
|
273
|
+
entries,
|
|
274
|
+
disagreements,
|
|
275
|
+
counts,
|
|
276
|
+
limitation: input.decisionSupport.counts.conflicts
|
|
277
|
+
? 'Readiness describes evidence sufficiency, not truth. Registration-source explanations are bounded hypotheses and require review of the original observations.'
|
|
278
|
+
: 'Readiness describes evidence sufficiency, not truth, ownership, intent, safety, or eligibility.',
|
|
279
|
+
};
|
|
280
|
+
}
|
|
@@ -2,6 +2,21 @@ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/gu;
|
|
|
2
2
|
const MAX_TEXT = 320;
|
|
3
3
|
const MAX_ENTRIES = 24;
|
|
4
4
|
const MAX_ACTIONS = 6;
|
|
5
|
+
export const MAX_LOOKUP_PRESENTED_ACTIONS = 3;
|
|
6
|
+
const LOOKUP_TASK_EVIDENCE_KINDS = new Set([
|
|
7
|
+
'delegation', 'dependency', 'dns', 'form', 'http', 'identity', 'mail', 'page',
|
|
8
|
+
'posture', 'ptr', 'redirect', 'tls',
|
|
9
|
+
]);
|
|
10
|
+
const TASK_ACTION_ID = Object.freeze({
|
|
11
|
+
acquisition: 'review-acquisition-dependencies',
|
|
12
|
+
brand: 'review-page-identity',
|
|
13
|
+
owned: 'review-owned-posture',
|
|
14
|
+
});
|
|
15
|
+
const ACTION_PRIORITY = Object.freeze({
|
|
16
|
+
high: 0,
|
|
17
|
+
medium: 1,
|
|
18
|
+
low: 2,
|
|
19
|
+
});
|
|
5
20
|
const TASK_GUIDANCE = Object.freeze({
|
|
6
21
|
general: Object.freeze({
|
|
7
22
|
label: 'General investigation',
|
|
@@ -338,12 +353,40 @@ function prioritizeEntries(entries, task) {
|
|
|
338
353
|
return [...entries].sort((left, right) => {
|
|
339
354
|
const leftBoost = taskBoost[task].some((prefix) => left.id.startsWith(prefix)) ? -1 : 0;
|
|
340
355
|
const rightBoost = taskBoost[task].some((prefix) => right.id.startsWith(prefix)) ? -1 : 0;
|
|
341
|
-
return
|
|
342
|
-
||
|
|
356
|
+
return importance[left.importance] - importance[right.importance]
|
|
357
|
+
|| leftBoost - rightBoost
|
|
343
358
|
|| left.title.localeCompare(right.title);
|
|
344
359
|
}).slice(0, 16);
|
|
345
360
|
}
|
|
361
|
+
function normalizedTaskEvidenceKinds(values) {
|
|
362
|
+
return new Set((values ?? [])
|
|
363
|
+
.slice(0, 24)
|
|
364
|
+
.filter((value) => (typeof value === 'string' && LOOKUP_TASK_EVIDENCE_KINDS.has(value))));
|
|
365
|
+
}
|
|
366
|
+
function supportsDecisionDestination(href, evidence) {
|
|
367
|
+
if (href === '#evidence-tls')
|
|
368
|
+
return evidence.has('tls');
|
|
369
|
+
if (href === '#evidence-http')
|
|
370
|
+
return evidence.has('http') || evidence.has('redirect');
|
|
371
|
+
if (href === '#evidence-page') {
|
|
372
|
+
return evidence.has('page') || evidence.has('identity') || evidence.has('form');
|
|
373
|
+
}
|
|
374
|
+
if (href === '#evidence-certificate-policy')
|
|
375
|
+
return evidence.has('tls') || evidence.has('dns');
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
function supportsTaskAction(task, targetType, evidence) {
|
|
379
|
+
if (targetType !== 'domain')
|
|
380
|
+
return false;
|
|
381
|
+
const requiredKinds = {
|
|
382
|
+
acquisition: ['dns', 'mail', 'tls', 'http', 'page', 'dependency'],
|
|
383
|
+
brand: ['page', 'identity', 'form', 'redirect'],
|
|
384
|
+
owned: ['delegation', 'mail', 'tls', 'posture'],
|
|
385
|
+
};
|
|
386
|
+
return requiredKinds[task]?.some((kind) => evidence.has(kind)) ?? false;
|
|
387
|
+
}
|
|
346
388
|
export function buildLookupDecisionSupport(input) {
|
|
389
|
+
const availableEvidence = normalizedTaskEvidenceKinds(input.availableEvidence);
|
|
347
390
|
const entries = prioritizeEntries([
|
|
348
391
|
...comparisonEntries(input.registryComparison, 'registry-whois'),
|
|
349
392
|
...comparisonEntries(input.registrarPublicationComparison, 'registry-registrar'),
|
|
@@ -351,7 +394,8 @@ export function buildLookupDecisionSupport(input) {
|
|
|
351
394
|
...certificatePolicyEntries(input.certificatePolicyReview),
|
|
352
395
|
], input.task);
|
|
353
396
|
const actions = [];
|
|
354
|
-
const firstConflict = entries.find((entry) => entry.state === 'conflict'
|
|
397
|
+
const firstConflict = entries.find((entry) => entry.state === 'conflict'
|
|
398
|
+
&& supportsDecisionDestination(entry.href, availableEvidence));
|
|
355
399
|
if (firstConflict) {
|
|
356
400
|
actions.push({
|
|
357
401
|
id: 'review-priority-conflict',
|
|
@@ -383,7 +427,7 @@ export function buildLookupDecisionSupport(input) {
|
|
|
383
427
|
priority: 'medium',
|
|
384
428
|
});
|
|
385
429
|
}
|
|
386
|
-
if (input.task === 'brand') {
|
|
430
|
+
if (supportsTaskAction(input.task, input.targetType, availableEvidence) && input.task === 'brand') {
|
|
387
431
|
actions.push({
|
|
388
432
|
id: 'review-page-identity',
|
|
389
433
|
label: 'Review identity and credential evidence',
|
|
@@ -393,7 +437,7 @@ export function buildLookupDecisionSupport(input) {
|
|
|
393
437
|
priority: 'medium',
|
|
394
438
|
});
|
|
395
439
|
}
|
|
396
|
-
else if (input.task === 'acquisition') {
|
|
440
|
+
else if (supportsTaskAction(input.task, input.targetType, availableEvidence) && input.task === 'acquisition') {
|
|
397
441
|
actions.push({
|
|
398
442
|
id: 'review-acquisition-dependencies',
|
|
399
443
|
label: 'Review transfer dependencies',
|
|
@@ -403,7 +447,7 @@ export function buildLookupDecisionSupport(input) {
|
|
|
403
447
|
priority: 'medium',
|
|
404
448
|
});
|
|
405
449
|
}
|
|
406
|
-
else if (input.task === 'owned') {
|
|
450
|
+
else if (supportsTaskAction(input.task, input.targetType, availableEvidence) && input.task === 'owned') {
|
|
407
451
|
actions.push({
|
|
408
452
|
id: 'review-owned-posture',
|
|
409
453
|
label: 'Review delegation and posture evidence',
|
|
@@ -435,6 +479,17 @@ export function buildLookupDecisionSupport(input) {
|
|
|
435
479
|
},
|
|
436
480
|
};
|
|
437
481
|
}
|
|
482
|
+
export function projectLookupNextActions(actions, task) {
|
|
483
|
+
const taskActionId = TASK_ACTION_ID[task];
|
|
484
|
+
return actions
|
|
485
|
+
.slice(0, MAX_ACTIONS)
|
|
486
|
+
.map((action, index) => ({ action, index }))
|
|
487
|
+
.sort((left, right) => (ACTION_PRIORITY[left.action.priority] - ACTION_PRIORITY[right.action.priority]
|
|
488
|
+
|| Number(right.action.id === taskActionId) - Number(left.action.id === taskActionId)
|
|
489
|
+
|| left.index - right.index))
|
|
490
|
+
.slice(0, MAX_LOOKUP_PRESENTED_ACTIONS)
|
|
491
|
+
.map(({ action }) => action);
|
|
492
|
+
}
|
|
438
493
|
function timingByEvidence(timing) {
|
|
439
494
|
const output = new Map();
|
|
440
495
|
if (!timing)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const LOOKUP_PRESENTATION_STORAGE_KEY = 'whoisleuth:lookup-presentation:v1';
|
|
2
2
|
export const MAX_LOOKUP_PRESENTATION_SERIALIZED_BYTES = 1_024;
|
|
3
|
+
export const MAX_LOOKUP_URL_QUERY_LENGTH = 4_096;
|
|
3
4
|
export const LOOKUP_TASK_VIEWS = Object.freeze([
|
|
4
5
|
Object.freeze({ id: 'general', label: 'General investigation' }),
|
|
5
6
|
Object.freeze({ id: 'acquisition', label: 'Acquisition review' }),
|
|
@@ -8,11 +9,72 @@ export const LOOKUP_TASK_VIEWS = Object.freeze([
|
|
|
8
9
|
Object.freeze({ id: 'owned', label: 'Owned-domain posture' }),
|
|
9
10
|
]);
|
|
10
11
|
const TASKS = new Set(LOOKUP_TASK_VIEWS.map((option) => option.id));
|
|
12
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/u;
|
|
13
|
+
export function parseLookupTaskContext(value) {
|
|
14
|
+
if (typeof value !== 'string' || value.length > 16)
|
|
15
|
+
return null;
|
|
16
|
+
return TASKS.has(value) ? value : null;
|
|
17
|
+
}
|
|
11
18
|
export function normalizeLookupTaskView(value) {
|
|
12
19
|
return typeof value === 'string' && TASKS.has(value)
|
|
13
20
|
? value
|
|
14
21
|
: 'general';
|
|
15
22
|
}
|
|
23
|
+
function lookupUrlQuery(value) {
|
|
24
|
+
if (value === null || value.length > MAX_LOOKUP_URL_QUERY_LENGTH || CONTROL_CHARACTERS.test(value))
|
|
25
|
+
return null;
|
|
26
|
+
const normalized = value.trim();
|
|
27
|
+
return normalized ? normalized : null;
|
|
28
|
+
}
|
|
29
|
+
function lookupUrlDepth(value) {
|
|
30
|
+
return value === 'fast' || value === 'deep' ? value : null;
|
|
31
|
+
}
|
|
32
|
+
function record(value) {
|
|
33
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
34
|
+
? value
|
|
35
|
+
: null;
|
|
36
|
+
}
|
|
37
|
+
export function lookupResultDepth(value, fallback = null) {
|
|
38
|
+
const result = record(value);
|
|
39
|
+
if (!result)
|
|
40
|
+
return fallback;
|
|
41
|
+
const availability = record(result.availability);
|
|
42
|
+
if (availability?.deepScanComplete === false)
|
|
43
|
+
return 'fast';
|
|
44
|
+
if (availability?.deepScanComplete === true)
|
|
45
|
+
return 'deep';
|
|
46
|
+
const whois = record(result.whois);
|
|
47
|
+
if (whois?.skipped === true
|
|
48
|
+
&& typeof whois.detail === 'string'
|
|
49
|
+
&& /fast rdap-only mode/iu.test(whois.detail))
|
|
50
|
+
return 'fast';
|
|
51
|
+
const scanModes = [
|
|
52
|
+
record(result.reverseDns)?.scanMode,
|
|
53
|
+
record(availability?.dns)?.scanMode,
|
|
54
|
+
record(availability?.http)?.scanMode,
|
|
55
|
+
];
|
|
56
|
+
if (scanModes.includes('deep'))
|
|
57
|
+
return 'deep';
|
|
58
|
+
if (scanModes.includes('fast'))
|
|
59
|
+
return 'fast';
|
|
60
|
+
return fallback;
|
|
61
|
+
}
|
|
62
|
+
export function reconcileLookupUrlState(current, searchParams, preferredTask) {
|
|
63
|
+
const explicitQuery = lookupUrlQuery(searchParams.get('q'));
|
|
64
|
+
const explicitDepth = lookupUrlDepth(searchParams.get('depth'));
|
|
65
|
+
const queryChanged = explicitQuery !== null && explicitQuery !== current.query;
|
|
66
|
+
const depthChanged = explicitDepth !== null && explicitDepth !== current.depth;
|
|
67
|
+
const conflictsWithResult = current.result !== null && ((explicitQuery !== null && explicitQuery !== current.completedTarget)
|
|
68
|
+
|| (explicitDepth !== null && explicitDepth !== current.retainedResultDepth));
|
|
69
|
+
return {
|
|
70
|
+
query: explicitQuery ?? current.query,
|
|
71
|
+
depth: explicitDepth ?? current.depth,
|
|
72
|
+
task: parseLookupTaskContext(searchParams.get('task')) ?? normalizeLookupTaskView(preferredTask),
|
|
73
|
+
result: conflictsWithResult ? null : current.result,
|
|
74
|
+
completedTarget: conflictsWithResult ? '' : current.completedTarget,
|
|
75
|
+
error: conflictsWithResult || queryChanged || depthChanged ? '' : current.error,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
16
78
|
export function readLookupPresentation(storage) {
|
|
17
79
|
try {
|
|
18
80
|
const serialized = storage.getItem(LOOKUP_PRESENTATION_STORAGE_KEY);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Opaque browser-local identifiers shared by independently owned record
|
|
2
|
+
// models. Validation is intentionally exact: callers must not trim, case-fold,
|
|
3
|
+
// repair, resolve, or otherwise reinterpret an identifier.
|
|
4
|
+
export const OPAQUE_REFERENCE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
5
|
+
export function normalizeOpaqueReferenceId(value) {
|
|
6
|
+
return typeof value === 'string' && OPAQUE_REFERENCE_ID_PATTERN.test(value)
|
|
7
|
+
? value
|
|
8
|
+
: null;
|
|
9
|
+
}
|
|
@@ -151,6 +151,10 @@ function splitDelimitedLine(line, delimiter = ',') {
|
|
|
151
151
|
return result.map((s) => s.trim());
|
|
152
152
|
}
|
|
153
153
|
const DOMAIN_HEADER_NAMES = ['domain', 'domain_name', 'domain name', 'hostname', 'name'];
|
|
154
|
+
export const MAX_DOMAIN_INPUT_CHARACTERS = 2 * 1024 * 1024;
|
|
155
|
+
export const MAX_DOMAIN_INPUT_BYTES = 2 * 1024 * 1024;
|
|
156
|
+
export const MAX_DOMAIN_INPUT_LINES = 10_000;
|
|
157
|
+
export const MAX_DOMAIN_INPUT_SEPARATORS = 20_000;
|
|
154
158
|
function detectDelimiter(line) {
|
|
155
159
|
const candidates = [',', ';', '\t'];
|
|
156
160
|
let best = null;
|
|
@@ -182,10 +186,29 @@ function looksLikeLookupToken(value) {
|
|
|
182
186
|
}
|
|
183
187
|
}
|
|
184
188
|
export function parseDomainInput(text) {
|
|
185
|
-
const
|
|
189
|
+
const source = String(text || '');
|
|
190
|
+
if (source.length > MAX_DOMAIN_INPUT_CHARACTERS) {
|
|
191
|
+
return { entries: [], duplicates: 0, usedHeader: false, tooLarge: true };
|
|
192
|
+
}
|
|
193
|
+
if (new TextEncoder().encode(source).byteLength > MAX_DOMAIN_INPUT_BYTES) {
|
|
194
|
+
return { entries: [], duplicates: 0, usedHeader: false, tooLarge: true };
|
|
195
|
+
}
|
|
196
|
+
let lineCount = 1;
|
|
197
|
+
let separatorCount = 0;
|
|
198
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
199
|
+
const character = source[index];
|
|
200
|
+
if (character === '\n')
|
|
201
|
+
lineCount += 1;
|
|
202
|
+
if (character === ',' || character === ';' || character === '\t')
|
|
203
|
+
separatorCount += 1;
|
|
204
|
+
if (lineCount > MAX_DOMAIN_INPUT_LINES || separatorCount > MAX_DOMAIN_INPUT_SEPARATORS) {
|
|
205
|
+
return { entries: [], duplicates: 0, usedHeader: false, tooLarge: true };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const normalized = source.replace(/^\uFEFF/, '');
|
|
186
209
|
const lines = normalized.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
187
210
|
if (lines.length === 0)
|
|
188
|
-
return { entries: [], duplicates: 0, usedHeader: false };
|
|
211
|
+
return { entries: [], duplicates: 0, usedHeader: false, tooLarge: false };
|
|
189
212
|
const firstLine = lines[0] ?? '';
|
|
190
213
|
const delimiter = detectDelimiter(firstLine);
|
|
191
214
|
const firstCells = delimiter ? splitDelimitedLine(firstLine, delimiter) : [firstLine];
|
|
@@ -228,5 +251,5 @@ export function parseDomainInput(text) {
|
|
|
228
251
|
seen.add(key);
|
|
229
252
|
entries.push(value);
|
|
230
253
|
}
|
|
231
|
-
return { entries, duplicates, usedHeader };
|
|
254
|
+
return { entries, duplicates, usedHeader, tooLarge: false };
|
|
232
255
|
}
|