@slicedearth/whoisleuth-cli 2.6.0 → 2.8.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/bulk-command-runner.mjs +5 -4
- package/cli/case-command.mjs +17 -17
- package/cli/case-pack.mjs +7 -1
- package/cli/ci-report.mjs +17 -5
- package/cli/collection-arguments.mjs +6 -0
- package/cli/collection-command-definitions.mjs +13 -3
- package/cli/command-definition.mjs +7 -5
- package/cli/discovery-scan-command-runner.mjs +13 -7
- package/cli/doctor.mjs +37 -3
- package/cli/domain-control-monitor.mjs +25 -2
- package/cli/domain-control-observations.mjs +52 -15
- package/cli/fail-policy.mjs +31 -22
- 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 +26 -18
- package/cli/lookup-diff.mjs +1 -0
- package/cli/review-command-definitions.mjs +11 -11
- package/cli/risk-policy.mjs +83 -0
- 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/cli/workflow-command-runner.mjs +5 -1
- package/docs/cli-reference.md +56 -11
- package/docs/cli.md +162 -33
- 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 +19 -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/analysis/brand-profile-signals.mjs +42 -0
- 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-domain-comparison.mjs +17 -8
- 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 +30 -30
|
@@ -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
|
}
|
|
@@ -8,7 +8,8 @@ import EXIT_CODES from "./exit-codes.mjs";
|
|
|
8
8
|
import { buildCliBulkDocument, formatJsonDocument, formatJsonLines } from "./formatters/json.mjs";
|
|
9
9
|
import { formatTerminalBulk } from "./formatters/terminal.mjs";
|
|
10
10
|
import { buildCollectionPreflight, formatCollectionPreflight } from "./collection-preflight.mjs";
|
|
11
|
-
import { evaluateCliFailPolicies,
|
|
11
|
+
import { evaluateCliFailPolicies, reportCliFailPolicies } from "./fail-policy.mjs";
|
|
12
|
+
import { readCliRiskProfile } from "./risk-policy.mjs";
|
|
12
13
|
import { formatCliJunit } from "./ci-report.mjs";
|
|
13
14
|
import { createCliProgressEvents } from "./progress-events.mjs";
|
|
14
15
|
async function runBulkCommand(args, dependencies, context) {
|
|
@@ -39,6 +40,7 @@ async function runBulkCommand(args, dependencies, context) {
|
|
|
39
40
|
context.writeStdout(args.output === 'json' ? formatJsonDocument(document) : context.terminal(formatCollectionPreflight(document), args.color));
|
|
40
41
|
return EXIT_CODES.SUCCESS;
|
|
41
42
|
}
|
|
43
|
+
const riskProfile = await readCliRiskProfile(args.brandProfileSource, context.readInput, args.brandProfileId);
|
|
42
44
|
const classify = dependencies.classifyQuery || classifyQuery;
|
|
43
45
|
const checkpointWriter = dependencies.createBulkCheckpointWriter || createBulkCheckpointWriter;
|
|
44
46
|
const checkpoint = args.checkpoint
|
|
@@ -106,6 +108,7 @@ async function runBulkCommand(args, dependencies, context) {
|
|
|
106
108
|
collectedTotal: items.length,
|
|
107
109
|
filter: args.filter,
|
|
108
110
|
};
|
|
111
|
+
const policyFindings = evaluateCliFailPolicies(buildCliBulkDocument(items, { ...metadata, filter: 'all' }), args.failOn || [], riskProfile);
|
|
109
112
|
const selectedItems = selectBulkItems(items, args.filter);
|
|
110
113
|
const selectedDocument = buildCliBulkDocument(selectedItems, metadata);
|
|
111
114
|
if (!args.quiet) {
|
|
@@ -126,9 +129,7 @@ async function runBulkCommand(args, dependencies, context) {
|
|
|
126
129
|
else
|
|
127
130
|
context.writeStdout(context.terminal(formatTerminalBulk(selectedItems, metadata), args.color));
|
|
128
131
|
}
|
|
129
|
-
|
|
130
|
-
if (policyFindings.length && !eventProgress.enabled)
|
|
131
|
-
context.writeStderr(formatFailPolicyNotice(policyFindings));
|
|
132
|
+
reportCliFailPolicies(policyFindings, context.writeStderr, eventProgress);
|
|
132
133
|
const exitCode = checkpointFailure || items.some((item) => !item.ok) || policyFindings.length
|
|
133
134
|
? EXIT_CODES.PARTIAL_FAILURE
|
|
134
135
|
: EXIT_CODES.SUCCESS;
|
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') {
|
|
@@ -29,6 +29,8 @@ function parseLookupArguments(parsed) {
|
|
|
29
29
|
? { saveLookup: parsed.optionValue('--save-lookup') }
|
|
30
30
|
: {}),
|
|
31
31
|
...(selectedFailPolicies ? { failOn: selectedFailPolicies } : {}),
|
|
32
|
+
...(parsed.optionValue('--brand-profile') ? { brandProfileSource: parsed.optionValue('--brand-profile') } : {}),
|
|
33
|
+
...(parsed.optionValue('--brand-id') ? { brandProfileId: parsed.optionValue('--brand-id') } : {}),
|
|
32
34
|
};
|
|
33
35
|
}
|
|
34
36
|
function parseBulkArguments(parsed) {
|
|
@@ -55,6 +57,8 @@ function parseBulkArguments(parsed) {
|
|
|
55
57
|
filter,
|
|
56
58
|
...terminalOptions(parsed),
|
|
57
59
|
...(selectedFailPolicies ? { failOn: selectedFailPolicies } : {}),
|
|
60
|
+
...(parsed.optionValue('--brand-profile') ? { brandProfileSource: parsed.optionValue('--brand-profile') } : {}),
|
|
61
|
+
...(parsed.optionValue('--brand-id') ? { brandProfileId: parsed.optionValue('--brand-id') } : {}),
|
|
58
62
|
};
|
|
59
63
|
}
|
|
60
64
|
function parseDiscoverArguments(parsed) {
|
|
@@ -113,6 +117,8 @@ function parseDiscoverScanArguments(parsed) {
|
|
|
113
117
|
plan: parsed.hasOption('--plan'),
|
|
114
118
|
...terminalOptions(parsed),
|
|
115
119
|
...(selectedFailPolicies ? { failOn: selectedFailPolicies } : {}),
|
|
120
|
+
...(parsed.optionValue('--brand-profile') ? { brandProfileSource: parsed.optionValue('--brand-profile') } : {}),
|
|
121
|
+
...(parsed.optionValue('--brand-id') ? { brandProfileId: parsed.optionValue('--brand-id') } : {}),
|
|
116
122
|
};
|
|
117
123
|
}
|
|
118
124
|
export const COLLECTION_ARGUMENT_PARSERS = Object.freeze({
|
|
@@ -2,12 +2,13 @@ import { positional, OPTIONAL_FILE_POSITIONAL, OPTIONAL_TEXT_POSITIONAL, constra
|
|
|
2
2
|
import { bulkQueryLimit, bulkConcurrencyLimit } from "../lib/bulk-limits.mjs";
|
|
3
3
|
const COLLECTION_CEILINGS = `${bulkQueryLimit('fast')} fast or ${bulkQueryLimit('deep')} deep`;
|
|
4
4
|
const CONCURRENCY_CEILINGS = `${bulkConcurrencyLimit('fast')} fast or ${bulkConcurrencyLimit('deep')} deep`;
|
|
5
|
+
const FAILURE_POLICY_BOUNDARY = 'Failure policies evaluate all collected and restored results before output filtering. For danger review, explicitly select --deep and --brand-profile; use --brand-id to select one profile from a multi-profile export. The current canonical Risk model compares collected evidence with that local reference; Fast, missing or ineligible evidence cannot establish a threshold match. The heuristic review threshold is not a maliciousness or safety verdict. The profile is not sent to collectors, retained in checkpoints or copied into output.';
|
|
5
6
|
export const COLLECTION_COMMAND_DEFINITIONS = Object.freeze({
|
|
6
7
|
lookup: commandSeed({
|
|
7
8
|
reference: {
|
|
8
9
|
description: 'Collect registration evidence for one domain, IP, or ASN.',
|
|
9
10
|
example: 'whoisleuth lookup example.test --deep --browse',
|
|
10
|
-
boundary: 'Fast is the default. An ICANN-recognised public domain, reserved documentation domain, IP, or ASN may occupy command position as shorthand; it delegates to this same parser and URL-like input requires the explicit lookup command. Deep mode adds bounded WHOIS, DNS, HTTP, TLS, technology, posture, and network context where applicable. --deep --exact-url explicitly sends the input URL path and query to the website, without its fragment; ordinary URL input sends only the hostname. --plan remains offline and omits the selected URL. Retained paths and page-derived text require review before sharing. A full Deep homepage observation can derive fixed publication and delivery/cache summaries from the same response without retaining raw metadata values or making another request. --browse opens before collection, shows aggregate Fast progress or independently settled planned Deep sources, and then navigates allowlisted retained fields in the completed document. Press ? for help and / to search rendered panel text only. Closing during collection cancels without a partial document. --save-lookup writes the exact completed private JSON only after a normal browser close; it can contain normalised evidence omitted from panels and refuses an existing path.',
|
|
11
|
+
boundary: 'Fast is the default. An ICANN-recognised public domain, reserved documentation domain, IP, or ASN may occupy command position as shorthand; it delegates to this same parser and URL-like input requires the explicit lookup command. Deep mode adds bounded WHOIS, DNS, HTTP, TLS, technology, posture, and network context where applicable. --deep --exact-url explicitly sends the input URL path and query to the website, without its fragment; ordinary URL input sends only the hostname. --plan remains offline and omits the selected URL. Retained paths and page-derived text require review before sharing. A full Deep homepage observation can derive fixed publication and delivery/cache summaries from the same response without retaining raw metadata values or making another request. --browse opens before collection, shows aggregate Fast progress or independently settled planned Deep sources, and then navigates allowlisted retained fields in the completed document. Press ? for help and / to search rendered panel text only. Closing during collection cancels without a partial document. --save-lookup writes the exact completed private JSON only after a normal browser close; it can contain normalised evidence omitted from panels and refuses an existing path. ' + FAILURE_POLICY_BOUNDARY,
|
|
11
12
|
},
|
|
12
13
|
collection: {
|
|
13
14
|
scope: 'Accepts one target. Fast is the default; deep collection must be selected explicitly.',
|
|
@@ -31,6 +32,8 @@ export const COLLECTION_COMMAND_DEFINITIONS = Object.freeze({
|
|
|
31
32
|
'--save-lookup',
|
|
32
33
|
'--strict-exit',
|
|
33
34
|
'--fail-on',
|
|
35
|
+
'--brand-profile',
|
|
36
|
+
'--brand-id',
|
|
34
37
|
'--events',
|
|
35
38
|
'--quiet',
|
|
36
39
|
'--no-color',
|
|
@@ -39,6 +42,7 @@ export const COLLECTION_COMMAND_DEFINITIONS = Object.freeze({
|
|
|
39
42
|
positional('target', 'text', 0, 1, [], 'argv_or_stdin', ['--browse']),
|
|
40
43
|
]),
|
|
41
44
|
constraints: Object.freeze([
|
|
45
|
+
constraint({ kind: 'requires_all', option: '--brand-id', requiredOptions: ['--brand-profile'] }),
|
|
42
46
|
constraint({
|
|
43
47
|
kind: 'mutually_exclusive',
|
|
44
48
|
options: ['--json', '--junit', '--markdown', '--html'],
|
|
@@ -106,7 +110,7 @@ export const COLLECTION_COMMAND_DEFINITIONS = Object.freeze({
|
|
|
106
110
|
reference: {
|
|
107
111
|
description: 'Triage newline-delimited domains, IPs, or ASNs with bounded concurrency.',
|
|
108
112
|
example: 'cat domains.txt | whoisleuth bulk --jsonl',
|
|
109
|
-
boundary: 'Fast and deep jobs use separate concurrency ceilings. Filters affect output only; collection failures and inconclusive authority states remain explicit in JSON, JSONL, and CSV. --csv-with-metadata adds source versions, separate observation and report times, collection origin and diagnostic states; --csv retains the compact columns.',
|
|
113
|
+
boundary: 'Fast and deep jobs use separate concurrency ceilings. Filters affect output only; collection failures and inconclusive authority states remain explicit in JSON, JSONL, and CSV. --csv-with-metadata adds source versions, separate observation and report times, collection origin and diagnostic states; --csv retains the compact columns. ' + FAILURE_POLICY_BOUNDARY,
|
|
110
114
|
},
|
|
111
115
|
collection: {
|
|
112
116
|
scope: `Accepts at most ${COLLECTION_CEILINGS} targets, with concurrency capped at ${CONCURRENCY_CEILINGS}.`,
|
|
@@ -131,11 +135,14 @@ export const COLLECTION_COMMAND_DEFINITIONS = Object.freeze({
|
|
|
131
135
|
'--events',
|
|
132
136
|
'--plan',
|
|
133
137
|
'--fail-on',
|
|
138
|
+
'--brand-profile',
|
|
139
|
+
'--brand-id',
|
|
134
140
|
'--quiet',
|
|
135
141
|
'--no-color',
|
|
136
142
|
],
|
|
137
143
|
positionals: OPTIONAL_FILE_POSITIONAL,
|
|
138
144
|
constraints: Object.freeze([
|
|
145
|
+
constraint({ kind: 'requires_all', option: '--brand-id', requiredOptions: ['--brand-profile'] }),
|
|
139
146
|
constraint({
|
|
140
147
|
kind: 'mutually_exclusive',
|
|
141
148
|
options: [
|
|
@@ -236,7 +243,7 @@ export const COLLECTION_COMMAND_DEFINITIONS = Object.freeze({
|
|
|
236
243
|
reference: {
|
|
237
244
|
description: 'Generate a bounded candidate set, collect a selected subset, and produce a supervised review queue.',
|
|
238
245
|
example: 'whoisleuth discover-scan example.test --scan-limit 50 --checkpoint scan.json --json',
|
|
239
|
-
boundary: `This command performs network collection. Fast compact lookup is the default; deep mode is capped at ${bulkQueryLimit('deep')} candidates. Allowlisting changes review priority only and shared infrastructure remains a lead, not attribution. --csv-with-metadata adds source versions, separate observation and report times, collection origin and diagnostic states; --csv retains the compact columns
|
|
246
|
+
boundary: `This command performs network collection. Fast compact lookup is the default; deep mode is capped at ${bulkQueryLimit('deep')} candidates. Allowlisting changes review priority only and shared infrastructure remains a lead, not attribution. --csv-with-metadata adds source versions, separate observation and report times, collection origin and diagnostic states; --csv retains the compact columns. ${FAILURE_POLICY_BOUNDARY}`,
|
|
240
247
|
},
|
|
241
248
|
collection: {
|
|
242
249
|
scope: `Scans at most ${COLLECTION_CEILINGS} candidates, with concurrency capped at ${CONCURRENCY_CEILINGS}.`,
|
|
@@ -265,6 +272,8 @@ export const COLLECTION_COMMAND_DEFINITIONS = Object.freeze({
|
|
|
265
272
|
'--events',
|
|
266
273
|
'--plan',
|
|
267
274
|
'--fail-on',
|
|
275
|
+
'--brand-profile',
|
|
276
|
+
'--brand-id',
|
|
268
277
|
'--json',
|
|
269
278
|
'--jsonl',
|
|
270
279
|
'--csv',
|
|
@@ -275,6 +284,7 @@ export const COLLECTION_COMMAND_DEFINITIONS = Object.freeze({
|
|
|
275
284
|
],
|
|
276
285
|
positionals: OPTIONAL_TEXT_POSITIONAL,
|
|
277
286
|
constraints: Object.freeze([
|
|
287
|
+
constraint({ kind: 'requires_all', option: '--brand-id', requiredOptions: ['--brand-profile'] }),
|
|
278
288
|
constraint({
|
|
279
289
|
kind: 'mutually_exclusive',
|
|
280
290
|
options: ['--json', '--jsonl', '--csv', '--csv-with-metadata', '--domains'],
|
|
@@ -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' }),
|
|
@@ -156,6 +156,8 @@ const CLI_OPTION_DEFINITIONS = Object.freeze({
|
|
|
156
156
|
'--interactive': flag('Prompt for missing supported inputs on an interactive terminal.'),
|
|
157
157
|
'--save-lookup': file('After a normal evidence-browser close, save the completed private Lookup JSON to a new file.'),
|
|
158
158
|
'--strict-exit': flag('Use the command’s strict outcome policy when deciding the exit status.'),
|
|
159
|
+
'--brand-profile': file('Read a local Brand Profile export for danger-policy comparisons; no additional requests or profile retention.'),
|
|
160
|
+
'--brand-id': text('Select an exact profile ID when --brand-profile contains multiple profiles.'),
|
|
159
161
|
'--fail-on': optionDefinition('policy_list', 'Return a failure-policy exit status for the selected comma-separated outcomes.', {
|
|
160
162
|
values: (command) => CLI_FAIL_POLICIES_BY_COMMAND[command] ?? [],
|
|
161
163
|
}),
|
|
@@ -235,10 +237,10 @@ const CLI_OPTION_DEFINITIONS = Object.freeze({
|
|
|
235
237
|
'--left-session': text('Select the left-hand retained capture session.', true),
|
|
236
238
|
'--right-session': text('Select the right-hand retained capture session.', true),
|
|
237
239
|
'--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
|
|
240
|
+
'--case-id': text('Select the retained case identifier.'),
|
|
241
|
+
'--domain': text('Supply the case domain.'),
|
|
242
|
+
'--title': text('Supply the case title.'),
|
|
243
|
+
'--new-incident': flag('Create a separate incident instead of updating a matching case.'),
|
|
242
244
|
'--text': text('Supply the note text directly.'),
|
|
243
245
|
'--note-file': file('Read note text from a selected local file.'),
|
|
244
246
|
'--input': file('Read the selected local input file.'),
|
|
@@ -10,7 +10,8 @@ import EXIT_CODES from "./exit-codes.mjs";
|
|
|
10
10
|
import { formatJsonDocument } from "./formatters/json.mjs";
|
|
11
11
|
import { createCliProgressEvents } from "./progress-events.mjs";
|
|
12
12
|
import { buildCollectionPreflight, formatCollectionPreflight } from "./collection-preflight.mjs";
|
|
13
|
-
import { evaluateCliFailPolicies,
|
|
13
|
+
import { evaluateCliFailPolicies, reportCliFailPolicies } from "./fail-policy.mjs";
|
|
14
|
+
import { readCliRiskProfile } from "./risk-policy.mjs";
|
|
14
15
|
async function readAllowlist(source, dependencies, context, classify) {
|
|
15
16
|
if (!source)
|
|
16
17
|
return new Set();
|
|
@@ -59,6 +60,7 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
|
|
|
59
60
|
context.writeStdout(args.output === 'json' ? formatJsonDocument(document) : context.terminal(formatCollectionPreflight(document), args.color));
|
|
60
61
|
return EXIT_CODES.SUCCESS;
|
|
61
62
|
}
|
|
63
|
+
const riskProfile = await readCliRiskProfile(args.brandProfileSource, context.readInput, args.brandProfileId);
|
|
62
64
|
const classify = dependencies.classifyQuery || classifyQuery;
|
|
63
65
|
const allowlist = await readAllowlist(args.allowlistSource, dependencies, context, classify);
|
|
64
66
|
const checkpointWriter = dependencies.createBulkCheckpointWriter || createBulkCheckpointWriter;
|
|
@@ -144,7 +146,7 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
|
|
|
144
146
|
const snapshot = args.observationSnapshot
|
|
145
147
|
? await updateDiscoveryObservationSnapshot(args.observationSnapshot, candidates, items, { deep: args.deep, resolverServers }, generationMetadata.generatedAt)
|
|
146
148
|
: null;
|
|
147
|
-
const
|
|
149
|
+
const metadata = {
|
|
148
150
|
generatedAt: generationMetadata.generatedAt,
|
|
149
151
|
seed: generationMetadata.seed,
|
|
150
152
|
preset: generationMetadata.preset,
|
|
@@ -157,9 +159,15 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
|
|
|
157
159
|
chunkSize: args.chunkSize,
|
|
158
160
|
concurrency: args.concurrency,
|
|
159
161
|
deep: args.deep,
|
|
160
|
-
filter:
|
|
162
|
+
filter: 'all',
|
|
161
163
|
resolverServers,
|
|
162
|
-
}
|
|
164
|
+
};
|
|
165
|
+
// Policies describe the complete collected run, including restored results,
|
|
166
|
+
// source diagnostics and candidate context, before presentation selection.
|
|
167
|
+
const collectedDocument = buildDiscoveryScanDocument(candidates, items, metadata, allowlist, snapshot);
|
|
168
|
+
const policyFindings = evaluateCliFailPolicies(collectedDocument, args.failOn || [], riskProfile);
|
|
169
|
+
const document = args.filter === 'all' ? collectedDocument
|
|
170
|
+
: buildDiscoveryScanDocument(candidates, items, { ...metadata, filter: args.filter }, allowlist, snapshot);
|
|
163
171
|
if (!args.quiet) {
|
|
164
172
|
if (args.output === 'json')
|
|
165
173
|
context.writeStdout(formatJsonDocument(document));
|
|
@@ -174,9 +182,7 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
|
|
|
174
182
|
else
|
|
175
183
|
context.writeStdout(context.terminal(formatTerminalDiscoveryScan(document), args.color));
|
|
176
184
|
}
|
|
177
|
-
|
|
178
|
-
if (policyFindings.length && !eventProgress.enabled)
|
|
179
|
-
context.writeStderr(formatFailPolicyNotice(policyFindings));
|
|
185
|
+
reportCliFailPolicies(policyFindings, context.writeStderr, eventProgress);
|
|
180
186
|
const exitCode = checkpointFailure || items.some((item) => !item.ok) || policyFindings.length
|
|
181
187
|
? EXIT_CODES.PARTIAL_FAILURE
|
|
182
188
|
: EXIT_CODES.SUCCESS;
|
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, };
|