@slicedearth/whoisleuth-cli 2.6.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DISCLOSURE +8 -5
- package/NOTICE +3 -2
- package/README.md +60 -67
- package/cli/artifact-validation/case-response.mjs +6 -857
- package/cli/artifact-validation/lookup-evidence.mjs +15 -2
- package/cli/artifact-verify.mjs +32 -1
- package/cli/case-command.mjs +17 -17
- package/cli/case-pack.mjs +7 -1
- package/cli/ci-report.mjs +17 -5
- package/cli/command-definition.mjs +5 -5
- package/cli/doctor.mjs +37 -3
- package/cli/formatters/html.mjs +3 -0
- package/cli/formatters/json.mjs +8 -1
- package/cli/formatters/terminal-lookup.mjs +24 -1
- package/cli/history-command-definitions.mjs +1 -1
- package/cli/investigation-plan.mjs +15 -0
- package/cli/lookup-brief.mjs +11 -5
- package/cli/lookup-command-runner.mjs +20 -13
- package/cli/review-command-definitions.mjs +11 -11
- package/cli/saved-lookup.mjs +22 -7
- package/cli/source-reliability.mjs +12 -10
- package/cli/support-arguments.mjs +1 -1
- package/cli/support-command-definitions.mjs +5 -4
- package/cli/support-command-runner.mjs +6 -4
- package/cli/workflow-command-definitions.mjs +2 -1
- package/docs/cli-reference.md +49 -11
- package/docs/cli.md +109 -32
- package/docs/contextual-reviews.md +10 -0
- package/docs/infrastructure-observations.md +9 -0
- package/lib/dmarc-discovery.mjs +4 -2
- package/lib/dns-inheritance-review.mjs +14 -1
- package/lib/evidence-export.mjs +10 -4
- package/lib/evidence-report.mjs +2 -1
- package/lib/generated/cisa-kev-catalog.mjs +17 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +178 -5
- package/lib/idn-confusables.mjs +5 -1
- package/lib/lookup-collection-receipt.mjs +52 -0
- package/lib/lookup-diagnostics.mjs +2 -12
- package/lib/lookup-progress.mjs +29 -31
- package/lib/lookup-response-contract.mjs +9 -0
- package/lib/lookup-response.mjs +15 -14
- package/lib/lookup-source-progress.mjs +4 -2
- package/lib/lookup.mjs +33 -2
- package/lib/rdap-capabilities.mjs +43 -9
- package/lib/registry-insights.mjs +17 -6
- package/lib/sslbl-certificates.generated.mjs +143 -142
- package/lib/whois-authority.mjs +56 -1
- package/lib/whois-chain.mjs +2 -1
- package/lib/whois-contracts.mjs +2 -0
- package/lib/whois-normalization.mjs +4 -4
- package/package.json +6 -6
- package/packages/analysis/bounded-json.mjs +12 -2
- package/packages/cases/case-attachment-model.mjs +36 -1
- package/packages/cases/case-checkpoint-receipts.mjs +68 -0
- package/packages/cases/case-evidence-links.mjs +28 -0
- package/packages/cases/case-evidence-model.mjs +39 -6
- package/packages/cases/case-migration-model.mjs +9 -1
- package/packages/cases/case-object-coverage.mjs +106 -20
- package/packages/cases/case-record-operations.mjs +13 -0
- package/packages/cases/case-record-projection.mjs +28 -2
- package/packages/cases/case-report.mjs +5 -4
- package/packages/cases/case-response-packet-validation.mjs +880 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
- package/packages/cases/case-response-packet.mjs +2 -15
- package/packages/cases/case-response-review-inputs.mjs +18 -1
- package/packages/cases/case-type-presentation.mjs +10 -0
- package/packages/cases/case-workflow-metadata.mjs +0 -8
- package/packages/comparison/capture-context.mjs +3 -2
- package/packages/contracts/analyst-review-state-contract.mjs +1 -1
- package/packages/contracts/case-portability.mjs +59 -28
- package/packages/contracts/cli-doctor.mjs +4 -0
- package/packages/contracts/cli-lookup.mjs +57 -46
- package/packages/contracts/lookup-evidence.mjs +12 -2
- package/packages/contracts/registry-insights.mjs +6 -0
- package/packages/evidence/dmarc-policy-context.mjs +80 -0
- package/packages/evidence/lookup-collection-receipt.mjs +140 -0
- package/packages/evidence/lookup-collection-review.mjs +74 -0
- package/packages/interchange/external-findings-import.mjs +8 -4
- package/packages/investigation/bulk-mail-exposure.mjs +2 -2
- package/packages/investigation/domain-history-review.mjs +7 -1
- package/packages/workspace/brand-keyword-campaign.mjs +11 -1
- package/packages/workspace/bulk-session-model.mjs +8 -0
- package/third-party-notices.txt +29 -29
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
-
import { assertLookupEvidencePrivacySafeTree, assertLookupEvidencePortableTree, LOOKUP_EVIDENCE_SCHEMA, LOOKUP_EVIDENCE_SCHEMA_VERSION, HOSTNAME_SCOPED_LOOKUP_EVIDENCE_SCHEMA_VERSION, PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION, REGISTRAR_STANDING_LOOKUP_EVIDENCE_SCHEMA_VERSION, V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION, projectLookupEvidenceAvailability, projectLookupEvidenceAvailabilityPublic, projectLookupEvidenceQuery, projectLookupEvidenceRdapPublication, projectLookupEvidenceRegistryInsights, projectLookupEvidenceWhoisPublication, SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS, } from "../../lib/evidence-export.mjs";
|
|
2
|
+
import { assertLookupEvidencePrivacySafeTree, assertLookupEvidencePortableTree, LOOKUP_EVIDENCE_SCHEMA, LOOKUP_EVIDENCE_SCHEMA_VERSION, lookupEvidenceRegistryInsightsVersion, HOSTNAME_SCOPED_LOOKUP_EVIDENCE_SCHEMA_VERSION, PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION, REGISTRAR_STANDING_LOOKUP_EVIDENCE_SCHEMA_VERSION, V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION, projectLookupEvidenceAvailability, projectLookupEvidenceAvailabilityPublic, projectLookupEvidenceQuery, projectLookupEvidenceRdapPublication, projectLookupEvidenceRegistryInsights, projectLookupEvidenceWhoisPublication, SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS, } from "../../lib/evidence-export.mjs";
|
|
3
3
|
import { validLookupObservationScope } from "../../packages/evidence/lookup-target.mjs";
|
|
4
4
|
import { externalPasswordFormObservation } from "../../packages/evidence/credential-form-attribution.mjs";
|
|
5
5
|
import { credentialSurfaceContractState } from "../../lib/lookup-child-profile-contract.mjs";
|
|
6
6
|
import { registrarStandingObservedBy, resolveRegistrarIanaId, validRegistrarStanding, } from "../../lib/registrar-standing-contract.mjs";
|
|
7
7
|
import { compareRegistrySources } from "../../lib/registry-comparison.mjs";
|
|
8
8
|
import { buildRegistryInsights } from "../../lib/registry-insights.mjs";
|
|
9
|
+
import { resolveRdapCapabilityCatalogRevision } from "../../lib/rdap-capabilities.mjs";
|
|
9
10
|
import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "../../packages/analysis/project-metadata.mjs";
|
|
10
11
|
import { validHttpDeliveryMetadata, validPagePublicationMetadata, } from "../../lib/homepage-metadata-contract.mjs";
|
|
11
12
|
import { HEX_DIGEST_RE, SEMANTIC_VERSION_RE, absoluteUrl, array, boolean, domain, enumeration, exact, exactOptional, fail, integer, iso, nullableRecord, optionalText, record, strings, text, } from "./structure-primitives.mjs";
|
|
@@ -344,7 +345,7 @@ function validateLookupEvidenceWhois(value, version) {
|
|
|
344
345
|
optionalText(complete.conflictingHop, 'Lookup evidence WHOIS conflicting hop', 253);
|
|
345
346
|
nullableRecord(complete.parsed, 'Lookup evidence WHOIS parsed data');
|
|
346
347
|
if (version >= PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION
|
|
347
|
-
&& !isDeepStrictEqual(complete.parsed, projectLookupEvidenceWhoisPublication(complete.parsed))) {
|
|
348
|
+
&& !isDeepStrictEqual(complete.parsed, projectLookupEvidenceWhoisPublication(complete.parsed, version))) {
|
|
348
349
|
fail('Lookup evidence WHOIS portable publication');
|
|
349
350
|
}
|
|
350
351
|
array(complete.chain, 'Lookup evidence WHOIS chain', 16).forEach((item, index) => {
|
|
@@ -715,6 +716,15 @@ export function validateLookupEvidenceArtifactStructure(value) {
|
|
|
715
716
|
});
|
|
716
717
|
if (!isDeepStrictEqual(registryComparison, expectedComparison))
|
|
717
718
|
fail('Lookup evidence registry comparison derivation');
|
|
719
|
+
const capabilities = record(registryInsights.rdapCapabilities, 'Lookup evidence RDAP capabilities');
|
|
720
|
+
const registryCapabilities = record(capabilities.registry, 'Lookup evidence registry RDAP capabilities');
|
|
721
|
+
const registrarCapabilities = record(capabilities.registrar, 'Lookup evidence registrar RDAP capabilities');
|
|
722
|
+
const catalogReviewedAt = registryCapabilities.catalogReviewedAt;
|
|
723
|
+
if (typeof catalogReviewedAt !== 'string'
|
|
724
|
+
|| resolveRdapCapabilityCatalogRevision(catalogReviewedAt) === null
|
|
725
|
+
|| registrarCapabilities.catalogReviewedAt !== catalogReviewedAt) {
|
|
726
|
+
fail('Lookup evidence RDAP capability catalogue revision');
|
|
727
|
+
}
|
|
718
728
|
const rebuiltInsights = buildRegistryInsights({
|
|
719
729
|
rdapParsed,
|
|
720
730
|
rdapStatus: rdapDiagnosticStatus,
|
|
@@ -722,6 +732,9 @@ export function validateLookupEvidenceArtifactStructure(value) {
|
|
|
722
732
|
whoisParsed,
|
|
723
733
|
whoisStatus: whoisDiagnosticStatus,
|
|
724
734
|
whoisQueriedAt: whois.queriedAt,
|
|
735
|
+
}, {
|
|
736
|
+
rdapCapabilityCatalogReviewedAt: catalogReviewedAt,
|
|
737
|
+
version: lookupEvidenceRegistryInsightsVersion(version),
|
|
725
738
|
});
|
|
726
739
|
const expectedInsights = version >= PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION
|
|
727
740
|
? projectLookupEvidenceRegistryInsights(rebuiltInsights)
|
package/cli/artifact-verify.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
|
-
import { MANAGED_INDICATOR_SET_SCHEMA, MANAGED_INDICATOR_SET_VERSION } from "../packages/contracts/analyst-interchange.mjs";
|
|
2
|
+
import { MANAGED_INDICATOR_SET_SCHEMA, MANAGED_INDICATOR_SET_VERSION, MAX_MANAGED_INDICATOR_SET_BYTES } from "../packages/contracts/analyst-interchange.mjs";
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { readEditableCaseExport } from "../packages/cases/case-export-input.mjs";
|
|
5
5
|
import { boundedJsonLimitsForBytes, parseBoundedJsonObject } from "./bounded-json.mjs";
|
|
@@ -532,6 +532,34 @@ export async function verifyOfflineArtifact(raw, options = {}) {
|
|
|
532
532
|
: null;
|
|
533
533
|
return Object.freeze({ ...report, manifestIdentity });
|
|
534
534
|
}
|
|
535
|
+
function offlineArtifactNextSteps(report) {
|
|
536
|
+
switch (report.artifact.kind) {
|
|
537
|
+
case 'saved_lookup':
|
|
538
|
+
return ['Review retained source states without displaying target values:', ' whoisleuth source-report -- ./lookup.json'];
|
|
539
|
+
case 'case_export':
|
|
540
|
+
return ['Read the Case records, including private analyst content:', ' whoisleuth case show -- ./cases.json'];
|
|
541
|
+
case 'workspace_archive':
|
|
542
|
+
return ['Inspect section metadata without restoring the archive:', ' whoisleuth inspect-archive -- ./workspace.json'];
|
|
543
|
+
case 'encrypted_workspace_archive':
|
|
544
|
+
return report.checks.authenticatedEncryption === 'verified'
|
|
545
|
+
? ['Inspect decrypted section metadata without restoring the archive:', ' whoisleuth inspect-archive --passphrase-file ./passphrase.txt -- ./workspace.encrypted.json']
|
|
546
|
+
: ['Check the encrypted contents using a separate passphrase file:', ' whoisleuth verify-artifact --passphrase-file ./passphrase.txt -- ./workspace.encrypted.json'];
|
|
547
|
+
case 'lookup_evidence':
|
|
548
|
+
return ['Review which fields this portable format preserves and omits:', ' whoisleuth interchange-report -- ./evidence.json'];
|
|
549
|
+
case 'cli_case_pack':
|
|
550
|
+
return ['Review the Case-pack compatibility and field exclusions:', ' whoisleuth interchange-report -- ./case-pack.json'];
|
|
551
|
+
case 'signed_review_artifact':
|
|
552
|
+
if (report.artifact.schema === DOMAIN_CONTROL_MANIFEST_SCHEMA || report.artifact.schema === LOOKUP_CLAIM_PASSPORT_SCHEMA) {
|
|
553
|
+
return ['Review the passport compatibility and retained field groups:', ' whoisleuth interchange-report -- ./passport.json'];
|
|
554
|
+
}
|
|
555
|
+
return report.artifact.schema === MANAGED_INDICATOR_SET_SCHEMA
|
|
556
|
+
&& report.summary.inputBytes <= MAX_MANAGED_INDICATOR_SET_BYTES
|
|
557
|
+
? ['Read the retained indicator identities and review windows:', ' whoisleuth indicator-set inspect -- ./indicators.json']
|
|
558
|
+
: [];
|
|
559
|
+
default:
|
|
560
|
+
return [];
|
|
561
|
+
}
|
|
562
|
+
}
|
|
535
563
|
export function formatOfflineArtifactVerification(report) {
|
|
536
564
|
const lines = [
|
|
537
565
|
'WHOISleuth offline artefact verification',
|
|
@@ -611,5 +639,8 @@ export function formatOfflineArtifactVerification(report) {
|
|
|
611
639
|
}
|
|
612
640
|
for (const limitation of report.limitations)
|
|
613
641
|
lines.push(`Limitation: ${limitation}`);
|
|
642
|
+
const nextSteps = offlineArtifactNextSteps(report);
|
|
643
|
+
if (nextSteps.length)
|
|
644
|
+
lines.push('', 'Offline next steps (examples; replace filenames; nothing runs automatically):', ...nextSteps);
|
|
614
645
|
return `${lines.join('\n')}\n`;
|
|
615
646
|
}
|
package/cli/case-command.mjs
CHANGED
|
@@ -29,12 +29,12 @@ function prose(input, maximum, label) {
|
|
|
29
29
|
function selection(cases, args, required) {
|
|
30
30
|
const domain = args.domain === null ? null : normalizeDomain(args.domain);
|
|
31
31
|
if (args.domain !== null && !domain)
|
|
32
|
-
throw new CliUsageError('Select a valid
|
|
32
|
+
throw new CliUsageError('Select a valid case domain.');
|
|
33
33
|
const selected = cases.filter(record => (!args.caseId || record.id === args.caseId) && (!domain || record.domain === domain));
|
|
34
34
|
if ((args.caseId || domain || required) && selected.length === 0)
|
|
35
|
-
throw new CliUsageError('The selected
|
|
35
|
+
throw new CliUsageError('The selected case is not present in this file.');
|
|
36
36
|
if (required && selected.length !== 1)
|
|
37
|
-
throw new CliUsageError('Select exactly one
|
|
37
|
+
throw new CliUsageError('Select exactly one case with --case-id; no arbitrary case was chosen.');
|
|
38
38
|
return selected;
|
|
39
39
|
}
|
|
40
40
|
function pin(input, now) {
|
|
@@ -42,7 +42,7 @@ function pin(input, now) {
|
|
|
42
42
|
throw new CliUsageError('An evidence pin must be a JSON object.');
|
|
43
43
|
const raw = input;
|
|
44
44
|
if (Object.hasOwn(raw, 'id') || Object.hasOwn(raw, 'createdAt'))
|
|
45
|
-
throw new CliUsageError('New pin identity and creation time are allocated by the
|
|
45
|
+
throw new CliUsageError('New pin identity and creation time are allocated by the case operation.');
|
|
46
46
|
const normal = appendCaseEvidencePin([], raw, now)[0];
|
|
47
47
|
// Validate the supplied subset against the domain result, without duplicating
|
|
48
48
|
// its field inventory or accepting silent truncation/default substitution.
|
|
@@ -86,7 +86,7 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
|
|
|
86
86
|
? createCaseIncident(cases, { domain: args.domain, title }, now)
|
|
87
87
|
: openOrCreateCase(cases, { domain: args.domain, ...(title ? { title } : {}) }, now, args.caseId ? { caseId: args.caseId } : {});
|
|
88
88
|
if (!result.created && title && title !== result.record.title)
|
|
89
|
-
throw new CliUsageError('The selected
|
|
89
|
+
throw new CliUsageError('The selected case already exists with another title. Use --new-incident for a separate investigation.');
|
|
90
90
|
return result.cases;
|
|
91
91
|
}
|
|
92
92
|
const current = selection(cases, args, true)[0];
|
|
@@ -113,7 +113,7 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
|
|
|
113
113
|
result = updateCase(cases, current.id, { action: input }, now);
|
|
114
114
|
const action = result.record.actions.find(candidate => !current.actions.some(prior => prior.id === candidate.id));
|
|
115
115
|
if (!action)
|
|
116
|
-
throw new CliUsageError('No new action was retained within the
|
|
116
|
+
throw new CliUsageError('No new action was retained within the case bounds; nothing was written.');
|
|
117
117
|
for (const [key, value] of Object.entries(input))
|
|
118
118
|
if (canonicalArtifactJsonV2(key === 'responseObjects' ? readCaseResponseObjects(value) : value) !== canonicalArtifactJsonV2(action[key])) {
|
|
119
119
|
throw new CliUsageError(`Action field ${safeTerminalValue(key)} is not canonical; nothing was written.`);
|
|
@@ -142,7 +142,7 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
|
|
|
142
142
|
result = updateCase(cases, current.id, { assertion: { ...input, kind: 'next_step' } }, now);
|
|
143
143
|
const question = result.record.assertions.find(candidate => !current.assertions.some(prior => prior.id === candidate.id));
|
|
144
144
|
if (!question)
|
|
145
|
-
throw new CliUsageError('No new question was retained within the
|
|
145
|
+
throw new CliUsageError('No new question was retained within the case bounds; nothing was written.');
|
|
146
146
|
for (const [key, value] of Object.entries(input))
|
|
147
147
|
if (canonicalArtifactJsonV2(value) !== canonicalArtifactJsonV2(question[key])) {
|
|
148
148
|
throw new CliUsageError(`Question field ${safeTerminalValue(key)} is not canonical; nothing was written.`);
|
|
@@ -150,12 +150,12 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
|
|
|
150
150
|
}
|
|
151
151
|
else if (args.operation === 'close-object') {
|
|
152
152
|
if (!input?.responseObject)
|
|
153
|
-
throw new CliUsageError('Object closure requires one explicit responseObject; it cannot close the whole
|
|
153
|
+
throw new CliUsageError('Object closure requires one explicit responseObject; it cannot close the whole case.');
|
|
154
154
|
fields(input, ['responseObject', 'reason', 'summary', 'observedEffectReviewId', 'actionId', 'limitations'], 'Object closure');
|
|
155
155
|
result = updateCase(cases, current.id, { closure: input }, now);
|
|
156
156
|
const closure = result.record.closures.records.find(candidate => !current.closures.records.some(prior => prior.id === candidate.id));
|
|
157
157
|
if (!closure)
|
|
158
|
-
throw new CliUsageError('No new closure was retained within the
|
|
158
|
+
throw new CliUsageError('No new closure was retained within the case bounds; nothing was written.');
|
|
159
159
|
for (const [key, value] of Object.entries(input))
|
|
160
160
|
if (canonicalArtifactJsonV2(value) !== canonicalArtifactJsonV2(closure[key])) {
|
|
161
161
|
throw new CliUsageError(`Closure field ${safeTerminalValue(key)} is not canonical; nothing was written.`);
|
|
@@ -191,7 +191,7 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
|
|
|
191
191
|
fields(input, ['state', 'observedAt', 'completeness', 'comparisonSummary', 'source', 'followUpAt', 'limitations', 'collectionDepth', 'recheck', 'observationHostname', 'responseObject', 'objectOutcome'], 'Recheck');
|
|
192
192
|
const recheck = readCaseRecheckAnswerContext(input.recheck);
|
|
193
193
|
if (input.state === 'not_reproduced' && !recheck) {
|
|
194
|
-
throw new CliUsageError('Not reproduced requires a saved recheck question and confirmed comparable conditions. Import the planned
|
|
194
|
+
throw new CliUsageError('Not reproduced requires a saved recheck question and confirmed comparable conditions. Import the planned case or record unavailable for a limited observation.');
|
|
195
195
|
}
|
|
196
196
|
const summary = prose(input.comparisonSummary, MAX_RESPONSE_VALUE_LENGTH, 'Comparison summary');
|
|
197
197
|
result = recordCaseRecheckOutcome(cases, current.id, {
|
|
@@ -214,12 +214,12 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
|
|
|
214
214
|
}
|
|
215
215
|
}
|
|
216
216
|
else
|
|
217
|
-
throw new CliUsageError('Select a supported
|
|
217
|
+
throw new CliUsageError('Select a supported case mutation.');
|
|
218
218
|
retainExistingEntries(current, result.record);
|
|
219
219
|
return result.cases;
|
|
220
220
|
}
|
|
221
221
|
function formatCases(cases, digest) {
|
|
222
|
-
const lines = ['Local
|
|
222
|
+
const lines = ['Local case review', `File digest ${digest}`, `Cases ${cases.length}`, ''];
|
|
223
223
|
for (const record of cases) {
|
|
224
224
|
lines.push(`Case ${safeTerminalValue(record.id)} · ${safeTerminalValue(record.title || record.domain)}`, `Domain ${safeTerminalValue(record.domain)}`, `Status ${statusLabel(record.status)}`, `Disposition ${dispositionLabel(record.disposition)} (analyst assessment)`, `Updated ${record.updatedAt}`);
|
|
225
225
|
for (const note of record.notes)
|
|
@@ -246,16 +246,16 @@ function formatCases(cases, digest) {
|
|
|
246
246
|
lines.push(`Recheck ${safeTerminalValue(review.observedAt, 'time unavailable')} · ${review.state} · ${review.completeness} · ${safeTerminalValue(review.source)}`, ` Object: ${review.responseObject ? `${review.responseObject.kind}: ${safeTerminalValue(review.responseObject.identifier)}` : 'unknown binding'}${review.objectOutcome ? ` · observed ${review.objectOutcome}` : ''}`);
|
|
247
247
|
lines.push(`Other retained records: ${record.evidenceHistory.length} snapshots, ${record.actions.length} actions, ${record.assertions.length} assertions, ${record.attachments?.length ?? 0} file references.`, '');
|
|
248
248
|
}
|
|
249
|
-
lines.push('Use --json for the full
|
|
249
|
+
lines.push('Use --json for the full case export. Attached file bytes remain separate. This command makes no request.', '');
|
|
250
250
|
return lines.join('\n');
|
|
251
251
|
}
|
|
252
252
|
export async function runCaseCommand(args, dependencies, context) {
|
|
253
|
-
context.setFailureLabel('Local
|
|
253
|
+
context.setFailureLabel('Local case operation');
|
|
254
254
|
try {
|
|
255
255
|
const raw = args.source ? dependencies.caseFileInput ?? await context.readInput(args.source, MAX_EDITABLE_CASE_INPUT_BYTES, 'Case file') : null;
|
|
256
256
|
const digest = raw === null ? null : `sha256:${createHash('sha256').update(raw).digest('hex')}`;
|
|
257
257
|
if (args.expectedFileDigest && args.expectedFileDigest !== digest)
|
|
258
|
-
throw new CliUsageError('The
|
|
258
|
+
throw new CliUsageError('The case file differs from the reviewed file digest. Nothing was written.');
|
|
259
259
|
const cases = raw === null ? [] : readEditableCaseExport(raw);
|
|
260
260
|
const now = normalizeExplicitIsoTimestamp(context.now());
|
|
261
261
|
if (!now)
|
|
@@ -271,7 +271,7 @@ export async function runCaseCommand(args, dependencies, context) {
|
|
|
271
271
|
const note = args.noteSource ? await context.readInput(args.noteSource, MAX_NOTE_LENGTH * 4 + 4, 'Case note') : args.text;
|
|
272
272
|
const next = applyCliCaseOperation(cases, args, input, note, now);
|
|
273
273
|
if (Buffer.byteLength(serializeCaseStore(next)) > MAX_CASE_STORE_BYTES)
|
|
274
|
-
throw new CliUsageError('The updated
|
|
274
|
+
throw new CliUsageError('The updated case store exceeds its byte budget. No evidence was pruned and nothing was written.');
|
|
275
275
|
const output = `${JSON.stringify(buildCaseExport(next, now))}\n`;
|
|
276
276
|
// Apply the same strict admission to our output before atomic publication.
|
|
277
277
|
readEditableCaseExport(output);
|
|
@@ -283,6 +283,6 @@ export async function runCaseCommand(args, dependencies, context) {
|
|
|
283
283
|
dependencies.signal?.throwIfAborted();
|
|
284
284
|
if (cause instanceof CliUsageError)
|
|
285
285
|
throw cause;
|
|
286
|
-
throw new CliUsageError(cause instanceof Error ? cause.message : 'The
|
|
286
|
+
throw new CliUsageError(cause instanceof Error ? cause.message : 'The case operation could not be completed.');
|
|
287
287
|
}
|
|
288
288
|
}
|
package/cli/case-pack.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { buildCaseReport, buildCaseReportVerificationProjection } from "../packa
|
|
|
5
5
|
import { WHOISLEUTH_APPLICATION_VERSION } from "../lib/application-version.mjs";
|
|
6
6
|
import { assertBoundedJsonStructure, scanBoundedJson } from "../packages/analysis/bounded-json.mjs";
|
|
7
7
|
import { assertCaseBrandProfileIds } from "../packages/cases/case-brand-profile-references.mjs";
|
|
8
|
-
import { CASE_AUDIENCE_SENSITIVE_FIELD_NAMES, caseAudienceExclusions, projectCaseForAudience, } from "../packages/cases/case-record-projection.mjs";
|
|
8
|
+
import { CASE_AUDIENCE_SENSITIVE_FIELD_NAMES, caseAudienceExclusions, projectCaseForAudience, assertCaseCollectionReceiptAudience, } from "../packages/cases/case-record-projection.mjs";
|
|
9
9
|
import { normalizeDomain } from "../packages/evidence/domain-name.mjs";
|
|
10
10
|
import { normalizeCaseStore } from "../packages/cases/case-migration-model.mjs";
|
|
11
11
|
import { safeId } from "../packages/cases/case-record-core.mjs";
|
|
@@ -83,6 +83,11 @@ function allowedSensitiveFieldPath(key, path) {
|
|
|
83
83
|
return listItemPath(path, 'actions');
|
|
84
84
|
if (key === 'packetReceipt')
|
|
85
85
|
return typeof path.at(-1) === 'number' && path.at(-2) === 'history' && listItemPath(path.slice(0, -2), 'actions');
|
|
86
|
+
if (key === 'checkpointCollectionReceipts')
|
|
87
|
+
return topCasePath(path);
|
|
88
|
+
if (key === 'collectionReceipt')
|
|
89
|
+
return path.length === 4 && path[0] === 'cases' && typeof path[1] === 'number'
|
|
90
|
+
&& (path[2] === 'evidenceHistory' || path[2] === 'checkpointCollectionReceipts') && typeof path[3] === 'number';
|
|
86
91
|
return true;
|
|
87
92
|
}
|
|
88
93
|
/** Rejects hidden audience-sensitive copies with a budget proven by the serialized byte bound. */
|
|
@@ -390,6 +395,7 @@ export function verifyCliCasePack(input) {
|
|
|
390
395
|
assertClosedEnvelopes(root, packet, integrity, redactionManifest, packet.reports, root.version);
|
|
391
396
|
assertSensitiveFieldPlacement(root, serialized.length);
|
|
392
397
|
const audience = packet.audience;
|
|
398
|
+
assertCaseCollectionReceiptAudience(root, audience);
|
|
393
399
|
if (!Number.isSafeInteger(redactionManifest.sourceCaseCount)
|
|
394
400
|
|| redactionManifest.sourceCaseCount !== root.cases.length
|
|
395
401
|
|| !stringListsMatch(redactionManifest.excluded, caseAudienceExclusions(audience, root.version))) {
|
package/cli/ci-report.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { WHOISLEUTH_REQUEST_POLICY_URL } from "../packages/analysis/project-metadata.mjs";
|
|
2
2
|
import { CLI_DOMAIN_CONTROL_MONITOR_SCHEMA } from "../packages/contracts/domain-control-monitor.mjs";
|
|
3
|
+
import { lookupDiagnosticEntries } from "../lib/lookup-progress.mjs";
|
|
4
|
+
import { lookupStrictExitFindings } from "./strict-exit.mjs";
|
|
3
5
|
function record(value) {
|
|
4
6
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
5
7
|
}
|
|
@@ -9,13 +11,23 @@ function xml(value) {
|
|
|
9
11
|
function junitCases(document) {
|
|
10
12
|
const schema = String(document.schema || '');
|
|
11
13
|
if (schema === 'whoisleuth.cli.lookup') {
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
const sources = lookupDiagnosticEntries(document.diagnostics).filter(source => source.present);
|
|
15
|
+
const findings = lookupStrictExitFindings(document);
|
|
16
|
+
const cases = sources.map(({ source, path, state }) => {
|
|
17
|
+
const failure = findings.find(finding => finding.path === path);
|
|
18
|
+
return failure || state === null
|
|
19
|
+
? { name: `source ${source}`, failure: `Source state: ${failure?.state ?? 'unavailable'}` }
|
|
17
20
|
: { name: `source ${source}` };
|
|
18
21
|
});
|
|
22
|
+
// Retain nested and historical failure coverage without echoing imported
|
|
23
|
+
// object keys, endpoints or evidence into CI names or messages.
|
|
24
|
+
const namedPaths = new Set(sources.map(source => source.path));
|
|
25
|
+
const additional = findings.filter(finding => !namedPaths.has(finding.path));
|
|
26
|
+
if (additional.length)
|
|
27
|
+
cases.push({
|
|
28
|
+
name: 'additional source diagnostics',
|
|
29
|
+
failure: `Source states: ${[...new Set(additional.map(finding => finding.state))].sort().join(', ')}`,
|
|
30
|
+
});
|
|
19
31
|
return cases.length ? cases : [{ name: 'lookup diagnostics', failure: 'No source diagnostics were available.' }];
|
|
20
32
|
}
|
|
21
33
|
if (schema === 'whoisleuth.cli.bulk') {
|
|
@@ -126,7 +126,7 @@ const CLI_OPTION_DEFINITIONS = Object.freeze({
|
|
|
126
126
|
'--config': file('Load explicit versioned CLI configuration from this file.'),
|
|
127
127
|
'--profile': text('Select a named profile from the supplied configuration.', true),
|
|
128
128
|
'--palette': enumeration('Choose the terminal colour palette; redirected output and no-colour settings still take precedence.', ['auto', 'light', 'dark']),
|
|
129
|
-
'--network': flag('Include the optional public DNS and port 43 runtime checks.'),
|
|
129
|
+
'--network': flag('Include the optional public DNS, HTTPS and WHOIS (port 43) runtime checks.'),
|
|
130
130
|
'--json': flag('Write structured JSON to stdout or the selected output file.'),
|
|
131
131
|
'--reported-action': optionDefinition('enum', 'Record an analyst-reported identity action; repeat for separate actions.', { values: IDENTITY_ACTIONS.map(action => action.id), occurrence: 'repeatable' }),
|
|
132
132
|
'--trusted-auth-header': optionDefinition('text', 'Select a recognised receiver header by part:header-index. This records analyst trust, not independent authentication; repeat for separate headers.', { occurrence: 'repeatable' }),
|
|
@@ -235,10 +235,10 @@ const CLI_OPTION_DEFINITIONS = Object.freeze({
|
|
|
235
235
|
'--left-session': text('Select the left-hand retained capture session.', true),
|
|
236
236
|
'--right-session': text('Select the right-hand retained capture session.', true),
|
|
237
237
|
'--compact': flag('Write a compact report presentation.'),
|
|
238
|
-
'--case-id': text('Select the retained
|
|
239
|
-
'--domain': text('Supply the
|
|
240
|
-
'--title': text('Supply the
|
|
241
|
-
'--new-incident': flag('Create a separate incident instead of updating a matching
|
|
238
|
+
'--case-id': text('Select the retained case identifier.'),
|
|
239
|
+
'--domain': text('Supply the case domain.'),
|
|
240
|
+
'--title': text('Supply the case title.'),
|
|
241
|
+
'--new-incident': flag('Create a separate incident instead of updating a matching case.'),
|
|
242
242
|
'--text': text('Supply the note text directly.'),
|
|
243
243
|
'--note-file': file('Read note text from a selected local file.'),
|
|
244
244
|
'--input': file('Read the selected local input file.'),
|
package/cli/doctor.mjs
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { withTimeout } from "../lib/abort.mjs";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { CLI_COMMAND_REGISTRY } from "./command-reference.mjs";
|
|
3
|
+
import { DOCTOR_SCHEMA, DOCTOR_VERSION, DOCTOR_SUMMARY_SCHEMA, DOCTOR_SUMMARY_VERSION } from "../packages/contracts/cli-doctor.mjs";
|
|
4
|
+
export { DOCTOR_SCHEMA, DOCTOR_VERSION, DOCTOR_SUMMARY_SCHEMA, DOCTOR_SUMMARY_VERSION };
|
|
4
5
|
const MINIMUM_NODE_MAJOR = 24;
|
|
5
6
|
const NETWORK_HOST = 'whois.iana.org';
|
|
6
7
|
const NETWORK_QUERY = 'example.com';
|
|
7
8
|
const NETWORK_HTTPS_URL = 'https://data.iana.org/rdap/dns.json';
|
|
8
9
|
const NETWORK_TIMEOUT_MS = 6_000;
|
|
9
10
|
const MAX_DOCTOR_DETAIL_LENGTH = 180;
|
|
11
|
+
const DOCTOR_CHECK_IDS = ['runtime', 'platform', 'terminal', 'offline_features', 'network', 'public_dns', 'https_transport', 'whois_transport'];
|
|
10
12
|
function boundedDetail(value, fallback) {
|
|
11
13
|
return String(value || fallback)
|
|
12
14
|
.replace(/[\u0000-\u001f\u007f]+/gu, ' ')
|
|
@@ -162,6 +164,38 @@ async function buildDoctorReport(options) {
|
|
|
162
164
|
checks: Object.freeze(checks),
|
|
163
165
|
});
|
|
164
166
|
}
|
|
167
|
+
function diagnosticVersion(value) {
|
|
168
|
+
return /^(\d{1,6}\.\d{1,6}\.\d{1,6})(?:[-+].*)?$/u.exec(value)?.[1] ?? null;
|
|
169
|
+
}
|
|
170
|
+
function buildDoctorSummary(report, nodeVersion = process.versions.node) {
|
|
171
|
+
const checkCodes = DOCTOR_CHECK_IDS.flatMap(id => {
|
|
172
|
+
const check = report.checks.find(item => item.id === id);
|
|
173
|
+
if (!check)
|
|
174
|
+
return [];
|
|
175
|
+
const state = check.state === 'pass' || check.state === 'partial' || check.state === 'skipped' ? check.state : 'unknown';
|
|
176
|
+
return [`${id}_${state}`];
|
|
177
|
+
});
|
|
178
|
+
const omittedCheckCount = report.checks.length - checkCodes.length;
|
|
179
|
+
return Object.freeze({
|
|
180
|
+
schema: DOCTOR_SUMMARY_SCHEMA,
|
|
181
|
+
version: DOCTOR_SUMMARY_VERSION,
|
|
182
|
+
cliVersion: diagnosticVersion(report.cliVersion),
|
|
183
|
+
nodeVersion: diagnosticVersion(nodeVersion),
|
|
184
|
+
state: report.state === 'pass' && omittedCheckCount === 0 ? 'pass' : 'partial',
|
|
185
|
+
networkRequested: report.networkRequested === true,
|
|
186
|
+
checkCodes: Object.freeze(checkCodes),
|
|
187
|
+
omittedCheckCount,
|
|
188
|
+
declaredFormatIdentifiers: Object.freeze([...new Set(CLI_COMMAND_REGISTRY.flatMap(command => command.documentation.supportedSchemaIdentifiers))].sort()),
|
|
189
|
+
formatScope: 'Public command declarations only; not an installed-package or input-compatibility test.',
|
|
190
|
+
omissions: Object.freeze([
|
|
191
|
+
'Targets, URLs, queries and fragments',
|
|
192
|
+
'File paths, folder names and user names',
|
|
193
|
+
'Launch tokens, cookies, credentials and headers',
|
|
194
|
+
'Input files, response content and free-form diagnostic details',
|
|
195
|
+
'Timestamps, platform details and version prerelease/build metadata',
|
|
196
|
+
]),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
165
199
|
function formatDoctorReport(report) {
|
|
166
200
|
const lines = [
|
|
167
201
|
'Runtime:',
|
|
@@ -175,4 +209,4 @@ function formatDoctorReport(report) {
|
|
|
175
209
|
}
|
|
176
210
|
return `${lines.join('\n')}\n`;
|
|
177
211
|
}
|
|
178
|
-
export { buildDoctorReport, formatDoctorReport, };
|
|
212
|
+
export { buildDoctorReport, buildDoctorSummary, formatDoctorReport, };
|
package/cli/formatters/html.mjs
CHANGED
|
@@ -40,6 +40,7 @@ function formatLookupEvidenceHtml(document, options = {}) {
|
|
|
40
40
|
<style>
|
|
41
41
|
:root{color-scheme:dark;--bg:#07110d;--panel:#0d1b15;--panel2:#10241b;--line:#254837;--text:#d8f5e4;--muted:#91b7a0;--accent:#6dff9d;--shadow:rgba(0,0,0,.28)}
|
|
42
42
|
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}main{width:min(1120px,calc(100% - 32px));margin:0 auto;padding:42px 0 64px}header{border:1px solid var(--line);background:linear-gradient(145deg,var(--panel2),var(--panel));padding:28px;box-shadow:0 18px 50px var(--shadow)}h1,h2,h3{line-height:1.2;margin:0;color:var(--accent)}h1{font-size:clamp(1.5rem,4vw,2.35rem);overflow-wrap:anywhere}h2{font-size:1.22rem;margin-bottom:16px}h3{font-size:1rem;margin-bottom:12px}.eyebrow{margin:0 0 10px;color:var(--muted);letter-spacing:.12em;text-transform:uppercase}.notice{margin:18px 0 0;color:var(--muted);max-width:85ch}.block{margin-top:24px;border:1px solid var(--line);background:var(--panel);padding:22px}.fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 24px;margin:0}.fields div{display:grid;grid-template-columns:minmax(130px,.8fr) minmax(0,1.6fr);gap:12px;border-top:1px solid var(--line);padding:9px 0}.fields div:first-child,.fields div:nth-child(2){border-top:0}.fields dt{color:var(--muted)}.fields dd{margin:0;overflow-wrap:anywhere}.card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr));gap:16px}.card{border:1px solid var(--line);background:var(--panel2);padding:18px}.card .fields{display:block}.card .fields div{grid-template-columns:minmax(120px,.8fr) minmax(0,1.4fr)}.card .fields div:nth-child(2){border-top:1px solid var(--line)}.table-scroll{overflow-x:auto}table{width:100%;border-collapse:collapse;min-width:720px}caption{text-align:left;color:var(--muted);padding:0 0 10px}th,td{border:1px solid var(--line);padding:9px 10px;text-align:left;vertical-align:top;overflow-wrap:anywhere}thead th{background:var(--panel2);color:var(--accent)}tbody th{color:var(--text)}.status{white-space:nowrap;color:var(--accent)}.empty,.omission{color:var(--muted)}ul{margin:0;padding-left:22px}li+li{margin-top:8px}footer{margin-top:24px;color:var(--muted);font-size:.88rem;text-align:center}
|
|
43
|
+
footer{overflow-wrap:anywhere}
|
|
43
44
|
@media(max-width:700px){main{width:min(100% - 20px,1120px);padding-top:16px}header,.block{padding:17px}.fields{display:block}.fields div,.card .fields div{grid-template-columns:1fr;gap:2px}.fields div:nth-child(2){border-top:1px solid var(--line)}}
|
|
44
45
|
@media print{:root{color-scheme:light;--bg:#fff;--panel:#fff;--panel2:#f5f7f5;--line:#b9c5bd;--text:#17231b;--muted:#46564b;--accent:#155c2f;--shadow:transparent}body{font:10pt/1.42 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}main{width:100%;padding:0}header,.block,.card{box-shadow:none;break-inside:avoid}.block{margin-top:12px}}
|
|
45
46
|
</style>
|
|
@@ -55,6 +56,8 @@ function formatLookupEvidenceHtml(document, options = {}) {
|
|
|
55
56
|
<section class="block"><h2>Query</h2>${renderFields(report.query)}</section>
|
|
56
57
|
<section class="block"><h2>Assessment</h2>${renderFields(report.assessment)}</section>
|
|
57
58
|
<section class="block"><h2>Registry sources</h2>${renderGroups(report.registryGroups)}</section>
|
|
59
|
+
${report.registrarStanding.length ? `<section class="block"><h2>Registrar standing</h2>${renderFields(report.registrarStanding)}</section>` : ''}
|
|
60
|
+
${report.registryInterpretation.length ? `<section class="block"><h2>Registry interpretation</h2>${renderFields(report.registryInterpretation)}</section>` : ''}
|
|
58
61
|
<section class="block"><h2>Registry-source comparison</h2>${renderFields(report.comparison.health)}${renderComparison(report.comparison)}</section>
|
|
59
62
|
${report.registrarComparison.fields.length ? `<section class="block"><h2>Registry / registrar RDAP comparison</h2>${renderFields(report.registrarComparison.health)}${renderRegistrarComparison(report.registrarComparison)}</section>` : ''}
|
|
60
63
|
<section class="block"><h2>Network evidence</h2>${renderGroups(report.networkGroups)}</section>
|
package/cli/formatters/json.mjs
CHANGED
|
@@ -69,7 +69,14 @@ function versionedResult(result, schema, version, generatedAt, requestedField, r
|
|
|
69
69
|
return { ...result, schema, version, generatedAt, [requestedField]: requestedValue };
|
|
70
70
|
}
|
|
71
71
|
function buildCliPostureDocument(requestedDomain, report, generatedAt = new Date().toISOString()) {
|
|
72
|
-
|
|
72
|
+
const checks = report.checks;
|
|
73
|
+
const retained = Array.isArray(checks) ? { ...report, checks: checks.map(check => {
|
|
74
|
+
if (!check || typeof check !== 'object' || Array.isArray(check))
|
|
75
|
+
return check;
|
|
76
|
+
const { dmarcPolicyContext: _transient, ...fields } = check;
|
|
77
|
+
return fields;
|
|
78
|
+
}) } : report;
|
|
79
|
+
return versionedResult(retained, CLI_POSTURE_SCHEMA, CLI_POSTURE_SCHEMA_VERSION, generatedAt, 'requestedDomain', requestedDomain);
|
|
73
80
|
}
|
|
74
81
|
function buildCliHttpDocument(requestedDomain, result, generatedAt = new Date().toISOString()) {
|
|
75
82
|
return versionedResult(result, CLI_HTTP_SCHEMA, CLI_HTTP_SCHEMA_VERSION, generatedAt, 'requestedDomain', requestedDomain);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { registryAccessProfileLabel } from "../registry-access.mjs";
|
|
2
|
+
import { isSupportedRegistryInsightsVersion } from "../../packages/contracts/registry-insights.mjs";
|
|
2
3
|
import { dnsAddressFamilyEvidence } from "../../packages/evidence/dns-query-outcome.mjs";
|
|
4
|
+
import { lookupCollectionReceiptReview } from "../../packages/evidence/lookup-collection-review.mjs";
|
|
3
5
|
import { appendTechnologyLines } from "./terminal-technology.mjs";
|
|
4
6
|
import { appendDeliveryMetadataLines, appendPublicationMetadataLines } from "./terminal-metadata.mjs";
|
|
5
7
|
import { securityPostureReview, summarizeSecurityPostureReview } from "../../lib/website-security-posture.mjs";
|
|
@@ -252,6 +254,14 @@ function formatTerminalLookup(document, { detail = 'standard' } = {}) {
|
|
|
252
254
|
`RDAP ${titleCase(rdapDiagnostics.status)}`,
|
|
253
255
|
`WHOIS ${titleCase(whoisDiagnostics.status)}`,
|
|
254
256
|
];
|
|
257
|
+
if (detail !== 'summary') {
|
|
258
|
+
for (const [source, status] of [['rdap', rdapDiagnostics.status], ['whois', whoisDiagnostics.status]]) {
|
|
259
|
+
const sourceDetail = terminalRecord(document[source]).detail;
|
|
260
|
+
if (['unsupported', 'skipped', 'disabled'].includes(String(status)) && typeof sourceDetail === 'string' && sourceDetail) {
|
|
261
|
+
registrationLines.push(`${source.toUpperCase()} detail ${boundedTerminalComponent(sourceDetail, 300)}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
255
265
|
if (detail !== 'summary' && rdapDiagnostics.endpoint) {
|
|
256
266
|
registrationLines.splice(1, 0, `RDAP source ${safeTerminalValue(rdapDiagnostics.endpoint)}`);
|
|
257
267
|
}
|
|
@@ -267,7 +277,7 @@ function formatTerminalLookup(document, { detail = 'standard' } = {}) {
|
|
|
267
277
|
appendNetworkRegistrationLines(registrationLines, terminalRecord(rdap.parsed), document.type, detail);
|
|
268
278
|
}
|
|
269
279
|
const registryInsights = terminalRecord(document.registryInsights);
|
|
270
|
-
if (detail !== 'summary' && registryInsights.version
|
|
280
|
+
if (detail !== 'summary' && isSupportedRegistryInsightsVersion(registryInsights.version)) {
|
|
271
281
|
const lifecycle = terminalRecord(registryInsights.lifecycle);
|
|
272
282
|
const disclosure = terminalRecord(registryInsights.contactDisclosure);
|
|
273
283
|
const registryDisclosure = terminalRecord(disclosure.registryRdap);
|
|
@@ -712,6 +722,19 @@ function formatTerminalLookup(document, { detail = 'standard' } = {}) {
|
|
|
712
722
|
const collectionLines = [];
|
|
713
723
|
if (detail === 'verbose') {
|
|
714
724
|
collectionLines.push(`Generated ${safeTerminalValue(document.generatedAt, 'unknown')}`);
|
|
725
|
+
const receipt = lookupCollectionReceiptReview(document.collectionReceipt);
|
|
726
|
+
if (receipt) {
|
|
727
|
+
collectionLines.push(`Original plan ${receipt.selection}`);
|
|
728
|
+
for (const choice of receipt.choices)
|
|
729
|
+
collectionLines.push(`${choice.label}: ${choice.selected ? 'selected' : 'not selected'}`);
|
|
730
|
+
for (const source of receipt.sources) {
|
|
731
|
+
collectionLines.push(`${source.label}: ${source.summary}; ${source.outcome}`);
|
|
732
|
+
collectionLines.push(` ${source.explanation}`);
|
|
733
|
+
}
|
|
734
|
+
collectionLines.push('Collector start is local execution provenance, not proof of network delivery or complete evidence.');
|
|
735
|
+
}
|
|
736
|
+
else
|
|
737
|
+
collectionLines.push('Original plan Not retained; original optional choices and collector starts are unknown.');
|
|
715
738
|
const timing = terminalRecord(document.timing);
|
|
716
739
|
if (Number(timing.totalMs) >= 0)
|
|
717
740
|
collectionLines.push(`Total time ${safeTerminalValue(timing.totalMs)} ms`);
|
|
@@ -65,7 +65,7 @@ export const HISTORY_COMMAND_DEFINITIONS = Object.freeze({
|
|
|
65
65
|
reference: {
|
|
66
66
|
description: 'Convert one saved lookup into a versioned evidence report.',
|
|
67
67
|
example: 'whoisleuth export lookup.json --markdown',
|
|
68
|
-
boundary: `
|
|
68
|
+
boundary: `Supported saved Lookups are capped at 8 MiB and scanned for duplicate keys, the prototype-sensitive __proto__ key, and bounded nesting, key, value, and per-container counts before parsing. Current schema-${LOOKUP_EVIDENCE_SCHEMA_VERSION} exports preserve evidence-source attribution and limitations; published v2 schemas ${PUBLISHED_V2_LOOKUP_EVIDENCE_VERSIONS} and exact v1 schema ${V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION} remain readable, while other historical and unreleased shapes are unsupported. Markdown and HTML include a presentation-only generator footer unless --no-attribution is selected; JSON retains bounded generator provenance. Compact output intentionally omits raw registry payloads.`,
|
|
69
69
|
},
|
|
70
70
|
collection: { scope: 'Reads one saved Lookup and writes one bounded report.' },
|
|
71
71
|
summary: 'Convert a lookup to an evidence report',
|
|
@@ -75,6 +75,7 @@ export function formatWorkflowRecipeCatalogue(catalogue) {
|
|
|
75
75
|
`${recipe.id} — ${recipe.label}`,
|
|
76
76
|
` Subject: ${recipe.subjectRequirement.replaceAll('_', ' ')}`,
|
|
77
77
|
` Workflow run: ${recipe.runnableByWorkflowRun ? 'supported' : 'plan and explanation only'}`,
|
|
78
|
+
...formatWorkflowEffort(recipe.id, recipe.steps).map((line) => ` ${line}`),
|
|
78
79
|
` ${recipe.objective}`,
|
|
79
80
|
...recipe.steps.map((item, index) => ` ${index + 1}. ${item.label} [${item.mode}; ${item.approval.replaceAll('_', ' ')}]`),
|
|
80
81
|
...recipe.limitations.map((item) => ` Limitation: ${item}`),
|
|
@@ -84,6 +85,19 @@ export function formatWorkflowRecipeCatalogue(catalogue) {
|
|
|
84
85
|
'',
|
|
85
86
|
].join('\n');
|
|
86
87
|
}
|
|
88
|
+
function formatWorkflowEffort(recipeId, steps) {
|
|
89
|
+
const counts = { network: 0, offline: 0 };
|
|
90
|
+
for (const step of steps)
|
|
91
|
+
counts[step.mode] += 1;
|
|
92
|
+
const reuse = workflowStandardInputs(recipeId).length;
|
|
93
|
+
return [
|
|
94
|
+
`Work planned: ${counts.network} networked step${counts.network === 1 ? '' : 's'}; ${counts.offline} offline step${counts.offline === 1 ? '' : 's'}.`,
|
|
95
|
+
reuse
|
|
96
|
+
? `Evidence reuse: ${reuse} link${reuse === 1 ? '' : 's'} from earlier outputs to later steps.`
|
|
97
|
+
: 'Evidence reuse: no earlier-output links are declared.',
|
|
98
|
+
'Counts describe steps and reuse links, not requests. Saved files and cache state are not checked.',
|
|
99
|
+
];
|
|
100
|
+
}
|
|
87
101
|
function validatedSteps(steps) {
|
|
88
102
|
for (const step of steps) {
|
|
89
103
|
const invocationEffect = cliInvocationNetworkEffect(step.command, step.arguments);
|
|
@@ -147,6 +161,7 @@ export function formatInvestigationPlan(plan) {
|
|
|
147
161
|
`Investigation plan: ${plan.recipe.label}`,
|
|
148
162
|
`Subject ${plan.subject}`,
|
|
149
163
|
`Mode ${plan.execution}`,
|
|
164
|
+
...formatWorkflowEffort(plan.recipe.id, plan.steps),
|
|
150
165
|
'',
|
|
151
166
|
...plan.steps.flatMap((item, index) => [
|
|
152
167
|
`${index + 1}. ${item.label}`,
|
package/cli/lookup-brief.mjs
CHANGED
|
@@ -214,23 +214,29 @@ export function buildCliLookupBrief(input, generatedAt = new Date().toISOString(
|
|
|
214
214
|
});
|
|
215
215
|
}
|
|
216
216
|
export function formatCliLookupBrief(document) {
|
|
217
|
+
const sourceFacts = document.sourceHealth.flatMap((source) => {
|
|
218
|
+
const facts = document.facts.filter((item) => item.source.id === source.id);
|
|
219
|
+
return [
|
|
220
|
+
` ${safeTerminalValue(source.label)} [${safeTerminalValue(source.state)}; ${source.observedAt ? `observed ${safeTerminalValue(source.observedAt)}` : 'source time not recorded'}]`,
|
|
221
|
+
...(facts.length
|
|
222
|
+
? facts.map((item) => ` ${safeTerminalValue(item.label)}: ${safeTerminalValue(item.value)}`)
|
|
223
|
+
: [' No fact selected for this brief.']),
|
|
224
|
+
];
|
|
225
|
+
});
|
|
217
226
|
return [
|
|
218
227
|
`Lookup brief: ${safeTerminalValue(document.target)}`,
|
|
219
228
|
`Observed ${safeTerminalValue(document.observedAt)}`,
|
|
220
229
|
`Mode ${safeTerminalValue(document.mode)}`,
|
|
221
230
|
'',
|
|
222
231
|
'Source-attributed facts',
|
|
223
|
-
...
|
|
232
|
+
...sourceFacts,
|
|
224
233
|
'',
|
|
225
234
|
'Contradictions',
|
|
226
235
|
...(document.contradictions.length ? document.contradictions.flatMap((item) => [
|
|
227
236
|
` ${safeTerminalValue(item.field)}: ${safeTerminalValue(item.detail)}`,
|
|
228
|
-
...item.observations.map((observation) => ` ${safeTerminalValue(observation.source.label)}: ${safeTerminalValue(observation.value)} (${safeTerminalValue(observation.state)}; ${observation.source.observedAt ? `observed ${safeTerminalValue(observation.source.observedAt)}` : 'source time not recorded'})`),
|
|
237
|
+
...item.observations.map((observation) => ` ${safeTerminalValue(observation.source.label)}: ${safeTerminalValue(observation.value)} (value state: ${safeTerminalValue(observation.state)}; source state: ${safeTerminalValue(observation.source.state)}; ${observation.source.observedAt ? `observed ${safeTerminalValue(observation.source.observedAt)}` : 'source time not recorded'})`),
|
|
229
238
|
]) : [' No conflicting publication field was retained.']),
|
|
230
239
|
'',
|
|
231
|
-
'Unknown or incomplete',
|
|
232
|
-
...(document.unknowns.length ? document.unknowns.map((item) => ` ${safeTerminalValue(item)}`) : [' No incomplete source state was identified.']),
|
|
233
|
-
'',
|
|
234
240
|
'Recommended manual actions',
|
|
235
241
|
...document.actionPlan.flatMap((item, index) => [
|
|
236
242
|
` ${index + 1}. ${safeTerminalValue(item.action)}`,
|
|
@@ -67,25 +67,32 @@ async function runLookupCommand(args, dependencies, context) {
|
|
|
67
67
|
return EXIT_CODES.SUCCESS;
|
|
68
68
|
}
|
|
69
69
|
let documentGeneratedAt = '';
|
|
70
|
-
const buildDocument = (result) => {
|
|
70
|
+
const buildDocument = ({ result, collectionReceipt }) => {
|
|
71
71
|
documentGeneratedAt = context.now();
|
|
72
72
|
return buildCliLookupDocument(query, classified, result, documentGeneratedAt, args.deep ? 'deep' : 'fast', {
|
|
73
|
+
collectionReceipt,
|
|
73
74
|
...(args.observerLabel ? { observerLabel: args.observerLabel } : {}),
|
|
74
75
|
...(args.vantageLabel ? { vantageLabel: args.vantageLabel } : {}),
|
|
75
76
|
});
|
|
76
77
|
};
|
|
77
|
-
const collect = (signal, onSourceSettled) => abortable(() =>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
78
|
+
const collect = (signal, onSourceSettled) => abortable(async () => {
|
|
79
|
+
let collectionReceipt;
|
|
80
|
+
const result = await executeLookup(classified, {
|
|
81
|
+
fast: !args.deep,
|
|
82
|
+
compact: false,
|
|
83
|
+
onCollectionReceipt: value => { collectionReceipt = value; },
|
|
84
|
+
...(signal ? { signal } : {}),
|
|
85
|
+
...(args.deep ? {
|
|
86
|
+
...(selectedUrl ? { selectedUrl } : {}),
|
|
87
|
+
onSourceSettled: settlement => {
|
|
88
|
+
onSourceSettled(settlement);
|
|
89
|
+
eventProgress.emit({ event: 'source_settled', source: settlement.source, state: settlement.state });
|
|
90
|
+
},
|
|
91
|
+
} : {}),
|
|
92
|
+
});
|
|
93
|
+
signal?.throwIfAborted();
|
|
94
|
+
return { result, collectionReceipt };
|
|
95
|
+
}, signal);
|
|
89
96
|
let document;
|
|
90
97
|
if (args.browse === true) {
|
|
91
98
|
const browse = dependencies.browseLookupOperation || browseLookupOperation;
|