@slicedearth/whoisleuth-cli 2.5.1 → 2.6.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 +1 -1
- package/cli/artifact-validation/case-response.mjs +51 -8
- package/cli/artifact-validation/lookup-evidence.mjs +15 -1
- package/cli/artifact-validation/signed-review.mjs +2 -51
- package/cli/artifact-verify.mjs +9 -2
- package/cli/bulk-command-runner.mjs +4 -6
- package/cli/case-command.mjs +79 -3
- package/cli/case-pack.mjs +7 -2
- package/cli/command-definition.mjs +2 -1
- package/cli/discovery-scan-command-runner.mjs +4 -6
- package/cli/domain-feed.mjs +61 -0
- package/cli/formatters/json.mjs +1 -13
- package/cli/indicator-set-command.mjs +1 -2
- package/cli/intake-command.mjs +43 -3
- package/cli/investigation-artifacts.mjs +2 -3
- package/cli/investigation-package-review.mjs +2 -0
- package/cli/local-mmdb-review.mjs +27 -4
- package/cli/lookup-command-runner.mjs +1 -0
- package/cli/offline-evidence-review.mjs +51 -5
- package/cli/review-arguments.mjs +3 -2
- package/cli/review-command-definitions.mjs +14 -8
- package/cli/review-command-runner.mjs +6 -3
- package/cli/risk-calibration.mjs +6 -4
- package/cli/selected-input-worker.mjs +12 -2
- package/cli/support-command-runner.mjs +3 -3
- package/cli/watchlist-review.mjs +43 -0
- package/cli/workflow-arguments.mjs +14 -0
- package/cli/workflow-command-definitions.mjs +26 -1
- package/cli/workflow-command-runner.mjs +32 -0
- package/docs/cli.md +99 -2
- package/docs/contextual-reviews.md +28 -5
- package/docs/infrastructure-observations.md +126 -0
- package/docs/offline-intake-context.md +213 -0
- package/lib/credential-form-attribution.mjs +136 -0
- package/lib/credential-surface-profile.mjs +6 -2
- package/lib/dns-delegation-health.mjs +23 -7
- package/lib/dns-intelligence.mjs +18 -4
- package/lib/domain-posture-analysis.mjs +53 -2
- package/lib/domain-posture-parsers.mjs +9 -0
- package/lib/domain-posture.mjs +29 -10
- package/lib/evidence-export-privacy.mjs +5 -2
- package/lib/evidence-report.mjs +1 -0
- package/lib/favicon.mjs +13 -4
- package/lib/html-signals.mjs +6 -0
- package/lib/lookup-child-profile-contract.mjs +28 -11
- package/lib/lookup-response-contract.mjs +1 -0
- package/lib/operation-budget.mjs +4 -0
- package/lib/service-binding-dns.mjs +19 -2
- package/lib/sslbl-certificates.generated.mjs +138 -137
- package/lib/static-html-analysis.mjs +32 -4
- package/lib/technology-evidence-role.mjs +1 -51
- package/lib/tls-intelligence.mjs +30 -5
- package/lib/website-enrichment.mjs +39 -23
- package/lib/whois-chain.mjs +12 -3
- package/lib/whois-common-formats.mjs +3 -1
- package/lib/whois-contacts.mjs +13 -2
- package/package.json +7 -5
- package/packages/analysis/risk-scoring.mjs +12 -9
- package/packages/cases/case-containment-handoff.mjs +195 -0
- package/packages/cases/case-evidence-links.mjs +16 -0
- package/packages/cases/case-evidence-model.mjs +14 -3
- package/packages/cases/case-object-coverage.mjs +33 -0
- package/packages/cases/case-packet-correction.mjs +106 -0
- package/packages/cases/case-recheck-model.mjs +35 -5
- package/packages/cases/case-record-operations.mjs +74 -9
- package/packages/cases/case-record-projection.mjs +15 -3
- package/packages/cases/case-record-version-input.mjs +23 -1
- package/packages/cases/case-report.mjs +30 -0
- package/packages/cases/case-requested-evidence.mjs +1 -1
- package/packages/cases/case-response-actions.mjs +57 -1
- package/packages/cases/case-response-model.mjs +7 -0
- package/packages/cases/case-response-object.mjs +119 -0
- package/packages/cases/case-response-outcomes.mjs +81 -14
- package/packages/cases/case-response-packet-render.mjs +22 -3
- package/packages/cases/case-response-packet.mjs +30 -2
- package/packages/cases/case-response-review-inputs.mjs +48 -7
- package/packages/cases/case-workflow-metadata.mjs +1 -9
- package/packages/contracts/analyst-interchange.mjs +7 -5
- package/packages/contracts/candidate-watch-review.mjs +54 -0
- package/packages/contracts/case-portability.mjs +260 -42
- package/packages/contracts/cli-command-semantics.mjs +3 -0
- package/packages/contracts/context-review.mjs +2 -1
- package/packages/contracts/external-observation-interchange.mjs +96 -28
- package/packages/contracts/lookup-evidence.mjs +3 -2
- package/packages/contracts/message-intake.mjs +7 -1
- package/packages/contracts/risk-calibration.mjs +44 -29
- package/packages/contracts/schema-lifecycle.mjs +3 -1
- package/packages/contracts/web-capture.mjs +1 -1
- package/packages/contracts/workspace-portability.mjs +86 -16
- package/packages/evidence/collection-quality.mjs +1 -1
- package/packages/evidence/credential-form-attribution.mjs +60 -0
- package/packages/evidence/image-regions.mjs +15 -0
- package/packages/evidence/record-identity.mjs +60 -16
- package/packages/evidence/technology-evidence-role.mjs +51 -0
- package/packages/interchange/external-findings-import.mjs +32 -17
- package/packages/interchange/json-output.mjs +14 -0
- package/packages/interchange/managed-indicator-export.mjs +3 -2
- package/packages/interchange/managed-indicator-set.mjs +13 -0
- package/packages/investigation/acquisition-decision-packet.mjs +78 -0
- package/packages/investigation/context-review.mjs +3 -3
- package/packages/investigation/document-intake.mjs +9 -3
- package/packages/investigation/domain-history-review.mjs +43 -11
- package/packages/investigation/domain-transition-review.mjs +2 -0
- package/packages/investigation/infrastructure-observation.mjs +234 -0
- package/packages/investigation/intake-context.mjs +284 -0
- package/packages/investigation/intake-indicators.mjs +116 -0
- package/packages/investigation/intake-phones.mjs +155 -0
- package/packages/investigation/intake-report.mjs +2 -1
- package/packages/investigation/investigation-manifest.mjs +33 -7
- package/packages/investigation/link-intake.mjs +38 -8
- package/packages/investigation/message-intake.mjs +55 -15
- package/packages/monitoring/domain-feed.mjs +321 -0
- package/packages/workspace/brand-candidate-workflow.mjs +351 -0
- package/packages/workspace/brand-keyword-campaign.mjs +231 -0
- package/packages/workspace/brand-profile-model.mjs +46 -2
- package/packages/workspace/bulk-session-model.mjs +4 -0
- package/packages/workspace/candidate-watch-handoff.mjs +195 -0
- package/packages/workspace/detection-rule-model.mjs +10 -2
- package/packages/workspace/shortlist-model.mjs +10 -2
- package/packages/workspace/watchlist-history.mjs +31 -4
- package/packages/workspace/watchlist-store.mjs +136 -7
- package/third-party-notices.txt +29 -29
package/DISCLOSURE
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
WHOISleuth is a defensive domain investigation and brand
|
|
1
|
+
WHOISleuth is a defensive domain investigation and brand protection tool. Its
|
|
2
2
|
command-line package can collect bounded WHOIS, RDAP, DNS, HTTP, TLS,
|
|
3
3
|
certificate-transparency, website, and related infrastructure evidence. It can
|
|
4
4
|
also generate and review lookalike-domain candidates, compare saved evidence,
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { readCaseEvidenceRequest, readCasePacketAmendment, assertEvidenceRequestEvent, assertEvidenceRequestHistory, assertPacketAmendmentSelection } from "../../packages/cases/case-requested-evidence.mjs";
|
|
3
|
+
import { readCaseDeliveryPacketReceipt, readCasePacketCorrection, assertDeliveryPacketReceipt, correctionDelivery } from "../../packages/cases/case-packet-correction.mjs";
|
|
4
|
+
import { CASE_SELECTED_EVIDENCE_SOURCE_LIMITATION } from "../../packages/cases/case-evidence-links.mjs";
|
|
5
|
+
import { OBJECT_RESPONSE_CASE_PACKET_VERSION, OBJECT_RESPONSE_CASE_REVIEW_INPUTS_VERSION } from "../../packages/contracts/case-portability.mjs";
|
|
3
6
|
import { canonicalArtifactJsonV2 } from "../../packages/evidence/artifact-integrity.mjs";
|
|
7
|
+
import { EVIDENCE_FOLLOW_UP_CASE_RESPONSE_PACKET_VERSION, EVIDENCE_FOLLOW_UP_CASE_RESPONSE_REVIEW_INPUTS_VERSION } from "../../packages/contracts/case-portability.mjs";
|
|
8
|
+
import { readCaseResponseObject, readCaseResponseObjects, readCaseResponseObjectOutcome, assertCaseObjectOutcome } from "../../packages/cases/case-response-object.mjs";
|
|
4
9
|
import { CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_2_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION, LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSIVE_URLS, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_SELECTED_EVIDENCE, RESPONSE_AUTHORISATION_CONFIRMATION_IDS, RESPONSE_PACKET_PROFILES, RESPONSE_CONTACT_KINDS, RESPONSE_PACKET_PROFILE_IDS, RESPONSE_READINESS_ROW_IDS, RESPONSE_READINESS_STATES, RESPONSE_ROUTE_STALE_AFTER_DAYS, } from "../../packages/cases/case-response-packet.mjs";
|
|
5
10
|
import { CASE_ACTION_EVENT_SOURCE_CLASSES, CASE_ACTION_STATES, CASE_ACTION_TYPES, CASE_CLOSURE_REASONS, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, CASE_OBSERVED_EFFECT_STATES, CASE_PROVIDER_OUTCOMES, isLegalCaseActionTransition, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTIONS, MAX_CASE_ASSERTIONS, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_RECIPIENT_LENGTH, } from "../../packages/cases/case-response-model.mjs";
|
|
6
11
|
import { HEX_DIGEST_RE, array, boolean, domain, enumeration, exact, exactOptional, fail, integer, iso, optionalText, sameValues, strings, text, } from "./structure-primitives.mjs";
|
|
@@ -282,7 +287,8 @@ function validateVersionedCaseResponsePacket(value, version) {
|
|
|
282
287
|
fail('Case-response abusive URLs');
|
|
283
288
|
text(incident.observedHarm, 'Case-response observed harm', 2_000);
|
|
284
289
|
iso(incident.observedAt, 'Case-response observedAt');
|
|
285
|
-
if (profile.subject !== `${selectedProfile?.subjectPrefix}: ${caseRecord.domain} (${incident.category})`
|
|
290
|
+
if (profile.subject !== `${selectedProfile?.subjectPrefix}: ${caseRecord.domain} (${incident.category})`
|
|
291
|
+
&& (version < 13 || profile.subject !== `Correction or retraction request: ${caseRecord.domain} (${incident.category})`))
|
|
286
292
|
fail('Case-response profile subject');
|
|
287
293
|
const contacts = array(root.contacts, 'Case-response contacts', MAX_RESPONSE_CONTACTS);
|
|
288
294
|
const contactKeys = new Set();
|
|
@@ -379,7 +385,10 @@ function validateVersionedCaseResponsePacket(value, version) {
|
|
|
379
385
|
const evidence = array(root.selectedEvidence, 'Case-response selected evidence', MAX_RESPONSE_SELECTED_EVIDENCE);
|
|
380
386
|
const evidenceIds = new Set();
|
|
381
387
|
for (const candidate of evidence) {
|
|
382
|
-
const item = exactOptional(candidate, ['id', 'label', 'source', 'observedAt', 'completeness', 'limitations'], version > PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION ? ['observationHostname'] : [], 'Case-response selected evidence');
|
|
388
|
+
const item = exactOptional(candidate, ['id', 'label', 'source', 'observedAt', 'completeness', 'limitations'], [...(version > PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION ? ['observationHostname', 'webObservationMode'] : []), ...(version >= 12 ? ['responseObject'] : [])], 'Case-response selected evidence');
|
|
389
|
+
readCaseResponseObject(item.responseObject);
|
|
390
|
+
if (item.webObservationMode !== undefined && item.webObservationMode !== 'selected_url')
|
|
391
|
+
fail('Case-response web observation mode');
|
|
383
392
|
const id = text(item.id, 'Case-response evidence id', 64);
|
|
384
393
|
if (evidenceIds.has(id))
|
|
385
394
|
fail('Case-response selected evidence');
|
|
@@ -550,8 +559,11 @@ function validateVersionedCaseResponsePacket(value, version) {
|
|
|
550
559
|
'historyLimitations', 'transitions', 'createdAt', 'updatedAt',
|
|
551
560
|
...(hasActionBinding ? ['routeObservedAt'] : []),
|
|
552
561
|
...(hasPlatformRoutes ? ['routeReviewAfter'] : []),
|
|
553
|
-
], Number(root.schemaVersion) >= 11 ? ['amendment'] : [], 'Case-response escalation action');
|
|
562
|
+
], [...(Number(root.schemaVersion) >= 11 ? ['amendment'] : []), ...(version >= 12 ? ['responseObjects'] : []), ...(version >= 13 ? ['correction'] : [])], 'Case-response escalation action');
|
|
563
|
+
readCaseResponseObjects(action.responseObjects);
|
|
554
564
|
readCasePacketAmendment(action.amendment);
|
|
565
|
+
if (readCasePacketCorrection(action.correction) && action.amendment)
|
|
566
|
+
throw new TypeError('Correction and provider amendment must remain separate.');
|
|
555
567
|
const actionId = text(action.actionId, 'Case-response action id', 64);
|
|
556
568
|
if (actionIds.has(actionId))
|
|
557
569
|
fail('Case-response action identity');
|
|
@@ -583,7 +595,19 @@ function validateVersionedCaseResponsePacket(value, version) {
|
|
|
583
595
|
let previousTime = Number.NEGATIVE_INFINITY;
|
|
584
596
|
let previousEventId = '';
|
|
585
597
|
for (const eventCandidate of transitions) {
|
|
586
|
-
const event = exactOptional(eventCandidate, ['id', 'previousState', 'nextState', 'occurredAt', 'sourceClass', 'provenance', 'reference', 'evidencePinId', 'limitations', 'providerOutcome', 'outcomeDetail', 'originActionId', 'applied'], Number(root.schemaVersion) >= 11 ? ['evidenceRequest'] : [], 'Case-response action transition');
|
|
598
|
+
const event = exactOptional(eventCandidate, ['id', 'previousState', 'nextState', 'occurredAt', 'sourceClass', 'provenance', 'reference', 'evidencePinId', 'limitations', 'providerOutcome', 'outcomeDetail', 'originActionId', 'applied'], [...(Number(root.schemaVersion) >= 11 ? ['evidenceRequest'] : []), ...(version >= 12 ? ['responseObjects', 'objectOutcome'] : []), ...(version >= 13 ? ['packetReceipt'] : [])], 'Case-response action transition');
|
|
599
|
+
const receipt = readCaseDeliveryPacketReceipt(event.packetReceipt);
|
|
600
|
+
assertDeliveryPacketReceipt(receipt, event, actionId);
|
|
601
|
+
if (receipt && (receipt.caseId !== caseRecord.id || receipt.target !== caseRecord.domain || receipt.recipient !== action.recipient))
|
|
602
|
+
fail('Case-response receipt Case, target or recipient');
|
|
603
|
+
const responseObjects = readCaseResponseObjects(event.responseObjects);
|
|
604
|
+
const objectOutcome = readCaseResponseObjectOutcome(event.objectOutcome);
|
|
605
|
+
if (objectOutcome) {
|
|
606
|
+
if (!responseObjects?.length || !['submitted', 'acknowledged', 'terminal'].includes(String(event.nextState))
|
|
607
|
+
|| !['analyst', 'provider'].includes(String(event.sourceClass)) || event.previousState === 'authorised')
|
|
608
|
+
fail('Case-response object outcome scope');
|
|
609
|
+
responseObjects.forEach(object => assertCaseObjectOutcome(objectOutcome, object));
|
|
610
|
+
}
|
|
587
611
|
const evidenceRequest = readCaseEvidenceRequest(event.evidenceRequest);
|
|
588
612
|
assertEvidenceRequestEvent(evidenceRequest, event);
|
|
589
613
|
const eventId = text(event.id, 'Case-response action event id', 64);
|
|
@@ -679,6 +703,15 @@ function validateVersionedCaseResponsePacket(value, version) {
|
|
|
679
703
|
const action = candidate;
|
|
680
704
|
return { id: action.actionId, history: action.transitions, originActionId: action.originActionId, amendment: action.amendment };
|
|
681
705
|
}), selectedActionId, evidenceIds);
|
|
706
|
+
if (version >= 13) {
|
|
707
|
+
const actions = history.map(candidate => { const action = candidate; return { ...action, id: action.actionId, history: action.transitions }; });
|
|
708
|
+
const selected = actions.find(action => action.id === selectedActionId);
|
|
709
|
+
if (selected?.correction)
|
|
710
|
+
correctionDelivery(actions, selected, { caseId: caseRecord.id, target: caseRecord.domain, profile: profile.id, selectedPinIds: evidenceIds });
|
|
711
|
+
const expectedPrefix = selected?.correction ? 'Correction or retraction request' : selectedProfile?.subjectPrefix;
|
|
712
|
+
if (profile.subject !== `${expectedPrefix}: ${caseRecord.domain} (${incident.category})`)
|
|
713
|
+
fail('Case-response correction subject');
|
|
714
|
+
}
|
|
682
715
|
const lifecycle = exact(root.responseLifecycle, ['providerOutcomeState', 'latestProviderOutcome', 'observedChangeState', 'latestObservedEffect', 'latestObservedChangeAt', 'closure', 'limitations'], 'Case-response lifecycle');
|
|
683
716
|
const providerOutcomeState = enumeration(lifecycle.providerOutcomeState, ['available', 'missing', 'ambiguous'], 'Case-response lifecycle provider state');
|
|
684
717
|
let actualLatestProvider = null;
|
|
@@ -721,7 +754,10 @@ function validateVersionedCaseResponsePacket(value, version) {
|
|
|
721
754
|
fail('Case-response lifecycle provider state');
|
|
722
755
|
const observedChangeState = enumeration(lifecycle.observedChangeState, ['available', 'missing', 'ambiguous'], 'Case-response lifecycle observed-change state');
|
|
723
756
|
if (lifecycle.latestObservedEffect !== null) {
|
|
724
|
-
const effect =
|
|
757
|
+
const effect = exactOptional(lifecycle.latestObservedEffect, ['reviewId', 'state', 'observedAt', 'sourceClass', 'source'], version >= 12 ? ['responseObject', 'objectOutcome'] : [], 'Case-response lifecycle observed effect');
|
|
758
|
+
const responseObject = readCaseResponseObject(effect.responseObject);
|
|
759
|
+
const objectOutcome = readCaseResponseObjectOutcome(effect.objectOutcome);
|
|
760
|
+
assertCaseObjectOutcome(objectOutcome, responseObject);
|
|
725
761
|
text(effect.reviewId, 'Case-response effect review id', 64);
|
|
726
762
|
enumeration(effect.state, CASE_OBSERVED_EFFECT_STATES, 'Case-response effect state');
|
|
727
763
|
iso(effect.observedAt, 'Case-response effect observedAt');
|
|
@@ -743,9 +779,11 @@ function validateVersionedCaseResponsePacket(value, version) {
|
|
|
743
779
|
contract: CASE_RESPONSE_REVIEW_INPUTS_SCHEMA,
|
|
744
780
|
version: version === CASE_RESPONSE_PACKET_VERSION
|
|
745
781
|
? CASE_RESPONSE_REVIEW_INPUTS_VERSION
|
|
746
|
-
: version ===
|
|
747
|
-
:
|
|
748
|
-
:
|
|
782
|
+
: version === OBJECT_RESPONSE_CASE_PACKET_VERSION ? OBJECT_RESPONSE_CASE_REVIEW_INPUTS_VERSION
|
|
783
|
+
: version === EVIDENCE_FOLLOW_UP_CASE_RESPONSE_PACKET_VERSION ? EVIDENCE_FOLLOW_UP_CASE_RESPONSE_REVIEW_INPUTS_VERSION
|
|
784
|
+
: version === LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION ? LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION
|
|
785
|
+
: hasPlatformRoutes ? PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION
|
|
786
|
+
: hasActionBinding ? PUBLISHED_V2_2_CASE_RESPONSE_REVIEW_INPUTS_VERSION : PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION,
|
|
749
787
|
profile: {
|
|
750
788
|
id: profile.id,
|
|
751
789
|
label: profile.label,
|
|
@@ -768,6 +806,7 @@ function validateVersionedCaseResponsePacket(value, version) {
|
|
|
768
806
|
escalationHistoryOmitted: root.escalationHistoryOmitted,
|
|
769
807
|
escalationHistoryLimitations: root.escalationHistoryLimitations,
|
|
770
808
|
responseLifecycle: root.responseLifecycle,
|
|
809
|
+
...(version >= 13 ? { sourceQualifications: strings(root.provenance.limitations, 'Case-response limitations', 8, 600).filter(value => value === CASE_SELECTED_EVIDENCE_SOURCE_LIMITATION) } : {}),
|
|
771
810
|
};
|
|
772
811
|
const expectedReviewDigest = createHash('sha256')
|
|
773
812
|
.update(canonicalArtifactJsonV2(reviewedInputs))
|
|
@@ -794,6 +833,10 @@ function validateVersionedCaseResponsePacket(value, version) {
|
|
|
794
833
|
fail('Case-response integrity');
|
|
795
834
|
}
|
|
796
835
|
export function validateCaseResponsePacket(value) {
|
|
836
|
+
if (value.schemaVersion === OBJECT_RESPONSE_CASE_PACKET_VERSION)
|
|
837
|
+
return validateVersionedCaseResponsePacket(value, OBJECT_RESPONSE_CASE_PACKET_VERSION);
|
|
838
|
+
if (value.schemaVersion === EVIDENCE_FOLLOW_UP_CASE_RESPONSE_PACKET_VERSION)
|
|
839
|
+
return validateVersionedCaseResponsePacket(value, EVIDENCE_FOLLOW_UP_CASE_RESPONSE_PACKET_VERSION);
|
|
797
840
|
if (value.schemaVersion === LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION)
|
|
798
841
|
return validateVersionedCaseResponsePacket(value, LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION);
|
|
799
842
|
if (value.schemaVersion === PUBLIC_CASE_RESPONSE_PACKET_VERSION)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from 'node:util';
|
|
2
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";
|
|
3
3
|
import { validLookupObservationScope } from "../../packages/evidence/lookup-target.mjs";
|
|
4
|
+
import { externalPasswordFormObservation } from "../../packages/evidence/credential-form-attribution.mjs";
|
|
5
|
+
import { credentialSurfaceContractState } from "../../lib/lookup-child-profile-contract.mjs";
|
|
4
6
|
import { registrarStandingObservedBy, resolveRegistrarIanaId, validRegistrarStanding, } from "../../lib/registrar-standing-contract.mjs";
|
|
5
7
|
import { compareRegistrySources } from "../../lib/registry-comparison.mjs";
|
|
6
8
|
import { buildRegistryInsights } from "../../lib/registry-insights.mjs";
|
|
@@ -24,7 +26,7 @@ const PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS = [
|
|
|
24
26
|
'expiresInDays', 'privacyProtected', 'dnssec', 'activityStatus',
|
|
25
27
|
'websiteProbeStatus', 'websiteProbeDetail', 'http', 'deepScanComplete',
|
|
26
28
|
'faviconHash', 'faviconPHash', 'pageTitle', 'hasPasswordField',
|
|
27
|
-
'phishingLanguageMatch', 'hasExternalFormAction', 'externalAssetHosts',
|
|
29
|
+
'phishingLanguageMatch', 'hasExternalFormAction', 'hasExternalPasswordForm', 'externalAssetHosts',
|
|
28
30
|
'pageIdentity', 'credentialSurfaceProfile', 'structuredDataIdentity',
|
|
29
31
|
'technologyProfile', 'pageRoleProfile', 'clientBehaviorProfile',
|
|
30
32
|
'securityPosture', 'dns', 'tls', 'hasMx', 'hasNullMx', 'mxHosts', 'hasSpf',
|
|
@@ -42,6 +44,18 @@ const LOOKUP_REGISTRAR_COMPARISON_STATUSES = [
|
|
|
42
44
|
'registrar_unavailable', 'registry_incomplete', 'registrar_incomplete',
|
|
43
45
|
];
|
|
44
46
|
function validateLookupEvidenceHomepageMetadata(availability, version) {
|
|
47
|
+
const credentialProfile = availability.credentialSurfaceProfile === null || availability.credentialSurfaceProfile === undefined
|
|
48
|
+
? null : record(availability.credentialSurfaceProfile, 'Lookup evidence credential profile');
|
|
49
|
+
if (version < 30 && (Object.hasOwn(availability, 'hasExternalPasswordForm') || credentialProfile?.credentialSurfaceVersion === 2)) {
|
|
50
|
+
fail('Lookup evidence form-attribution epoch');
|
|
51
|
+
}
|
|
52
|
+
if (Object.hasOwn(availability, 'hasExternalPasswordForm') && availability.hasExternalPasswordForm !== null)
|
|
53
|
+
boolean(availability.hasExternalPasswordForm, 'Lookup evidence password-form destination');
|
|
54
|
+
if (credentialProfile?.credentialSurfaceVersion === 2 && credentialSurfaceContractState(credentialProfile) !== 'supported')
|
|
55
|
+
fail('Lookup evidence form-attribution profile');
|
|
56
|
+
if (credentialProfile?.credentialSurfaceVersion === 2 && availability.hasExternalPasswordForm !== undefined
|
|
57
|
+
&& availability.hasExternalPasswordForm !== externalPasswordFormObservation(credentialProfile.formAttribution))
|
|
58
|
+
fail('Lookup evidence form-attribution consistency');
|
|
45
59
|
const pageIdentity = availability.pageIdentity === null || availability.pageIdentity === undefined
|
|
46
60
|
? null
|
|
47
61
|
: record(availability.pageIdentity, 'Lookup evidence page identity');
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ACQUISITION_DECISION_PACKET_SCHEMA, validateAcquisitionDecisionPacketStructure, } from "../../packages/investigation/acquisition-decision-packet.mjs";
|
|
2
2
|
import { BULK_DOMAIN_COMPARISON_EXPORT_VERSION, BULK_DOMAIN_COMPARISON_SCHEMA, } from "../../packages/investigation/bulk-domain-comparison.mjs";
|
|
3
3
|
import { BULK_MAIL_EXPOSURE_EXPORT_VERSION, BULK_MAIL_EXPOSURE_SCHEMA, MAX_BULK_MAIL_EXPOSURE_ROWS, } from "../../packages/investigation/bulk-mail-exposure.mjs";
|
|
4
4
|
import { BULK_REVIEW_MANIFEST_SCHEMA, BULK_REVIEW_MANIFEST_VERSION, SUPPORTED_BULK_REVIEW_MANIFEST_VERSIONS, } from "../../packages/contracts/investigation-portability.mjs";
|
|
@@ -26,55 +26,6 @@ const DOMAIN_COMPARISON_ROW_IDS = [
|
|
|
26
26
|
];
|
|
27
27
|
const MAIL_STATES = ['authenticated_mail', 'evidence_incomplete', 'mail_auth_gap', 'mail_auth_incomplete', 'no_explicit_mx', 'null_mx'];
|
|
28
28
|
const DNS_TYPES = ['A', 'AAAA', 'CAA', 'CDNSKEY', 'CDS', 'CNAME', 'CSYNC', 'HTTPS', 'MX', 'NS', 'SRV', 'SVCB', 'TLSA', 'TXT'];
|
|
29
|
-
function validateAcquisitionItem(value, label) {
|
|
30
|
-
const item = exact(value, ['id', 'label', 'state', 'detail', 'provenance'], label);
|
|
31
|
-
const id = enumeration(item.id, ['availability', 'contacts', 'lifecycle', 'mail', 'nameservers', 'operations', 'policy_eligibility', 'policy_lifecycle', 'policy_transfer', 'tls', 'transfer', 'web'], label);
|
|
32
|
-
enumeration(item.state, ['authoritative', 'observed', 'review', 'unavailable'], label);
|
|
33
|
-
text(item.label, label, 200);
|
|
34
|
-
text(item.detail, label, 2_000);
|
|
35
|
-
text(item.provenance, label, 300);
|
|
36
|
-
return id;
|
|
37
|
-
}
|
|
38
|
-
function validateAcquisition(value) {
|
|
39
|
-
const root = exact(value, ['schema', 'version', 'generatedAt', 'target', 'synthetic', 'evidenceObservedAt', 'analystReview', 'evidenceReview', 'limitations', 'integrity'], 'Acquisition decision packet');
|
|
40
|
-
iso(root.generatedAt, 'Acquisition decision packet generatedAt');
|
|
41
|
-
domain(root.target, 'Acquisition decision packet target');
|
|
42
|
-
boolean(root.synthetic, 'Acquisition decision packet synthetic');
|
|
43
|
-
iso(root.evidenceObservedAt, 'Acquisition decision packet evidenceObservedAt', true);
|
|
44
|
-
const review = exact(root.analystReview, ['decision', 'rationale', 'reviewedChecks', 'outstandingChecks', 'state'], 'Acquisition analyst review');
|
|
45
|
-
const decision = enumeration(review.decision, ACQUISITION_DECISIONS, 'Acquisition analyst review decision');
|
|
46
|
-
text(review.rationale, 'Acquisition analyst review rationale', 2_000, true);
|
|
47
|
-
const reviewed = strings(review.reviewedChecks, 'Acquisition reviewed checks', ACQUISITION_MANUAL_CHECKS.length, 40);
|
|
48
|
-
const outstanding = strings(review.outstandingChecks, 'Acquisition outstanding checks', ACQUISITION_MANUAL_CHECKS.length, 40);
|
|
49
|
-
const expectedReviewed = ACQUISITION_MANUAL_CHECKS.filter((item) => reviewed.includes(item));
|
|
50
|
-
const expectedOutstanding = ACQUISITION_MANUAL_CHECKS.filter((item) => !reviewed.includes(item));
|
|
51
|
-
if (!sameValues(reviewed, expectedReviewed) || !sameValues(outstanding, expectedOutstanding))
|
|
52
|
-
fail('Acquisition analyst review checks');
|
|
53
|
-
const expectedState = decision === 'unresolved' || reviewed.length < ACQUISITION_MANUAL_CHECKS.length ? 'draft' : 'reviewed';
|
|
54
|
-
if (review.state !== expectedState)
|
|
55
|
-
fail('Acquisition analyst review state');
|
|
56
|
-
const evidence = exact(root.evidenceReview, ['version', 'label', 'state', 'items', 'transitionDependencies', 'policyChecks', 'nextSteps', 'limitations'], 'Acquisition evidence review');
|
|
57
|
-
if (evidence.version !== 2)
|
|
58
|
-
fail('Acquisition evidence review');
|
|
59
|
-
text(evidence.label, 'Acquisition evidence review label', 200);
|
|
60
|
-
enumeration(evidence.state, ['incomplete', 'registered', 'review_transition', 'sale_signal', 'unregistered_observation'], 'Acquisition evidence review state');
|
|
61
|
-
const expectedEvidenceIds = {
|
|
62
|
-
items: ['availability', 'lifecycle', 'transfer', 'operations', 'contacts'],
|
|
63
|
-
transitionDependencies: ['nameservers', 'web', 'mail', 'tls'],
|
|
64
|
-
policyChecks: ['policy_eligibility', 'policy_lifecycle', 'policy_transfer'],
|
|
65
|
-
};
|
|
66
|
-
for (const key of ['items', 'transitionDependencies', 'policyChecks']) {
|
|
67
|
-
const expected = expectedEvidenceIds[key];
|
|
68
|
-
const values = array(evidence[key], `Acquisition evidence review ${key}`, expected.length, expected.length);
|
|
69
|
-
const ids = values.map((item, index) => validateAcquisitionItem(item, `Acquisition evidence review ${key}[${index}]`));
|
|
70
|
-
if (!sameValues(ids, expected))
|
|
71
|
-
fail(`Acquisition evidence review ${key}`);
|
|
72
|
-
}
|
|
73
|
-
strings(evidence.nextSteps, 'Acquisition next steps', 6, 600);
|
|
74
|
-
strings(evidence.limitations, 'Acquisition evidence limitations', 12, 600);
|
|
75
|
-
strings(root.limitations, 'Acquisition limitations', 8, 600);
|
|
76
|
-
validateIntegrity(root.integrity, 'Acquisition integrity', root.version, ACQUISITION_DECISION_PACKET_VERSION);
|
|
77
|
-
}
|
|
78
29
|
function validateClaimPassportTarget(type, value) {
|
|
79
30
|
const candidate = text(value, 'Claim passport target', 253);
|
|
80
31
|
if (type === 'domain') {
|
|
@@ -656,7 +607,7 @@ export function validateSignedDigestArtifactStructure(schema, value) {
|
|
|
656
607
|
if (value.schema !== schema)
|
|
657
608
|
fail('Signed review artefact');
|
|
658
609
|
if (schema === ACQUISITION_DECISION_PACKET_SCHEMA)
|
|
659
|
-
|
|
610
|
+
validateAcquisitionDecisionPacketStructure(value);
|
|
660
611
|
else if (schema === MANAGED_INDICATOR_SET_SCHEMA)
|
|
661
612
|
validateManagedIndicatorSet(value);
|
|
662
613
|
else if (schema === LOOKUP_CLAIM_PASSPORT_SCHEMA)
|
package/cli/artifact-verify.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { INVESTIGATION_MANIFEST_SCHEMA, SUPPORTED_INVESTIGATION_MANIFEST_VERSION
|
|
|
19
19
|
import { verifyCliCasePack } from "./case-pack.mjs";
|
|
20
20
|
import { LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES, LOOKUP_EVIDENCE_SCHEMA, SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS, } from "../lib/evidence-export.mjs";
|
|
21
21
|
export const OFFLINE_ARTIFACT_VERIFICATION_SCHEMA = 'whoisleuth.offline-artifact-verification';
|
|
22
|
-
export const OFFLINE_ARTIFACT_VERIFICATION_VERSION =
|
|
22
|
+
export const OFFLINE_ARTIFACT_VERIFICATION_VERSION = 5;
|
|
23
23
|
export const MAX_OFFLINE_ARTIFACT_BYTES = Math.max(MAX_DOMAIN_CONTROL_MANIFEST_BYTES, MAX_ENCRYPTED_WORKSPACE_ARCHIVE_BYTES);
|
|
24
24
|
export const MAX_OFFLINE_PASSPHRASE_FILE_BYTES = 1024;
|
|
25
25
|
export class UnsupportedOfflineArtifactError extends TypeError {
|
|
@@ -590,8 +590,15 @@ export function formatOfflineArtifactVerification(report) {
|
|
|
590
590
|
}
|
|
591
591
|
if (report.package) {
|
|
592
592
|
lines.push(`Package bytes: ${report.package.digestSha256}`, 'Storage: unchanged; inspection only', 'Audience: private; review selected file contents before sharing', 'Signature trust: not checked', 'Trusted timestamp: not checked', 'Factual accuracy: not established');
|
|
593
|
-
for (const entry of report.package.entries)
|
|
593
|
+
for (const entry of report.package.entries) {
|
|
594
594
|
lines.push(`${entry.id}: ${entry.state} · ${entry.byteLength} bytes · ${entry.mediaType}${entry.issue ? ` · ${entry.issue}` : ''}`);
|
|
595
|
+
if (entry.imageDerivation) {
|
|
596
|
+
const declaration = entry.imageDerivation;
|
|
597
|
+
lines.push(` Declared image derivation: ${declaration.method}; ${declaration.operations.join(', ')}`, ` Parent: ${declaration.source.digestSha256} · ${declaration.source.byteLength} bytes`);
|
|
598
|
+
}
|
|
599
|
+
else if (entry.mediaType.startsWith('image/'))
|
|
600
|
+
lines.push(' Editing history: not declared');
|
|
601
|
+
}
|
|
595
602
|
for (const link of report.package.links)
|
|
596
603
|
lines.push(`Source link ${link.capsuleEntryId}: ${link.state}${link.sourceEntryId ? ` (${link.sourceEntryId})` : ''}`);
|
|
597
604
|
for (const capture of report.package.captureManifests) {
|
|
@@ -93,6 +93,10 @@ async function runBulkCommand(args, dependencies, context) {
|
|
|
93
93
|
}
|
|
94
94
|
catch (error) {
|
|
95
95
|
checkpointFailure = error;
|
|
96
|
+
eventProgress.emit({ event: 'warning', state: 'checkpoint_unavailable' });
|
|
97
|
+
if (!eventProgress.enabled) {
|
|
98
|
+
context.writeStderr(`Checkpoint warning: ${boundedCliErrorMessage(error, 'Checkpoint could not be written')}. The latest progress may not be saved.\n`);
|
|
99
|
+
}
|
|
96
100
|
}
|
|
97
101
|
}
|
|
98
102
|
const metadata = {
|
|
@@ -122,12 +126,6 @@ async function runBulkCommand(args, dependencies, context) {
|
|
|
122
126
|
else
|
|
123
127
|
context.writeStdout(context.terminal(formatTerminalBulk(selectedItems, metadata), args.color));
|
|
124
128
|
}
|
|
125
|
-
if (checkpointFailure) {
|
|
126
|
-
eventProgress.emit({ event: 'warning', state: 'checkpoint_unavailable' });
|
|
127
|
-
if (!eventProgress.enabled) {
|
|
128
|
-
context.writeStderr(`Checkpoint warning: ${boundedCliErrorMessage(checkpointFailure, 'Checkpoint could not be written')}. Completed output is still available.\n`);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
129
|
const policyFindings = evaluateCliFailPolicies(buildCliBulkDocument(items, { ...metadata, filter: 'all' }), args.failOn || []);
|
|
132
130
|
if (policyFindings.length && !eventProgress.enabled)
|
|
133
131
|
context.writeStderr(formatFailPolicyNotice(policyFindings));
|
package/cli/case-command.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { createCaseIncident, openOrCreateCase, recordCaseConclusion, recordCaseR
|
|
|
8
8
|
import { buildCaseExport, serializeCaseStore } from "../packages/cases/case-storage-model.mjs";
|
|
9
9
|
import { appendCaseEvidencePin } from "../packages/cases/case-response-model.mjs";
|
|
10
10
|
import { readCaseRecheckAnswerContext } from "../packages/cases/case-recheck-model.mjs";
|
|
11
|
+
import { readCaseResponseObjects } from "../packages/cases/case-response-object.mjs";
|
|
11
12
|
import { caseEvidenceLinkIssues, mergeCaseEvidenceLinks } from "../packages/cases/case-evidence-links.mjs";
|
|
12
13
|
import { dispositionLabel, statusLabel } from "../packages/cases/case-record-decisions.mjs";
|
|
13
14
|
import { normalizeDomain } from "../packages/cases/case-record-core.mjs";
|
|
@@ -59,7 +60,7 @@ function retainExistingEntries(before, after) {
|
|
|
59
60
|
}
|
|
60
61
|
const pairs = [
|
|
61
62
|
[before.notes, after.notes], [before.evidenceHistory, after.evidenceHistory], [before.evidencePins, after.evidencePins],
|
|
62
|
-
[before.decisions, after.decisions], [before.
|
|
63
|
+
[before.decisions, after.decisions], [before.assertions, after.assertions],
|
|
63
64
|
[before.manualTrail, after.manualTrail], [before.sightings, after.sightings],
|
|
64
65
|
[before.observedEffects.reviews, after.observedEffects.reviews], [before.closures.records, after.closures.records],
|
|
65
66
|
[before.branches ?? [], after.branches ?? []], [before.attachments ?? [], after.attachments ?? []],
|
|
@@ -70,6 +71,13 @@ function retainExistingEntries(before, after) {
|
|
|
70
71
|
throw new CliUsageError('The operation would remove or alter earlier retained evidence. Export a smaller working set or review it in the console; nothing was written.');
|
|
71
72
|
}
|
|
72
73
|
}
|
|
74
|
+
for (const action of before.actions) {
|
|
75
|
+
const next = after.actions.find(candidate => candidate.id === action.id);
|
|
76
|
+
const events = new Set(next?.history.map(event => canonicalArtifactJsonV2(event)) ?? []);
|
|
77
|
+
if (!next || action.history.some(event => !events.has(canonicalArtifactJsonV2(event)))) {
|
|
78
|
+
throw new CliUsageError('The operation would alter retained response events. Nothing was written.');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
73
81
|
}
|
|
74
82
|
export function applyCliCaseOperation(cases, args, input, note, now) {
|
|
75
83
|
if (args.operation === 'open') {
|
|
@@ -92,6 +100,67 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
|
|
|
92
100
|
else if (args.operation === 'link' || args.operation === 'withdraw-link') {
|
|
93
101
|
result = updateCase(cases, current.id, args.operation === 'link' ? { evidenceLink: input } : { evidenceLinkWithdrawal: input }, now);
|
|
94
102
|
}
|
|
103
|
+
else if (args.operation === 'incident-link') {
|
|
104
|
+
if (!input)
|
|
105
|
+
throw new CliUsageError('An incident link requires JSON input.');
|
|
106
|
+
fields(input, ['url'], 'Incident link');
|
|
107
|
+
result = updateCase(cases, current.id, { incidentTarget: input.url }, now);
|
|
108
|
+
}
|
|
109
|
+
else if (args.operation === 'action') {
|
|
110
|
+
if (!input)
|
|
111
|
+
throw new CliUsageError('A response action requires JSON input.');
|
|
112
|
+
fields(input, ['type', 'recipient', 'contactSource', 'routeObservedAt', 'routeReviewAfter', 'contactLimitations', 'dueAt', 'followUpAt', 'responseObjects', 'originActionId', 'amendment', 'correction'], 'Response action');
|
|
113
|
+
result = updateCase(cases, current.id, { action: input }, now);
|
|
114
|
+
const action = result.record.actions.find(candidate => !current.actions.some(prior => prior.id === candidate.id));
|
|
115
|
+
if (!action)
|
|
116
|
+
throw new CliUsageError('No new action was retained within the Case bounds; nothing was written.');
|
|
117
|
+
for (const [key, value] of Object.entries(input))
|
|
118
|
+
if (canonicalArtifactJsonV2(key === 'responseObjects' ? readCaseResponseObjects(value) : value) !== canonicalArtifactJsonV2(action[key])) {
|
|
119
|
+
throw new CliUsageError(`Action field ${safeTerminalValue(key)} is not canonical; nothing was written.`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
else if (args.operation === 'action-event') {
|
|
123
|
+
if (!input || !input.transition || typeof input.transition !== 'object' || Array.isArray(input.transition))
|
|
124
|
+
throw new CliUsageError('An action event requires id and transition JSON.');
|
|
125
|
+
fields(input, ['id', 'transition'], 'Response action event');
|
|
126
|
+
const transition = input.transition;
|
|
127
|
+
fields(transition, ['nextState', 'occurredAt', 'sourceClass', 'provenance', 'reference', 'evidencePinId', 'limitations', 'providerOutcome', 'outcomeDetail', 'responseObjects', 'objectOutcome', 'evidenceRequest', 'packetReceipt'], 'Response transition');
|
|
128
|
+
result = updateCase(cases, current.id, { actionUpdate: input }, now);
|
|
129
|
+
const previous = current.actions.find(action => action.id === input.id);
|
|
130
|
+
const event = result.record.actions.find(action => action.id === input.id)?.history.find(candidate => !previous?.history.some(prior => prior.id === candidate.id));
|
|
131
|
+
if (!event)
|
|
132
|
+
throw new CliUsageError('The selected action is not retained. Nothing was written.');
|
|
133
|
+
for (const [key, value] of Object.entries(transition))
|
|
134
|
+
if (canonicalArtifactJsonV2(key === 'responseObjects' ? readCaseResponseObjects(value) : value) !== canonicalArtifactJsonV2(event[key])) {
|
|
135
|
+
throw new CliUsageError(`Transition field ${safeTerminalValue(key)} is not canonical; nothing was written.`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else if (args.operation === 'recheck-question') {
|
|
139
|
+
if (!input)
|
|
140
|
+
throw new CliUsageError('A recheck question requires JSON input.');
|
|
141
|
+
fields(input, ['statement', 'rationale', 'recheck', 'evidencePinIds'], 'Recheck question');
|
|
142
|
+
result = updateCase(cases, current.id, { assertion: { ...input, kind: 'next_step' } }, now);
|
|
143
|
+
const question = result.record.assertions.find(candidate => !current.assertions.some(prior => prior.id === candidate.id));
|
|
144
|
+
if (!question)
|
|
145
|
+
throw new CliUsageError('No new question was retained within the Case bounds; nothing was written.');
|
|
146
|
+
for (const [key, value] of Object.entries(input))
|
|
147
|
+
if (canonicalArtifactJsonV2(value) !== canonicalArtifactJsonV2(question[key])) {
|
|
148
|
+
throw new CliUsageError(`Question field ${safeTerminalValue(key)} is not canonical; nothing was written.`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
else if (args.operation === 'close-object') {
|
|
152
|
+
if (!input?.responseObject)
|
|
153
|
+
throw new CliUsageError('Object closure requires one explicit responseObject; it cannot close the whole Case.');
|
|
154
|
+
fields(input, ['responseObject', 'reason', 'summary', 'observedEffectReviewId', 'actionId', 'limitations'], 'Object closure');
|
|
155
|
+
result = updateCase(cases, current.id, { closure: input }, now);
|
|
156
|
+
const closure = result.record.closures.records.find(candidate => !current.closures.records.some(prior => prior.id === candidate.id));
|
|
157
|
+
if (!closure)
|
|
158
|
+
throw new CliUsageError('No new closure was retained within the Case bounds; nothing was written.');
|
|
159
|
+
for (const [key, value] of Object.entries(input))
|
|
160
|
+
if (canonicalArtifactJsonV2(value) !== canonicalArtifactJsonV2(closure[key])) {
|
|
161
|
+
throw new CliUsageError(`Closure field ${safeTerminalValue(key)} is not canonical; nothing was written.`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
95
164
|
else if (args.operation === 'assess') {
|
|
96
165
|
if (!input)
|
|
97
166
|
throw new CliUsageError('An assessment requires JSON input.');
|
|
@@ -119,7 +188,7 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
|
|
|
119
188
|
else if (args.operation === 'recheck') {
|
|
120
189
|
if (!input)
|
|
121
190
|
throw new CliUsageError('A recheck requires JSON input.');
|
|
122
|
-
fields(input, ['state', 'observedAt', 'completeness', 'comparisonSummary', 'source', 'followUpAt', 'limitations', 'collectionDepth', 'recheck', 'observationHostname'], 'Recheck');
|
|
191
|
+
fields(input, ['state', 'observedAt', 'completeness', 'comparisonSummary', 'source', 'followUpAt', 'limitations', 'collectionDepth', 'recheck', 'observationHostname', 'responseObject', 'objectOutcome'], 'Recheck');
|
|
123
192
|
const recheck = readCaseRecheckAnswerContext(input.recheck);
|
|
124
193
|
if (input.state === 'not_reproduced' && !recheck) {
|
|
125
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.');
|
|
@@ -135,6 +204,8 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
|
|
|
135
204
|
comparisonSummary: comparison.value, source: review.source, followUpAt: review.followUpAt,
|
|
136
205
|
limitations: review.limitations, collectionDepth: comparison.collectionDepth,
|
|
137
206
|
...(review.recheck ? { recheck: review.recheck } : {}),
|
|
207
|
+
...(review.responseObject ? { responseObject: review.responseObject } : {}),
|
|
208
|
+
...(review.objectOutcome ? { objectOutcome: review.objectOutcome } : {}),
|
|
138
209
|
...(comparison.observationHostname ? { observationHostname: comparison.observationHostname } : {}),
|
|
139
210
|
};
|
|
140
211
|
for (const [key, value] of Object.entries(input)) {
|
|
@@ -166,8 +237,13 @@ function formatCases(cases, digest) {
|
|
|
166
237
|
if (cyclic)
|
|
167
238
|
lines.push(' Conflicting imported derivation cycle; no order is inferred.');
|
|
168
239
|
}
|
|
240
|
+
for (const action of record.actions) {
|
|
241
|
+
lines.push(`Action ${safeTerminalValue(action.id)} · ${action.type} · ${action.state}`, ` Objects: ${action.responseObjects?.map(object => `${object.kind}: ${safeTerminalValue(object.identifier)}`).join('; ') || 'unknown binding'}`);
|
|
242
|
+
for (const event of action.history)
|
|
243
|
+
lines.push(` Event ${event.occurredAt} · ${event.sourceClass} · ${event.nextState}${event.objectOutcome ? ` · reported ${event.objectOutcome}` : ''}`, ` Scope: ${event.responseObjects?.map(object => `${object.kind}: ${safeTerminalValue(object.identifier)}`).join('; ') || 'unknown binding'}`);
|
|
244
|
+
}
|
|
169
245
|
for (const review of record.observedEffects.reviews)
|
|
170
|
-
lines.push(`Recheck ${safeTerminalValue(review.observedAt, 'time unavailable')} · ${review.state} · ${review.completeness} · ${safeTerminalValue(review.source)}`);
|
|
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}` : ''}`);
|
|
171
247
|
lines.push(`Other retained records: ${record.evidenceHistory.length} snapshots, ${record.actions.length} actions, ${record.assertions.length} assertions, ${record.attachments?.length ?? 0} file references.`, '');
|
|
172
248
|
}
|
|
173
249
|
lines.push('Use --json for the full Case export. Attached file bytes remain separate. This command makes no request.', '');
|
package/cli/case-pack.mjs
CHANGED
|
@@ -62,6 +62,7 @@ function listItemPath(path, list) {
|
|
|
62
62
|
&& path[3] === 'analystResponse' && path[4] === list && typeof path[5] === 'number');
|
|
63
63
|
}
|
|
64
64
|
function allowedSensitiveFieldPath(key, path) {
|
|
65
|
+
const deliveryReceipt = path.at(-1) === 'packetReceipt' && typeof path.at(-2) === 'number' && path.at(-3) === 'history' && listItemPath(path.slice(0, -3), 'actions');
|
|
65
66
|
if (key === 'brandProfileIds')
|
|
66
67
|
return topCasePath(path) || reportCasePath(path);
|
|
67
68
|
if (key === 'notes')
|
|
@@ -75,9 +76,13 @@ function allowedSensitiveFieldPath(key, path) {
|
|
|
75
76
|
return topCasePath(path) || analystResponsePath(path);
|
|
76
77
|
}
|
|
77
78
|
if (key === 'recipient')
|
|
78
|
-
return listItemPath(path, 'actions');
|
|
79
|
+
return listItemPath(path, 'actions') || deliveryReceipt;
|
|
79
80
|
if (key === 'target')
|
|
80
|
-
return listItemPath(path, 'manualTrail');
|
|
81
|
+
return listItemPath(path, 'manualTrail') || deliveryReceipt;
|
|
82
|
+
if (key === 'correction')
|
|
83
|
+
return listItemPath(path, 'actions');
|
|
84
|
+
if (key === 'packetReceipt')
|
|
85
|
+
return typeof path.at(-1) === 'number' && path.at(-2) === 'history' && listItemPath(path.slice(0, -2), 'actions');
|
|
81
86
|
return true;
|
|
82
87
|
}
|
|
83
88
|
/** Rejects hidden audience-sensitive copies with a budget proven by the serialized byte bound. */
|
|
@@ -19,7 +19,7 @@ const LEGACY_WORKSPACE_ARCHIVE_SCOPE = LEGACY_WORKSPACE_ARCHIVE_VERSIONS
|
|
|
19
19
|
.map((version) => `v${version}`)
|
|
20
20
|
.join(' and ');
|
|
21
21
|
const INLINE_COMMAND_FAMILIES = ['support', 'review', 'assurance', 'workflow', 'history'];
|
|
22
|
-
const CLI_CASE_OPERATIONS = ['show', 'open', 'note', 'pin', 'link', 'withdraw-link', 'assess', 'recheck'];
|
|
22
|
+
const CLI_CASE_OPERATIONS = ['show', 'open', 'note', 'pin', 'link', 'withdraw-link', 'assess', 'recheck', 'incident-link', 'action', 'action-event', 'recheck-question', 'close-object'];
|
|
23
23
|
const CLI_INDICATOR_OPERATIONS = ['revise', 'inspect', 'stix', 'misp'];
|
|
24
24
|
const CLI_META_ACTIONS = Object.freeze([
|
|
25
25
|
Object.freeze({
|
|
@@ -130,6 +130,7 @@ const CLI_OPTION_DEFINITIONS = Object.freeze({
|
|
|
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' }),
|
|
133
|
+
'--intake-context': optionDefinition('file', 'Read one selected version-1 distribution declaration or version-2 digest-bound phone/destination selection file. Declarations remain attributed claims.'),
|
|
133
134
|
'--package': flag('Verify a portable evidence ZIP or encrypted package rather than a single report.'),
|
|
134
135
|
'--folder': file('Verify the evidence package within this selected folder.'),
|
|
135
136
|
'--quiet': flag('Suppress ordinary terminal presentation.', 'idempotent'),
|
|
@@ -135,6 +135,10 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
|
|
|
135
135
|
}
|
|
136
136
|
catch (error) {
|
|
137
137
|
checkpointFailure = error;
|
|
138
|
+
eventProgress.emit({ event: 'warning', state: 'checkpoint_unavailable' });
|
|
139
|
+
if (!eventProgress.enabled) {
|
|
140
|
+
context.writeStderr(`Checkpoint warning: ${boundedCliErrorMessage(error, 'Checkpoint could not be written')}. The latest progress may not be saved.\n`);
|
|
141
|
+
}
|
|
138
142
|
}
|
|
139
143
|
}
|
|
140
144
|
const snapshot = args.observationSnapshot
|
|
@@ -170,12 +174,6 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
|
|
|
170
174
|
else
|
|
171
175
|
context.writeStdout(context.terminal(formatTerminalDiscoveryScan(document), args.color));
|
|
172
176
|
}
|
|
173
|
-
if (checkpointFailure) {
|
|
174
|
-
eventProgress.emit({ event: 'warning', state: 'checkpoint_unavailable' });
|
|
175
|
-
if (!eventProgress.enabled) {
|
|
176
|
-
context.writeStderr(`Checkpoint warning: ${boundedCliErrorMessage(checkpointFailure, 'Checkpoint could not be written')}. Completed output is still available.\n`);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
177
|
const policyFindings = evaluateCliFailPolicies(document, args.failOn || []);
|
|
180
178
|
if (policyFindings.length && !eventProgress.enabled)
|
|
181
179
|
context.writeStderr(formatFailPolicyNotice(policyFindings));
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open } from 'node:fs/promises';
|
|
3
|
+
import { DOMAIN_FEED_LIMITS, normalizeDomainFeedSelection } from "../packages/monitoring/domain-feed.mjs";
|
|
4
|
+
import { CliUsageError } from "./errors.mjs";
|
|
5
|
+
export function parseDomainFeedSelectors(values) {
|
|
6
|
+
const hosts = [], terms = [], negativeTerms = [];
|
|
7
|
+
for (const value of values) {
|
|
8
|
+
if (value.startsWith('host:'))
|
|
9
|
+
hosts.push(value.slice(5));
|
|
10
|
+
else if (value.startsWith('term:'))
|
|
11
|
+
terms.push(value.slice(5));
|
|
12
|
+
else if (value.startsWith('exclude:'))
|
|
13
|
+
negativeTerms.push(value.slice(8));
|
|
14
|
+
else
|
|
15
|
+
throw new CliUsageError('--select requires host:<plain-hostname>, term:<literal-text> or exclude:<literal-text>.');
|
|
16
|
+
}
|
|
17
|
+
const selection = normalizeDomainFeedSelection({ hosts, terms, ...(negativeTerms.length ? { negativeTerms } : {}) });
|
|
18
|
+
if (!selection.hosts.length && !selection.terms.length)
|
|
19
|
+
throw new CliUsageError('Select at least one exact host or literal term.');
|
|
20
|
+
return selection;
|
|
21
|
+
}
|
|
22
|
+
/** Stream one descriptor-bound local regular file; URLs, pipes and symlinks are not inputs. */
|
|
23
|
+
export async function* readDomainFeedFile(source, signal) {
|
|
24
|
+
if (!source || /^[a-z][a-z0-9+.-]*:\/\//iu.test(source))
|
|
25
|
+
throw new CliUsageError('Domain feed review requires an explicit local regular file, not a URL.');
|
|
26
|
+
if (signal?.aborted)
|
|
27
|
+
throw new DOMException('Feed reading was cancelled.', 'AbortError');
|
|
28
|
+
const handle = await open(source, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
|
|
29
|
+
let stream;
|
|
30
|
+
const abort = () => stream?.destroy(new DOMException('Feed reading was cancelled.', 'AbortError'));
|
|
31
|
+
try {
|
|
32
|
+
if (signal?.aborted)
|
|
33
|
+
throw new DOMException('Feed reading was cancelled.', 'AbortError');
|
|
34
|
+
const before = await handle.stat();
|
|
35
|
+
if (!before.isFile())
|
|
36
|
+
throw new CliUsageError('The domain feed must be a regular file.');
|
|
37
|
+
if (before.size > DOMAIN_FEED_LIMITS.bytes)
|
|
38
|
+
throw new CliUsageError('The domain feed exceeds its 256 MiB raw-byte ceiling.');
|
|
39
|
+
stream = handle.createReadStream({ autoClose: false, highWaterMark: 64 * 1024 });
|
|
40
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
41
|
+
if (signal?.aborted)
|
|
42
|
+
abort();
|
|
43
|
+
let bytes = 0;
|
|
44
|
+
for await (const chunk of stream) {
|
|
45
|
+
if (signal?.aborted)
|
|
46
|
+
throw new DOMException('Feed reading was cancelled.', 'AbortError');
|
|
47
|
+
if (!(chunk instanceof Uint8Array) || chunk.byteLength > DOMAIN_FEED_LIMITS.bytes - bytes)
|
|
48
|
+
throw new CliUsageError('The domain feed exceeds its raw-byte ceiling.');
|
|
49
|
+
bytes += chunk.byteLength;
|
|
50
|
+
yield chunk;
|
|
51
|
+
}
|
|
52
|
+
const after = await handle.stat();
|
|
53
|
+
if (bytes !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs)
|
|
54
|
+
throw new CliUsageError('The domain feed changed while it was being read; no review was produced.');
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
signal?.removeEventListener('abort', abort);
|
|
58
|
+
stream?.destroy();
|
|
59
|
+
await handle.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
package/cli/formatters/json.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { bulkDnsSummary } from "../bulk-output.mjs";
|
|
2
2
|
import { SAVED_LOOKUP_SCHEMA_VERSION, buildCliLookupDocument, } from "../saved-lookup.mjs";
|
|
3
3
|
import { CLI_LOOKUP_SCHEMA } from "../../packages/contracts/cli-lookup.mjs";
|
|
4
|
+
import { terminalSafeJson } from "../../packages/interchange/json-output.mjs";
|
|
4
5
|
const CLI_BULK_SCHEMA = 'whoisleuth.cli.bulk';
|
|
5
6
|
const CLI_BULK_ITEM_SCHEMA = 'whoisleuth.cli.bulk.item';
|
|
6
7
|
const CLI_CT_SEARCH_SCHEMA = 'whoisleuth.cli.ct-search';
|
|
@@ -18,19 +19,6 @@ const CLI_POSTURE_SCHEMA_VERSION = 1;
|
|
|
18
19
|
const CLI_HTTP_SCHEMA_VERSION = 3;
|
|
19
20
|
const CLI_TLS_SCHEMA_VERSION = 1;
|
|
20
21
|
const CLI_COMPARE_SCHEMA_VERSION = 3;
|
|
21
|
-
const TERMINAL_UNSAFE_JSON_RE = /[\u007f-\u009f\u2028\u2029]|\p{Default_Ignorable_Code_Point}/gu;
|
|
22
|
-
function jsonUnicodeEscape(value) {
|
|
23
|
-
const codePoint = value.codePointAt(0);
|
|
24
|
-
if (codePoint <= 0xffff)
|
|
25
|
-
return `\\u${codePoint.toString(16).padStart(4, '0')}`;
|
|
26
|
-
const offset = codePoint - 0x10000;
|
|
27
|
-
const high = 0xd800 + (offset >> 10);
|
|
28
|
-
const low = 0xdc00 + (offset & 0x3ff);
|
|
29
|
-
return `\\u${high.toString(16).padStart(4, '0')}\\u${low.toString(16).padStart(4, '0')}`;
|
|
30
|
-
}
|
|
31
|
-
function terminalSafeJson(value, space) {
|
|
32
|
-
return JSON.stringify(value, null, space).replace(TERMINAL_UNSAFE_JSON_RE, jsonUnicodeEscape);
|
|
33
|
-
}
|
|
34
22
|
function formatJsonDocument(document) {
|
|
35
23
|
return `${terminalSafeJson(document, 2)}\n`;
|
|
36
24
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { buildManagedIndicatorRevision, managedIndicatorState, parseManagedIndicatorJson, readManagedIndicatorSet } from "../packages/interchange/managed-indicator-set.mjs";
|
|
2
2
|
import { exportManagedIndicators } from "../packages/interchange/managed-indicator-export.mjs";
|
|
3
3
|
import { MAX_MANAGED_INDICATOR_PLAN_BYTES, MAX_MANAGED_INDICATOR_SET_BYTES } from "../packages/contracts/analyst-interchange.mjs";
|
|
4
|
-
import { formatJsonDocument } from "./formatters/json.mjs";
|
|
5
4
|
import { safeTerminalValue } from "./formatters/terminal.mjs";
|
|
6
5
|
import EXIT_CODES from "./exit-codes.mjs";
|
|
7
6
|
export async function runIndicatorSetCommand(args, dependencies, context) {
|
|
@@ -18,7 +17,7 @@ export async function runIndicatorSetCommand(args, dependencies, context) {
|
|
|
18
17
|
if (args.operation === 'stix' || args.operation === 'misp')
|
|
19
18
|
context.writeStdout((await exportManagedIndicators(manifest, args.operation)).content);
|
|
20
19
|
else if (args.output === 'json')
|
|
21
|
-
context.writeStdout(
|
|
20
|
+
context.writeStdout((await exportManagedIndicators(manifest, 'manifest')).content);
|
|
22
21
|
else {
|
|
23
22
|
const lines = [`${safeTerminalValue(manifest.name)} · revision ${manifest.revision}`, `Set ${manifest.id}`, `Content digest ${manifest.integrity.digestSha256}`,
|
|
24
23
|
'Content verified; authorship and earlier revisions are not authenticated.'];
|