@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
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { normalizeDomain } from "./case-model.js";
|
|
2
2
|
import { parse } from 'tldts';
|
|
3
3
|
export const INVESTIGATION_GUIDE_SCHEMA = 'whoisleuth.investigation-recipe';
|
|
4
|
-
export const INVESTIGATION_GUIDE_VERSION =
|
|
4
|
+
export const INVESTIGATION_GUIDE_VERSION = 5;
|
|
5
5
|
export const INVESTIGATION_GUIDE_LEGACY_VERSION = 1;
|
|
6
|
-
export const INVESTIGATION_GUIDE_EXPORT_VERSION =
|
|
7
|
-
export const INVESTIGATION_GUIDE_SUPPORTED_VERSIONS = [2, 3, INVESTIGATION_GUIDE_VERSION];
|
|
6
|
+
export const INVESTIGATION_GUIDE_EXPORT_VERSION = 4;
|
|
7
|
+
export const INVESTIGATION_GUIDE_SUPPORTED_VERSIONS = [2, 3, 4, INVESTIGATION_GUIDE_VERSION];
|
|
8
8
|
export const INVESTIGATION_GUIDE_EXPORT_SCHEMA = 'whoisleuth.investigation-recipe-summary';
|
|
9
9
|
export const MAX_INVESTIGATION_GUIDE_DOMAIN_LENGTH = 253;
|
|
10
10
|
export const MAX_INVESTIGATION_GUIDE_REVIEW_DOMAINS = 25;
|
|
@@ -16,6 +16,7 @@ const CONTROL_RE = /[\x00-\x1f\x7f]/u;
|
|
|
16
16
|
const SAFE_TEMPLATE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u;
|
|
17
17
|
const GUIDE_STATUSES = new Set(['active', 'paused']);
|
|
18
18
|
const GUIDE_OUTCOMES = new Set(['pending', 'complete', 'partial', 'skipped']);
|
|
19
|
+
const PRE_RESPONSE_RECIPE_IDS = new Set(['brand_sweep', 'infrastructure_pivot', 'new_domain_triage']);
|
|
19
20
|
function stage(id, workspace, label, detail, expectedEvidence, requestImpact, prerequisite, completionCriteria, instructions, requiresApproval) {
|
|
20
21
|
return Object.freeze({
|
|
21
22
|
id,
|
|
@@ -91,6 +92,39 @@ export const INVESTIGATION_RECIPES = Object.freeze([
|
|
|
91
92
|
stage('monitor', 'monitor', 'Record disposition', 'Review the bounded domain set carried from Bulk and create or update only the cases that need an analyst decision or follow-up plan.', 'A bounded review queue plus case records with dispositions, notes, evidence history, and optional monitoring intent.', 'Local case editing makes no request. Rescans and hosted-monitor changes remain separate explicit actions.', 'Review source provenance, limitations, and any scoring explanation before deciding.', 'The analyst records the required dispositions or explicitly skips retention; the recipe never decides automatically.', ['Review the domains carried from Bulk and open only the cases that need retention.', 'Record a disposition and any concise evidence-based follow-up for each retained case.', 'Mark the step reviewed when the retained cases reflect your decision.'], false),
|
|
92
93
|
]),
|
|
93
94
|
}),
|
|
95
|
+
Object.freeze({
|
|
96
|
+
id: 'credential_impersonation_response',
|
|
97
|
+
label: 'Credential impersonation response',
|
|
98
|
+
summary: 'Review observed impersonation evidence, confirm the affected brand boundary, and prepare a local response packet from explicitly recorded routes.',
|
|
99
|
+
targetLabel: 'Domain under review',
|
|
100
|
+
stages: Object.freeze([
|
|
101
|
+
stage('lookup', 'lookup', 'Review impersonation evidence', 'Collect separately attributed deep evidence for the domain without treating visual or naming similarity as attribution.', 'Current registry, DNS, certificate, HTTP, page-identity, and source-health observations where supported.', 'Deep Lookup can contact several public services within explicit deadlines and response caps.', 'Confirm the target is in scope and do not enter a credential-bearing URL or secret.', 'Observed facts and missing sources are reviewed without making an ownership, intent, or harm conclusion.', ['Run the pre-filled Lookup in Deep mode.', 'Review exact page, redirect, certificate, and registration observations with their source states.', 'Create or update a Case only when the retained observations need reviewed follow-up.'], true),
|
|
102
|
+
stage('brands', 'brands', 'Confirm affected brand boundary', 'Review the saved Brand Profile that defines official and allowlisted references; do not infer an association from similarity.', 'An analyst-selected Brand Profile boundary, or an explicit decision to continue without one.', 'Local-only. Opening Brands makes no analysis request.', 'Know which saved profile, if any, is relevant to the affected party.', 'The relevant profile boundary is reviewed, or the stage is marked partial or skipped without creating an association.', ['Open the saved Brand Profiles list.', 'Review the official references and allowlists for the affected party.', 'Return to the guide without adding a profile association unless you deliberately selected one in Monitor.'], false),
|
|
103
|
+
stage('monitor', 'monitor', 'Prepare reviewed response', 'Open the Case response workspace, record only analyst-selected published recipient routes, and review packet preflight.', 'A Case action with recipient provenance and limitations plus an optional local draft packet that remains unsubmitted.', 'Local Case edits and packet generation make no request. WHOISleuth never sends the packet.', 'Retain supporting facts and reasoning before recording an external route; confirm the route manually.', 'The reviewed action and packet preflight are recorded, or the stage is partial or skipped without implying submission.', ['Open or create the pre-filled Case, then review its evidence and reasoning.', 'Record an analyst-selected published route with its source and limitations; do not claim it is reachable.', 'Open response preflight and prepare a local draft only if its required facts are complete.'], false),
|
|
104
|
+
]),
|
|
105
|
+
}),
|
|
106
|
+
Object.freeze({
|
|
107
|
+
id: 'mail_abuse_response',
|
|
108
|
+
label: 'Mail abuse response',
|
|
109
|
+
summary: 'Review official mail posture and observed domain evidence before preparing a source-qualified, unsent response packet.',
|
|
110
|
+
targetLabel: 'Domain under review',
|
|
111
|
+
stages: Object.freeze([
|
|
112
|
+
stage('brands', 'brands', 'Review official mail posture', 'Review the affected Brand Profile and retained mail posture without treating a missing control as proof of abuse.', 'Analyst-selected official-domain, mail, and Brand Profile context with explicit availability states.', 'Local-only unless the analyst separately starts a Brand posture refresh.', 'Identify the affected profile and distinguish official infrastructure from the domain under review.', 'Relevant retained posture is reviewed, or unavailable context is marked partial rather than absent.', ['Open the saved Brand Profiles list.', 'Review official MX, SPF, DMARC, and approved-domain context where retained.', 'Do not refresh or change a profile unless that separate action is necessary and approved.'], false),
|
|
113
|
+
stage('lookup', 'lookup', 'Review domain and mail evidence', 'Collect current deep evidence for the domain and keep DNS, mail, registration, and page observations separately attributed.', 'Authority-aware availability plus bounded DNS, mail, TLS, HTTP, and page observations where supported.', 'Deep Lookup can contact several public services within explicit deadlines and response caps.', 'Confirm the domain is in scope; email content and mailbox data are outside this workflow.', 'Current observations and source failures are reviewed without claiming message origin, sender control, or maliciousness.', ['Run the pre-filled Lookup in Deep mode.', 'Review MX, SPF, DMARC, registration, certificate, HTTP, and page source states.', 'Retain only the evidence needed for a reviewed Case.'], true),
|
|
114
|
+
stage('monitor', 'monitor', 'Prepare mail-abuse response', 'Record a reviewed recipient route and use Case packet preflight without sending or claiming delivery.', 'A bounded Case action and optional local response draft with exact recipient provenance and limitations.', 'Local Case edits and packet generation make no request. WHOISleuth never sends the packet.', 'Confirm the intended recipient and retain the observed facts and limitations that support review.', 'The current action state and packet preflight are recorded, or the stage remains partial without a delivery conclusion.', ['Open or create the pre-filled Case and review its retained evidence.', 'Record only a deliberately selected published route, its source, and known limitations.', 'Use the response preflight to prepare a local draft; submission remains a separate manual action recorded later.'], false),
|
|
115
|
+
]),
|
|
116
|
+
}),
|
|
117
|
+
Object.freeze({
|
|
118
|
+
id: 'domain_control_change_response',
|
|
119
|
+
label: 'Domain-control change response',
|
|
120
|
+
summary: 'Review retained control posture and current authority-aware evidence before documenting a bounded internal or provider response.',
|
|
121
|
+
targetLabel: 'Official domain',
|
|
122
|
+
stages: Object.freeze([
|
|
123
|
+
stage('brands', 'brands', 'Review retained control posture', 'Review the saved domain-control passport, posture matrix, and source limitations before interpreting a change.', 'Retained official-domain control observations with separately attributed registry, DNS, certificate, mail, and web states.', 'Local-only unless the analyst separately starts a posture refresh.', 'Confirm the domain belongs to the reviewed Brand Profile; a saved association is analyst-selected, not inferred.', 'The retained baseline and its completeness are understood, or the stage is partial when required context is unavailable.', ['Open the relevant saved Brand Profile.', 'Review the domain-control passport, portfolio matrix, and observation times.', 'Identify which source actually supports the suspected change and preserve unavailable states.'], false),
|
|
124
|
+
stage('lookup', 'lookup', 'Confirm current domain evidence', 'Collect a deliberate deep Lookup so current authority-aware evidence remains distinct from the retained baseline.', 'Current registry, registrar, DNS, certificate, HTTP, and page observations with explicit source health.', 'Deep Lookup can contact several public services within explicit deadlines and response caps.', 'Confirm a fresh collection is necessary and review its request implications.', 'The current result is reviewed against the retained baseline without converting incomplete evidence into removal or compromise.', ['Run the pre-filled Lookup in Deep mode.', 'Compare current observations with the retained control evidence source by source.', 'Record only facts and limitations that need a Case response.'], true),
|
|
125
|
+
stage('monitor', 'monitor', 'Document control-change response', 'Record an internal or provider action and review an optional response packet without automatic enforcement or submission.', 'A bounded Case action, due or follow-up dates, recipient provenance when external, and an optional unsubmitted packet.', 'Local Case edits and packet generation make no request. WHOISleuth never changes DNS, registrar, hosting, mail, or defensive systems.', 'Document supporting evidence and confirm any recipient route or internal owner manually.', 'The reviewed action state is recorded, or the stage is partial without a compromise, delivery, or remediation claim.', ['Open or create the pre-filled Case and retain the relevant comparison facts.', 'Record the internal owner or analyst-selected provider route, source, limitations, and follow-up dates.', 'Review packet preflight when a draft is useful; record submission or resolution only after it actually occurs.'], false),
|
|
126
|
+
]),
|
|
127
|
+
}),
|
|
94
128
|
]);
|
|
95
129
|
function record(value) {
|
|
96
130
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
@@ -239,10 +273,15 @@ export function investigationGuideHref(stageId, domain, recipeId = 'new_domain_t
|
|
|
239
273
|
return `/discover?q=${encodeURIComponent(registrableGuideDomain(normalized))}#discovery-seed`;
|
|
240
274
|
if (stageDefinition.workspace === 'bulk')
|
|
241
275
|
return `/bulk?investigation=${encodeURIComponent(normalized)}#domains`;
|
|
242
|
-
if (stageDefinition.workspace === 'monitor')
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
276
|
+
if (stageDefinition.workspace === 'monitor') {
|
|
277
|
+
const response = typeof recipeId === 'string' && recipeId.endsWith('_response') ? '&response=1' : '';
|
|
278
|
+
return `/monitor?view=cases&investigation=1${response}&domain=${encodeURIComponent(workingDomain)}#case-review-queue`;
|
|
279
|
+
}
|
|
280
|
+
if (stageDefinition.workspace === 'brands') {
|
|
281
|
+
return typeof recipeId === 'string' && recipeId.endsWith('_response')
|
|
282
|
+
? '/brands'
|
|
283
|
+
: `/brands?new=1&domain=${encodeURIComponent(normalized)}#official-domains`;
|
|
284
|
+
}
|
|
246
285
|
return stageDefinition.path;
|
|
247
286
|
}
|
|
248
287
|
export function investigationGuideApprovedHref(guide, stageId) {
|
|
@@ -324,9 +363,10 @@ export function parseInvestigationGuide(value) {
|
|
|
324
363
|
const domain = normalizeInvestigationGuideDomain(input.domain);
|
|
325
364
|
const createdAt = timestamp(input.createdAt);
|
|
326
365
|
const updatedAt = timestamp(input.updatedAt);
|
|
327
|
-
if (!recipe || !domain || !createdAt || !updatedAt
|
|
366
|
+
if (!recipe || !domain || !createdAt || !updatedAt
|
|
367
|
+
|| (input.version !== INVESTIGATION_GUIDE_VERSION && !PRE_RESPONSE_RECIPE_IDS.has(recipe.id)))
|
|
328
368
|
return null;
|
|
329
|
-
const supportsTemplate = input.version === 3 || input.version === INVESTIGATION_GUIDE_VERSION;
|
|
369
|
+
const supportsTemplate = input.version === 3 || input.version === 4 || input.version === INVESTIGATION_GUIDE_VERSION;
|
|
330
370
|
const template = supportsTemplate && input.template !== null && input.template !== undefined
|
|
331
371
|
? normalizeInvestigationGuideTemplateSnapshot(input.template, recipe.id)
|
|
332
372
|
: null;
|
|
@@ -361,7 +401,9 @@ export function parseInvestigationGuide(value) {
|
|
|
361
401
|
outcome: guideOutcome(item?.outcome),
|
|
362
402
|
approvedAt: nullableTimestamp(item?.approvedAt),
|
|
363
403
|
openedAt: nullableTimestamp(item?.openedAt),
|
|
364
|
-
reviewNote: input.version ===
|
|
404
|
+
reviewNote: input.version === 4 || input.version === INVESTIGATION_GUIDE_VERSION
|
|
405
|
+
? boundedReviewNote(item?.reviewNote)
|
|
406
|
+
: null,
|
|
365
407
|
updatedAt: timestamp(item?.updatedAt) || updatedAt,
|
|
366
408
|
};
|
|
367
409
|
}),
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { INVESTIGATION_RECIPES, normalizeInvestigationGuideTemplateSnapshot, } from "./investigation-guide.js";
|
|
2
2
|
export const INVESTIGATION_TEMPLATE_SCHEMA = 'whoisleuth.investigation-templates';
|
|
3
|
-
export const INVESTIGATION_TEMPLATE_VERSION =
|
|
3
|
+
export const INVESTIGATION_TEMPLATE_VERSION = 2;
|
|
4
|
+
export const INVESTIGATION_TEMPLATE_SUPPORTED_VERSIONS = [1, INVESTIGATION_TEMPLATE_VERSION];
|
|
4
5
|
export const MAX_INVESTIGATION_TEMPLATES = 20;
|
|
5
6
|
export const MAX_INVESTIGATION_TEMPLATE_STORE_BYTES = 256 * 1024;
|
|
6
7
|
export const MAX_INVESTIGATION_TEMPLATE_IMPORT_BYTES = 384 * 1024;
|
|
7
8
|
const CONTROL_RE = /[\x00-\x1f\x7f]/u;
|
|
9
|
+
const LEGACY_RECIPE_IDS = new Set(['brand_sweep', 'infrastructure_pivot', 'new_domain_triage']);
|
|
8
10
|
function record(value) {
|
|
9
11
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
10
12
|
}
|
|
@@ -17,12 +19,12 @@ function timestamp(value) {
|
|
|
17
19
|
function byteLength(value) {
|
|
18
20
|
return new TextEncoder().encode(value).byteLength;
|
|
19
21
|
}
|
|
20
|
-
function template(raw) {
|
|
22
|
+
function template(raw, sourceVersion = INVESTIGATION_TEMPLATE_VERSION) {
|
|
21
23
|
const value = record(raw);
|
|
22
24
|
const snapshot = normalizeInvestigationGuideTemplateSnapshot(value);
|
|
23
25
|
const createdAt = timestamp(value?.createdAt);
|
|
24
26
|
const updatedAt = timestamp(value?.updatedAt);
|
|
25
|
-
return snapshot && createdAt && updatedAt
|
|
27
|
+
return snapshot && createdAt && updatedAt && (sourceVersion !== 1 || LEGACY_RECIPE_IDS.has(snapshot.recipeId))
|
|
26
28
|
? { ...snapshot, createdAt, updatedAt }
|
|
27
29
|
: null;
|
|
28
30
|
}
|
|
@@ -35,11 +37,12 @@ export function normalizeInvestigationTemplateStore(raw) {
|
|
|
35
37
|
&& Number(value.version) > INVESTIGATION_TEMPLATE_VERSION) {
|
|
36
38
|
throw new Error('This investigation-template collection uses a newer schema and cannot be read safely.');
|
|
37
39
|
}
|
|
40
|
+
const sourceVersion = Array.isArray(raw) ? INVESTIGATION_TEMPLATE_VERSION : Number(value?.version) || INVESTIGATION_TEMPLATE_VERSION;
|
|
38
41
|
const source = Array.isArray(raw) ? raw : Array.isArray(value?.templates) ? value.templates : [];
|
|
39
42
|
const templates = [];
|
|
40
43
|
const seen = new Set();
|
|
41
44
|
for (const candidate of source.slice(0, MAX_INVESTIGATION_TEMPLATES * 2)) {
|
|
42
|
-
const normalized = template(candidate);
|
|
45
|
+
const normalized = template(candidate, sourceVersion);
|
|
43
46
|
if (!normalized || seen.has(normalized.id))
|
|
44
47
|
continue;
|
|
45
48
|
seen.add(normalized.id);
|
|
@@ -115,8 +118,8 @@ function strictImport(raw) {
|
|
|
115
118
|
if (Number(value.version) > INVESTIGATION_TEMPLATE_VERSION) {
|
|
116
119
|
throw new Error('This investigation-template export uses a newer schema and cannot be imported safely.');
|
|
117
120
|
}
|
|
118
|
-
if (value.version
|
|
119
|
-
throw new Error(`Expected investigation-template schema ${INVESTIGATION_TEMPLATE_VERSION}.`);
|
|
121
|
+
if (!INVESTIGATION_TEMPLATE_SUPPORTED_VERSIONS.includes(Number(value.version))) {
|
|
122
|
+
throw new Error(`Expected investigation-template schema 1 or ${INVESTIGATION_TEMPLATE_VERSION}.`);
|
|
120
123
|
}
|
|
121
124
|
return normalizeInvestigationTemplateStore(value).templates;
|
|
122
125
|
}
|
|
@@ -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
|
+
}
|