@slicedearth/whoisleuth-cli 1.43.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 +67 -10
- package/bin/whoisleuth.mjs +22 -9
- 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 +67 -31
- package/cli/bulk-command-runner.mjs +16 -6
- package/cli/bulk.mjs +18 -7
- package/cli/case-pack.mjs +819 -34
- package/cli/command-reference.mjs +48 -24
- package/cli/completion.mjs +50 -11
- package/cli/config-profile.mjs +15 -12
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discovery-observation-snapshot.mjs +8 -14
- package/cli/discovery-scan-command-runner.mjs +18 -6
- package/cli/discovery-snapshot.mjs +8 -15
- 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/errors.mjs +7 -1
- 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/json.mjs +12 -4
- package/cli/formatters/terminal.mjs +4 -1
- package/cli/http.mjs +2 -1
- 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/lookup-reconcile.mjs +1 -1
- package/cli/mail-review.mjs +5 -5
- package/cli/manual.mjs +1 -1
- 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-fixture-scaffold.mjs +4 -3
- 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 +150 -177
- package/cli/saved-lookup.mjs +26 -3
- package/cli/sharing-review.mjs +12 -6
- package/docs/cli-reference.md +467 -74
- package/docs/cli.md +25 -5
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
- package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +33 -7
- 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 +9 -1
- 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-core.js +1 -1
- package/frontend/src/lib/analysis/case-record-model.js +2 -0
- package/frontend/src/lib/analysis/case-record-operations.js +43 -13
- 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-asset-graph.js +8 -8
- 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 +63 -8
- package/frontend/src/lib/analysis/lookup-presentation.js +70 -14
- package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
- package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
- package/frontend/src/lib/analysis/utils.js +27 -4
- 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-crypto.js +1 -4
- package/frontend/src/lib/analysis/workspace-archive.js +105 -19
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +14 -12
- package/lib/bounded-file.mjs +82 -0
- package/lib/bounded-json-response.mjs +170 -0
- package/lib/bounded-json.mjs +249 -0
- package/lib/browser-library-profile.mjs +167 -38
- 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-cache.mjs +5 -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/rdap-transport.mjs +6 -6
- 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 +96 -42
- 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 +4 -13
- package/lib/threat-intelligence-types.mjs +18 -1
- package/lib/tls-intelligence.mjs +11 -5
- package/lib/tlsa-evidence.mjs +37 -7
- package/lib/website-technology.mjs +2 -6
- package/package.json +1 -1
- package/third-party-notices.txt +95 -5817
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { LOOKUP_CLAIM_READINESS_VERSION, LOOKUP_CLAIM_IDS, LOOKUP_CLAIM_REQUIREMENT_IDS, } from "./lookup-claim-readiness.js";
|
|
2
|
+
import { SORTED_JSON_V2, sha256ArtifactDigestV2, } from "./artifact-integrity.js";
|
|
3
|
+
export const LOOKUP_CLAIM_PASSPORT_SCHEMA = 'whoisleuth.lookup-claim-passport';
|
|
4
|
+
export const LOOKUP_CLAIM_PASSPORT_VERSION = 1;
|
|
5
|
+
export const MAX_LOOKUP_CLAIM_PASSPORT_BYTES = 64 * 1024;
|
|
6
|
+
export const MAX_LOOKUP_CLAIM_PASSPORT_REQUIREMENTS = 16;
|
|
7
|
+
export const MAX_LOOKUP_CLAIM_PASSPORT_LIMITATIONS = 16;
|
|
8
|
+
export const LOOKUP_CLAIM_PASSPORT_TARGET_TYPES = ['domain', 'ipv4', 'ipv6', 'asn'];
|
|
9
|
+
const SOURCE_STATES = new Set([
|
|
10
|
+
'complete',
|
|
11
|
+
'not_found',
|
|
12
|
+
'partial',
|
|
13
|
+
'skipped',
|
|
14
|
+
'unavailable',
|
|
15
|
+
'unknown',
|
|
16
|
+
'unsupported',
|
|
17
|
+
]);
|
|
18
|
+
const CLAIM_STATES = new Set(['ready', 'limited', 'not_ready']);
|
|
19
|
+
const REQUIREMENT_IDS = new Set(LOOKUP_CLAIM_REQUIREMENT_IDS);
|
|
20
|
+
const CLAIM_IDS = new Set(LOOKUP_CLAIM_IDS);
|
|
21
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/gu;
|
|
22
|
+
const HAS_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/u;
|
|
23
|
+
const DOMAIN_RE = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u;
|
|
24
|
+
const SEMVER_RE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u;
|
|
25
|
+
function text(value, maximum) {
|
|
26
|
+
return typeof value === 'string'
|
|
27
|
+
? value.replace(CONTROL_CHARACTERS, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum)
|
|
28
|
+
: '';
|
|
29
|
+
}
|
|
30
|
+
function exactText(value, maximum) {
|
|
31
|
+
if (typeof value !== 'string' || value.length > maximum || HAS_CONTROL_CHARACTERS.test(value))
|
|
32
|
+
return '';
|
|
33
|
+
return value.trim();
|
|
34
|
+
}
|
|
35
|
+
function timestamp(value) {
|
|
36
|
+
if (typeof value !== 'string')
|
|
37
|
+
return null;
|
|
38
|
+
const parsed = Date.parse(value);
|
|
39
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
40
|
+
}
|
|
41
|
+
function uniqueText(values, maximum = MAX_LOOKUP_CLAIM_PASSPORT_LIMITATIONS) {
|
|
42
|
+
const output = [];
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
for (const value of values.slice(0, maximum * 2)) {
|
|
45
|
+
const normalized = text(value, 400);
|
|
46
|
+
if (!normalized || seen.has(normalized))
|
|
47
|
+
continue;
|
|
48
|
+
output.push(normalized);
|
|
49
|
+
seen.add(normalized);
|
|
50
|
+
if (output.length >= maximum)
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
return output;
|
|
54
|
+
}
|
|
55
|
+
function targetType(value) {
|
|
56
|
+
if (typeof value !== 'string' || !LOOKUP_CLAIM_PASSPORT_TARGET_TYPES.includes(value)) {
|
|
57
|
+
throw new TypeError('A supported Lookup target type is required for a claim passport.');
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
function domainTarget(value) {
|
|
62
|
+
const raw = exactText(value, 253).toLowerCase().replace(/\.$/u, '');
|
|
63
|
+
if (!raw || /[/\\?#@]/u.test(raw))
|
|
64
|
+
throw new TypeError('A canonical domain is required for a claim passport.');
|
|
65
|
+
let normalized = '';
|
|
66
|
+
try {
|
|
67
|
+
normalized = new URL(`http://${raw}`).hostname.toLowerCase().replace(/\.$/u, '');
|
|
68
|
+
}
|
|
69
|
+
catch { /* fail below */ }
|
|
70
|
+
if (!DOMAIN_RE.test(normalized))
|
|
71
|
+
throw new TypeError('A canonical domain is required for a claim passport.');
|
|
72
|
+
return normalized;
|
|
73
|
+
}
|
|
74
|
+
function ipv4Target(value) {
|
|
75
|
+
const raw = exactText(value, 64);
|
|
76
|
+
const parts = raw.split('.');
|
|
77
|
+
if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/u.test(part) || Number(part) > 255 || String(Number(part)) !== part)) {
|
|
78
|
+
throw new TypeError('A canonical IPv4 address is required for a claim passport.');
|
|
79
|
+
}
|
|
80
|
+
return raw;
|
|
81
|
+
}
|
|
82
|
+
function ipv6Target(value) {
|
|
83
|
+
const raw = exactText(value, 64).toLowerCase();
|
|
84
|
+
if (!raw || /[\[\]%/?#@]/u.test(raw))
|
|
85
|
+
throw new TypeError('A canonical IPv6 address is required for a claim passport.');
|
|
86
|
+
try {
|
|
87
|
+
const hostname = new URL(`http://[${raw}]/`).hostname;
|
|
88
|
+
const normalized = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
|
|
89
|
+
if (!normalized.includes(':'))
|
|
90
|
+
throw new TypeError();
|
|
91
|
+
return normalized;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
throw new TypeError('A canonical IPv6 address is required for a claim passport.');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function asnTarget(value) {
|
|
98
|
+
const raw = exactText(value, 32).toUpperCase().replace(/^AS/u, '');
|
|
99
|
+
if (!/^\d{1,10}$/u.test(raw) || Number(raw) < 1 || Number(raw) > 4_294_967_295) {
|
|
100
|
+
throw new TypeError('A canonical ASN is required for a claim passport.');
|
|
101
|
+
}
|
|
102
|
+
return `AS${Number(raw)}`;
|
|
103
|
+
}
|
|
104
|
+
function targetValue(type, value) {
|
|
105
|
+
if (type === 'domain')
|
|
106
|
+
return domainTarget(value);
|
|
107
|
+
if (type === 'ipv4')
|
|
108
|
+
return ipv4Target(value);
|
|
109
|
+
if (type === 'ipv6')
|
|
110
|
+
return ipv6Target(value);
|
|
111
|
+
return asnTarget(value);
|
|
112
|
+
}
|
|
113
|
+
function requirementId(value) {
|
|
114
|
+
if (typeof value !== 'string' || !REQUIREMENT_IDS.has(value)) {
|
|
115
|
+
throw new TypeError('The selected claim contains an unsupported evidence requirement.');
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
export async function buildLookupClaimPassport(input) {
|
|
120
|
+
if (input.readiness.version !== LOOKUP_CLAIM_READINESS_VERSION) {
|
|
121
|
+
throw new TypeError('The claim-readiness contract is not supported for passport export.');
|
|
122
|
+
}
|
|
123
|
+
if (input.readiness.entries.length > LOOKUP_CLAIM_IDS.length) {
|
|
124
|
+
throw new TypeError('The claim-readiness contract contains too many entries for passport export.');
|
|
125
|
+
}
|
|
126
|
+
const rawClaimId = exactText(input.claimId, 80);
|
|
127
|
+
if (!CLAIM_IDS.has(rawClaimId))
|
|
128
|
+
throw new TypeError('Select a current Lookup claim to export.');
|
|
129
|
+
const claimId = rawClaimId;
|
|
130
|
+
const selected = input.readiness.entries.find((entry) => entry.id === claimId);
|
|
131
|
+
if (!selected || !CLAIM_STATES.has(selected.state))
|
|
132
|
+
throw new TypeError('Select a current Lookup claim to export.');
|
|
133
|
+
if (selected.requirements.length < 1 || selected.requirements.length > MAX_LOOKUP_CLAIM_PASSPORT_REQUIREMENTS) {
|
|
134
|
+
throw new TypeError('The selected claim has an unsupported evidence-requirement count.');
|
|
135
|
+
}
|
|
136
|
+
const type = targetType(input.targetType);
|
|
137
|
+
const value = targetValue(type, input.target);
|
|
138
|
+
const generatedAt = timestamp(input.generatedAt) ?? new Date().toISOString();
|
|
139
|
+
const applicationVersion = exactText(input.applicationVersion, 80);
|
|
140
|
+
if (!SEMVER_RE.test(applicationVersion))
|
|
141
|
+
throw new TypeError('A valid application version is required for a claim passport.');
|
|
142
|
+
const lookupDepth = input.lookupDepth === 'deep' ? 'deep' : input.lookupDepth === 'fast' ? 'fast' : null;
|
|
143
|
+
if (!lookupDepth)
|
|
144
|
+
throw new TypeError('A completed Lookup depth is required for a claim passport.');
|
|
145
|
+
const requirementIds = new Set();
|
|
146
|
+
const requirements = selected.requirements.map((source) => {
|
|
147
|
+
const id = requirementId(source.id);
|
|
148
|
+
if (requirementIds.has(id) || !SOURCE_STATES.has(source.state)) {
|
|
149
|
+
throw new TypeError('The selected claim contains duplicate or invalid evidence requirements.');
|
|
150
|
+
}
|
|
151
|
+
requirementIds.add(id);
|
|
152
|
+
let evidenceId = null;
|
|
153
|
+
if (source.evidenceId !== null) {
|
|
154
|
+
const normalizedEvidenceId = exactText(source.evidenceId, 64);
|
|
155
|
+
if (!/^[a-z][a-z0-9_-]{0,63}$/u.test(normalizedEvidenceId))
|
|
156
|
+
throw new TypeError('The selected claim contains an invalid evidence source identifier.');
|
|
157
|
+
evidenceId = normalizedEvidenceId;
|
|
158
|
+
}
|
|
159
|
+
if (source.mode !== 'network_collection' && source.mode !== 'local_review') {
|
|
160
|
+
throw new TypeError('The selected claim contains an invalid evidence requirement mode.');
|
|
161
|
+
}
|
|
162
|
+
return Object.freeze({
|
|
163
|
+
id,
|
|
164
|
+
label: exactText(source.label, 160),
|
|
165
|
+
evidenceId,
|
|
166
|
+
mode: source.mode,
|
|
167
|
+
state: source.state,
|
|
168
|
+
observedAt: evidenceId ? timestamp(input.evidenceObservedAtById?.[evidenceId]) : null,
|
|
169
|
+
limitations: Object.freeze(uniqueText(source.limitations, 8)),
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
if (requirements.some((item) => !item.label))
|
|
173
|
+
throw new TypeError('The selected claim contains an invalid evidence label.');
|
|
174
|
+
const requiredEvidenceIds = requirements.map((item) => item.id);
|
|
175
|
+
const missingEvidenceIds = requirements.filter((item) => item.state !== 'complete').map((item) => item.id);
|
|
176
|
+
const riskModelVersion = Number.isSafeInteger(input.riskModelVersion)
|
|
177
|
+
&& Number(input.riskModelVersion) >= 1
|
|
178
|
+
&& Number(input.riskModelVersion) <= 1_000
|
|
179
|
+
? Number(input.riskModelVersion)
|
|
180
|
+
: null;
|
|
181
|
+
const claimLabel = exactText(selected.label, 160);
|
|
182
|
+
const claimConclusion = exactText(selected.conclusion, 600);
|
|
183
|
+
const unsigned = {
|
|
184
|
+
schema: LOOKUP_CLAIM_PASSPORT_SCHEMA,
|
|
185
|
+
version: LOOKUP_CLAIM_PASSPORT_VERSION,
|
|
186
|
+
generatedAt,
|
|
187
|
+
application: { name: 'WHOISleuth', version: applicationVersion },
|
|
188
|
+
target: { type, value },
|
|
189
|
+
observation: { observedAt: timestamp(input.observedAt), lookupDepth },
|
|
190
|
+
claim: {
|
|
191
|
+
id: claimId,
|
|
192
|
+
label: claimLabel,
|
|
193
|
+
state: selected.state,
|
|
194
|
+
conclusion: claimConclusion,
|
|
195
|
+
requiredEvidenceIds,
|
|
196
|
+
missingEvidenceIds,
|
|
197
|
+
requirements,
|
|
198
|
+
limitations: uniqueText(selected.limitations),
|
|
199
|
+
},
|
|
200
|
+
models: {
|
|
201
|
+
claimReadiness: LOOKUP_CLAIM_READINESS_VERSION,
|
|
202
|
+
risk: riskModelVersion,
|
|
203
|
+
},
|
|
204
|
+
limitations: uniqueText([
|
|
205
|
+
input.readiness.limitation,
|
|
206
|
+
'This portable passport records evidence sufficiency for one point-in-time statement. It does not establish that the statement is true, current, safe, authorized, or attributable to an owner or actor.',
|
|
207
|
+
'The SHA-256 digest detects changes to this file. It does not authenticate the analyst, collection source, signer, or truth of the retained states.',
|
|
208
|
+
'Raw registry responses, contacts, page values, request paths, credentials, and browser-local records are excluded.',
|
|
209
|
+
]),
|
|
210
|
+
};
|
|
211
|
+
if (!unsigned.claim.label || !unsigned.claim.conclusion)
|
|
212
|
+
throw new TypeError('The selected claim is incomplete.');
|
|
213
|
+
const digestSha256 = await sha256ArtifactDigestV2(unsigned);
|
|
214
|
+
const document = Object.freeze({
|
|
215
|
+
...unsigned,
|
|
216
|
+
claim: Object.freeze({
|
|
217
|
+
...unsigned.claim,
|
|
218
|
+
requiredEvidenceIds: Object.freeze(requiredEvidenceIds),
|
|
219
|
+
missingEvidenceIds: Object.freeze(missingEvidenceIds),
|
|
220
|
+
requirements: Object.freeze(requirements),
|
|
221
|
+
limitations: Object.freeze(unsigned.claim.limitations),
|
|
222
|
+
}),
|
|
223
|
+
limitations: Object.freeze(unsigned.limitations),
|
|
224
|
+
integrity: Object.freeze({ algorithm: 'SHA-256', canonicalization: SORTED_JSON_V2, digestSha256 }),
|
|
225
|
+
});
|
|
226
|
+
const content = `${JSON.stringify(document, null, 2)}\n`;
|
|
227
|
+
if (new TextEncoder().encode(content).byteLength > MAX_LOOKUP_CLAIM_PASSPORT_BYTES) {
|
|
228
|
+
throw new TypeError('The claim passport exceeds its export byte limit.');
|
|
229
|
+
}
|
|
230
|
+
const safeTarget = value.toLowerCase().replace(/[^a-z0-9.-]+/gu, '-').replace(/^-|-$/gu, '').slice(0, 100) || 'target';
|
|
231
|
+
return Object.freeze({
|
|
232
|
+
document,
|
|
233
|
+
content,
|
|
234
|
+
filename: `whoisleuth-claim-${safeTarget}-${claimId}-${generatedAt.slice(0, 10)}.json`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
@@ -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',
|
|
@@ -275,7 +290,7 @@ function identityEntries(input) {
|
|
|
275
290
|
title: 'The declared canonical origin differs from the observed final origin',
|
|
276
291
|
detail: `${finalHost} served the page while the document declared ${canonicalHost} as canonical.`,
|
|
277
292
|
sources: ['HTTP', 'HTML'],
|
|
278
|
-
href: '#evidence-page
|
|
293
|
+
href: '#evidence-page',
|
|
279
294
|
});
|
|
280
295
|
}
|
|
281
296
|
if (finalHost && openGraphHost && !sameRegistrableScope(finalHost, openGraphHost, registrableDomain)) {
|
|
@@ -286,7 +301,7 @@ function identityEntries(input) {
|
|
|
286
301
|
title: 'The Open Graph URL points outside the observed final origin',
|
|
287
302
|
detail: `${finalHost} served the page while Open Graph metadata declared ${openGraphHost}. Publisher metadata is a claim, not identity proof.`,
|
|
288
303
|
sources: ['HTTP', 'HTML'],
|
|
289
|
-
href: '#evidence-page
|
|
304
|
+
href: '#evidence-page',
|
|
290
305
|
});
|
|
291
306
|
}
|
|
292
307
|
return output;
|
|
@@ -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)
|