@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/cli/intake-command.mjs
CHANGED
|
@@ -2,6 +2,8 @@ import { readBoundedRegularFile } from "../lib/bounded-file.mjs";
|
|
|
2
2
|
import { MAX_MESSAGE_INTAKE_BYTES, MESSAGE_INTAKE_INPUTS } from "../packages/contracts/message-intake.mjs";
|
|
3
3
|
import { reviewSelectedInputInWorker } from "./selected-input-worker.mjs";
|
|
4
4
|
import { reviewIdentityIncident } from "../packages/investigation/identity-incident-review.mjs";
|
|
5
|
+
import { intakeIndicators, intakeIndicatorSource } from "../packages/investigation/intake-indicators.mjs";
|
|
6
|
+
import { MAX_INTAKE_CONTEXT_BYTES, applyIntakeContextInput } from "../packages/investigation/intake-context.mjs";
|
|
5
7
|
import { selectReceiverTrust, authenticationHeaderLabel } from "../packages/investigation/mail-authentication-review.mjs";
|
|
6
8
|
import { CliUsageError } from "./errors.mjs";
|
|
7
9
|
import { formatJsonDocument } from "./formatters/json.mjs";
|
|
@@ -33,6 +35,28 @@ export function formatMessageIntake(report) {
|
|
|
33
35
|
lines.push(` Ambiguous parameters: ${link.authorisation.duplicateParameters.join(', ')}`);
|
|
34
36
|
}
|
|
35
37
|
}
|
|
38
|
+
for (const indicator of intakeIndicators(report))
|
|
39
|
+
lines.push(`${indicator.id}: ${indicator.kind.toUpperCase()} ${indicator.value} · literal selected text · ${indicator.location.partId}${indicator.location.page ? ` page ${indicator.location.page}` : ''} · source ${intakeIndicatorSource(report, indicator)}`);
|
|
40
|
+
if (report.schemaVersion === 2) {
|
|
41
|
+
lines.push(`Indicator coverage: ${report.indicatorCoverage.state.replaceAll('_', ' ')}. Hashes require explicit algorithm labels; no indicator was resolved or reputation-checked.`);
|
|
42
|
+
const supplied = report.distributionContext;
|
|
43
|
+
if (supplied)
|
|
44
|
+
lines.push(`Analyst-declared distribution: ${supplied.channel} · ${supplied.sourceLabel} · observed ${supplied.observedAt ?? 'unknown'}`, ` Reference ${supplied.reference ?? 'not supplied'} · observer ${supplied.observerLabel ?? 'unknown'} · vantage ${supplied.vantageLabel ?? 'unknown'}`, 'Distribution, observation time and vantage are supplied claims, not verified capture conditions or independent collection.');
|
|
45
|
+
const selected = report.selectedEvidence;
|
|
46
|
+
if (selected) {
|
|
47
|
+
lines.push(`Deliberately selected phone groups: ${selected.phones.length}. Original spans use zero-based, end-exclusive UTF-16 code units.`);
|
|
48
|
+
lines.push(`Phone extraction coverage: ${selected.phoneCoverage.state.replaceAll('_', ' ')}; ${selected.phoneCoverage.candidatesShown} transient candidates shown, ${selected.phoneCoverage.candidatesReviewed} candidate runs reviewed.`);
|
|
49
|
+
for (const phone of selected.phones) {
|
|
50
|
+
lines.push(` ${phone.canonical ?? 'No canonical number'} · ${phone.state.replaceAll('_', ' ')} · declared ${phone.declaration.role.replaceAll('_', ' ')} · ${phone.declaration.basis.replaceAll('_', ' ')} · country context ${phone.declaration.countryCallingCode ?? 'unknown'}`, ` Source ${phone.declaration.sourceLabel}; declared observation ${phone.declaration.observedAt ?? 'unknown'}; input ${selected.sourceDigestSha256}`);
|
|
51
|
+
for (const occurrence of phone.occurrences)
|
|
52
|
+
lines.push(` ${JSON.stringify(occurrence.original)} · [${occurrence.start}, ${occurrence.end})${phone.extension ? ` · separate extension ${phone.extension}` : ''}`);
|
|
53
|
+
}
|
|
54
|
+
const pair = selected.destinationPair;
|
|
55
|
+
if (pair)
|
|
56
|
+
lines.push(`Supplied destination pair: ${pair.state.replaceAll('_', ' ')} · displayed ${pair.displayed.hostname ?? pair.displayed.state} · destination ${pair.destination.hostname ?? pair.destination.state}`, ` Displayed source ${pair.displayedDeclaration.sourceLabel}; declared ${pair.displayedDeclaration.observedAt ?? 'unknown'}; ${pair.displayedDeclaration.basis}`, ` Destination role ${pair.destinationDeclaration.role}; source ${pair.destinationDeclaration.sourceLabel}; declared ${pair.destinationDeclaration.observedAt ?? 'unknown'}; ${pair.destinationDeclaration.basis}`);
|
|
57
|
+
lines.push(...selected.limitations);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
36
60
|
if (report.actionHints.length)
|
|
37
61
|
lines.push(`Review wording: ${report.actionHints.map(value => value.replaceAll('_', ' ')).join(', ')}`);
|
|
38
62
|
if (report.documentReview) {
|
|
@@ -70,7 +94,7 @@ export function formatMessageIntake(report) {
|
|
|
70
94
|
lines.push(`Partial analysis: ${report.coverage.boundsReached.join('; ')}`);
|
|
71
95
|
if (report.source.kind === 'qr' && !report.coverage.reviewedParts)
|
|
72
96
|
lines.push('No QR text was decoded. This does not establish that the image contains no QR symbol.');
|
|
73
|
-
lines.push('Exact paths, queries, fragments and
|
|
97
|
+
lines.push('Exact paths, queries, fragments and surrounding message content are not included. Only deliberately selected phone substrings can be retained. Link relationships and authorisation parameters are supplied claims, not proof of a completed redirect or account compromise.');
|
|
74
98
|
return `${lines.join('\n')}\n`;
|
|
75
99
|
}
|
|
76
100
|
export async function runIntakeCommand(args, dependencies, context) {
|
|
@@ -95,11 +119,27 @@ export async function runIntakeCommand(args, dependencies, context) {
|
|
|
95
119
|
throw new CliUsageError(cause.message);
|
|
96
120
|
throw cause;
|
|
97
121
|
}
|
|
98
|
-
|
|
122
|
+
let report = { ...result.report, authenticationReview, identityRecovery: reviewIdentityIncident({ reportedActions: args.reportedActions }) };
|
|
123
|
+
if (args.intakeContextSource) {
|
|
124
|
+
if (args.intakeContextSource === '-')
|
|
125
|
+
throw new CliUsageError('Distribution context requires a separate selected file, not stdin.');
|
|
126
|
+
report = applyIntakeContextInput({ ...result, report }, await context.readInput(args.intakeContextSource, MAX_INTAKE_CONTEXT_BYTES, 'Intake context'));
|
|
127
|
+
}
|
|
99
128
|
dependencies.signal?.throwIfAborted();
|
|
129
|
+
if (!args.quiet && result.phoneReview && (result.phoneReview.candidates.length || result.phoneReview.state === 'partial')) {
|
|
130
|
+
const phones = result.phoneReview;
|
|
131
|
+
context.writeStderr([
|
|
132
|
+
`Transient phone candidates: ${phones.candidates.length}; coverage ${phones.state}. Values are omitted until explicitly selected.`,
|
|
133
|
+
`Full source digest: ${phones.sourceDigestSha256}`,
|
|
134
|
+
...phones.candidates.map(candidate => `${candidate.id}: UTF-16 span [${candidate.start}, ${candidate.end}) · ${candidate.state.replaceAll('_', ' ')}`),
|
|
135
|
+
'Review these spans in the original plaintext. Use --intake-context with version 2, review.sourceDigestSha256, and review.phones entries containing start, end and an explicit declaration. Offsets are zero-based UTF-16 code units, end exclusive; retain BOMs and supplementary characters when counting.',
|
|
136
|
+
'Only selected values enter the private report. Public/trusted Case summaries and STIX/MISP mappings omit phone values.',
|
|
137
|
+
'',
|
|
138
|
+
].join('\n'));
|
|
139
|
+
}
|
|
100
140
|
if (!args.quiet)
|
|
101
141
|
context.writeStdout(args.output === 'json' ? formatJsonDocument(report) : context.terminal(formatMessageIntake(report), args.color));
|
|
102
|
-
return args.strictExit && report.coverage.state === 'partial' ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
|
|
142
|
+
return args.strictExit && (report.coverage.state === 'partial' || result.phoneReview?.state === 'partial') ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
|
|
103
143
|
}
|
|
104
144
|
finally {
|
|
105
145
|
bytes.fill(0);
|
|
@@ -10,7 +10,7 @@ import { validateLookupEvidenceArtifactStructure } from "./artifact-validation/l
|
|
|
10
10
|
import { CLI_DISCOVERY_SCAN_SCHEMA, CLI_DISCOVERY_SCAN_VERSION } from "./discovery-scan.mjs";
|
|
11
11
|
import { CLI_DISCOVER_SCHEMA, CLI_DISCOVER_SCHEMA_VERSION, CLI_POSTURE_SCHEMA, CLI_POSTURE_SCHEMA_VERSION, CLI_CT_SEARCH_SCHEMA, CLI_CT_SEARCH_SCHEMA_VERSION, CLI_COMPARE_SCHEMA, CLI_COMPARE_SCHEMA_VERSION, } from "./formatters/json.mjs";
|
|
12
12
|
import { CLI_LOOKUP_BRIEF_SCHEMA, CLI_LOOKUP_BRIEF_VERSION } from "../packages/contracts/cli-lookup-brief.mjs";
|
|
13
|
-
import { EXTERNAL_FINDINGS_SCHEMA,
|
|
13
|
+
import { EXTERNAL_FINDINGS_SCHEMA, parseExternalFindingsDocument } from "../packages/interchange/external-findings-import.mjs";
|
|
14
14
|
import { SOURCE_RELIABILITY_REPORT_SCHEMA, SOURCE_RELIABILITY_REPORT_VERSION } from "./source-reliability.mjs";
|
|
15
15
|
import { CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, verifyCliCasePack } from "./case-pack.mjs";
|
|
16
16
|
import { SHARING_REVIEW_SCHEMA, SHARING_REVIEW_VERSION } from "./sharing-review.mjs";
|
|
@@ -59,8 +59,7 @@ export function validateWorkflowResult(step, result, retained) {
|
|
|
59
59
|
break;
|
|
60
60
|
case 'ct-intake':
|
|
61
61
|
schema = EXTERNAL_FINDINGS_SCHEMA;
|
|
62
|
-
version =
|
|
63
|
-
parseExternalFindingsDocument(value);
|
|
62
|
+
version = parseExternalFindingsDocument(value).schemaVersion;
|
|
64
63
|
break;
|
|
65
64
|
case 'brief':
|
|
66
65
|
schema = CLI_LOOKUP_BRIEF_SCHEMA;
|
|
@@ -74,6 +74,7 @@ export async function verifyOfflineInvestigationPackage(input, passphrase) {
|
|
|
74
74
|
}
|
|
75
75
|
entries.push({ id: reviewed.entry.id, byteLength: reviewed.entry.byteLength,
|
|
76
76
|
mediaType: 'mediaType' in reviewed.entry ? reviewed.entry.mediaType : 'application/json',
|
|
77
|
+
imageDerivation: 'imageDerivation' in reviewed.entry ? reviewed.entry.imageDerivation : null,
|
|
77
78
|
state, identity: reviewed.state === 'identity_verified' ? 'verified' : 'failed', verification, issue });
|
|
78
79
|
}
|
|
79
80
|
const complete = entries.every((entry) => entry.state === 'admitted' || entry.state === 'opaque')
|
|
@@ -97,6 +98,7 @@ export async function verifyOfflineInvestigationPackage(input, passphrase) {
|
|
|
97
98
|
'Capture attachment checks bind selected bytes to the capture manifest declaration, not to a trusted publisher or real website observation.',
|
|
98
99
|
'Case-file checks match original references to other verified package bytes by length and digest, not by filename or claimed source. Missing originals make the package partial; no file is fetched or imported.',
|
|
99
100
|
'Source declarations and packaging times are not authenticated. Signatures and timestamp tokens require their own verification and trust decisions.',
|
|
101
|
+
'Image derivation declares the immediate parent fingerprint and editing operations, not proof of transformation or complete redaction. Parent bytes are optional; missing declarations leave editing history unknown.',
|
|
100
102
|
'This command does not import, overwrite or collect evidence. Unchanged bytes and valid source structure do not establish factual accuracy, currentness or safe sharing.',
|
|
101
103
|
]),
|
|
102
104
|
});
|
|
@@ -31,26 +31,33 @@ function names(value) {
|
|
|
31
31
|
const source = record(value);
|
|
32
32
|
return boundedText(source.en, 120);
|
|
33
33
|
}
|
|
34
|
-
async function readStableMmdb(databasePath, openFile = open) {
|
|
34
|
+
async function readStableMmdb(databasePath, openFile = open, signal) {
|
|
35
35
|
let handle = null;
|
|
36
|
+
let database = null;
|
|
36
37
|
try {
|
|
38
|
+
signal?.throwIfAborted();
|
|
37
39
|
const resolvedPath = await realpath(databasePath);
|
|
40
|
+
signal?.throwIfAborted();
|
|
38
41
|
handle = await openFile(resolvedPath, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW);
|
|
42
|
+
signal?.throwIfAborted();
|
|
39
43
|
const before = await handle.stat();
|
|
44
|
+
signal?.throwIfAborted();
|
|
40
45
|
if (!before.isFile() || before.size <= 0 || before.size > MAX_LOCAL_MMDB_BYTES) {
|
|
41
46
|
throw new CliUsageError(`The supplied MMDB must be a file no larger than ${MAX_LOCAL_MMDB_BYTES} bytes.`);
|
|
42
47
|
}
|
|
43
48
|
// Dedicated backing storage can be transferred without copying a pooled
|
|
44
49
|
// buffer or exposing bytes outside the stable file's exact length.
|
|
45
|
-
|
|
50
|
+
database = Buffer.allocUnsafeSlow(before.size);
|
|
46
51
|
let offset = 0;
|
|
47
52
|
while (offset < database.length) {
|
|
48
53
|
const { bytesRead } = await handle.read(database, offset, database.length - offset, offset);
|
|
54
|
+
signal?.throwIfAborted();
|
|
49
55
|
if (bytesRead === 0)
|
|
50
56
|
break;
|
|
51
57
|
offset += bytesRead;
|
|
52
58
|
}
|
|
53
59
|
const after = await handle.stat();
|
|
60
|
+
signal?.throwIfAborted();
|
|
54
61
|
if (offset !== database.length
|
|
55
62
|
|| before.dev !== after.dev
|
|
56
63
|
|| before.ino !== after.ino
|
|
@@ -62,6 +69,8 @@ async function readStableMmdb(databasePath, openFile = open) {
|
|
|
62
69
|
return database;
|
|
63
70
|
}
|
|
64
71
|
catch (cause) {
|
|
72
|
+
database?.fill(0);
|
|
73
|
+
signal?.throwIfAborted();
|
|
65
74
|
if (cause instanceof CliUsageError)
|
|
66
75
|
throw cause;
|
|
67
76
|
throw new CliUsageError('The supplied MMDB file could not be read.');
|
|
@@ -238,11 +247,17 @@ if (!isMainThread && parentPort && record(workerData).kind === LOCAL_MMDB_WORKER
|
|
|
238
247
|
/** One disposable worker pre-empts the existing synchronous reader. It is not
|
|
239
248
|
* a sandbox and never receives a database path or inherited environment. */
|
|
240
249
|
export async function reviewLocalMmdb(input, databasePath, checkedAt = new Date().toISOString(), dependencies = {}) {
|
|
250
|
+
const { signal } = dependencies;
|
|
251
|
+
signal?.throwIfAborted();
|
|
241
252
|
const query = prepareQuery(input, checkedAt);
|
|
242
253
|
const deadlineMs = dependencies.deadlineMs ?? LOCAL_MMDB_REVIEW_DEADLINE_MS;
|
|
243
254
|
if (!Number.isSafeInteger(deadlineMs) || deadlineMs <= 0 || deadlineMs > LOCAL_MMDB_REVIEW_DEADLINE_MS)
|
|
244
255
|
throw new CliUsageError('Invalid local database processing deadline.');
|
|
245
|
-
const database = await readStableMmdb(databasePath, dependencies.openFile);
|
|
256
|
+
const database = await readStableMmdb(databasePath, dependencies.openFile, signal);
|
|
257
|
+
if (signal?.aborted) {
|
|
258
|
+
database.fill(0);
|
|
259
|
+
signal.throwIfAborted();
|
|
260
|
+
}
|
|
246
261
|
const identity = { sha256: createHash('sha256').update(database).digest('hex'), byteLength: database.length };
|
|
247
262
|
const options = { workerData: { kind: LOCAL_MMDB_WORKER_KIND, query, bytes: database, identity },
|
|
248
263
|
transferList: [database.buffer], env: {}, execArgv: [], stdout: true, stderr: true,
|
|
@@ -254,6 +269,7 @@ export async function reviewLocalMmdb(input, databasePath, checkedAt = new Date(
|
|
|
254
269
|
catch {
|
|
255
270
|
if (database.byteLength)
|
|
256
271
|
database.fill(0);
|
|
272
|
+
signal?.throwIfAborted();
|
|
257
273
|
if (query.version === 2)
|
|
258
274
|
return freezeResult(currentResult(query, identity, { metadata: null, ageDays: null, reason: 'invalid_metadata' }, 'processing_unavailable'));
|
|
259
275
|
throw new CliUsageError('The supplied database could not be reviewed within its format and processing bounds.');
|
|
@@ -268,17 +284,24 @@ export async function reviewLocalMmdb(input, databasePath, checkedAt = new Date(
|
|
|
268
284
|
return;
|
|
269
285
|
settled = true;
|
|
270
286
|
clearTimeout(timer);
|
|
271
|
-
|
|
287
|
+
signal?.removeEventListener('abort', onAbort);
|
|
288
|
+
if (signal?.aborted)
|
|
289
|
+
reject(signal.reason);
|
|
290
|
+
else if (result)
|
|
272
291
|
resolve(freezeResult(result));
|
|
273
292
|
else if (query.version === 2)
|
|
274
293
|
resolve(freezeResult(currentResult(query, identity, { metadata: null, ageDays: null, reason: 'invalid_metadata' }, reason)));
|
|
275
294
|
else
|
|
276
295
|
reject(new CliUsageError('The supplied database could not be reviewed within its format and processing bounds.'));
|
|
277
296
|
};
|
|
297
|
+
const onAbort = () => finish();
|
|
278
298
|
const timer = setTimeout(() => finish(undefined, 'processing_deadline'), deadlineMs);
|
|
279
299
|
worker.once('message', (reply) => reply && 'result' in reply ? finish(reply.result) : finish());
|
|
280
300
|
worker.once('error', () => finish());
|
|
281
301
|
worker.once('exit', () => finish());
|
|
302
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
303
|
+
if (signal?.aborted)
|
|
304
|
+
onAbort();
|
|
282
305
|
});
|
|
283
306
|
}
|
|
284
307
|
finally {
|
|
@@ -110,6 +110,7 @@ async function runLookupCommand(args, dependencies, context) {
|
|
|
110
110
|
const save = dependencies.writePrivateFile || writePrivateFile;
|
|
111
111
|
await save(args.saveLookup, serializeCliLookupDocument(document), {
|
|
112
112
|
existingFileMessage: 'Saved Lookup file already exists. Choose a new --save-lookup path or remove the existing file explicitly.',
|
|
113
|
+
beforePublish: async () => { dependencies.signal?.throwIfAborted(); },
|
|
113
114
|
});
|
|
114
115
|
context.writeStderr('Saved the completed private Lookup JSON. It can contain raw public registry and WHOIS responses plus target and collection context; review it before sharing.\n');
|
|
115
116
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
2
|
import { scanBoundedJson } from "../packages/analysis/bounded-json.mjs";
|
|
3
3
|
import { CONTEXT_INPUT_SCHEMAS, reviewContextInput } from "../packages/investigation/context-review.mjs";
|
|
4
|
-
import { CONTEXT_REVIEW_KINDS } from "../packages/contracts/context-review.mjs";
|
|
4
|
+
import { CONTEXT_REVIEW_KINDS, DOMAIN_HISTORY_INPUT_SCHEMA, DOMAIN_HISTORY_INPUT_VERSION } from "../packages/contracts/context-review.mjs";
|
|
5
5
|
import { normalizeEncryptedDnsAdapter, planEncryptedDnsQuery, } from "../lib/encrypted-dns-contract.mjs";
|
|
6
6
|
import { validateDnssecEvidence } from "../lib/dnssec-evidence-validation.mjs";
|
|
7
7
|
import { reviewDomainChange } from "../lib/domain-change-review.mjs";
|
|
@@ -18,6 +18,10 @@ import { compareTrustStoreEvidence } from "../lib/trust-store-comparison.mjs";
|
|
|
18
18
|
import { CliUsageError } from "./errors.mjs";
|
|
19
19
|
import { safeTerminalValue } from "./formatters/terminal.mjs";
|
|
20
20
|
import { LOCAL_MMDB_QUERY_SCHEMA, LOCAL_MMDB_QUERY_VERSION, LOCAL_MMDB_REVIEW_SCHEMA, LOCAL_MMDB_REVIEW_VERSION, reviewLocalMmdb } from "./local-mmdb-review.mjs";
|
|
21
|
+
import { INFRASTRUCTURE_OBSERVATION_SCHEMA, MAX_INFRASTRUCTURE_OBSERVATION_BYTES, infrastructureObservationFacts, compareInfrastructureObservations } from "../packages/investigation/infrastructure-observation.mjs";
|
|
22
|
+
import { exact } from "../packages/evidence/artifact-structure.mjs";
|
|
23
|
+
import { INFRASTRUCTURE_COMPARISON_INPUT_SCHEMA, MAX_INFRASTRUCTURE_COMPARISON_INPUT_BYTES } from "../packages/contracts/external-observation-interchange.mjs";
|
|
24
|
+
import { CASE_CONTAINMENT_INPUT_SCHEMA, readCaseContainmentInput, formatCaseContainmentHandoff } from "../packages/cases/case-containment-handoff.mjs";
|
|
21
25
|
const OFFLINE_EVIDENCE_REVIEW_SCHEMA = 'whoisleuth.cli.offline-evidence-review';
|
|
22
26
|
const OFFLINE_EVIDENCE_REVIEW_VERSION = 1;
|
|
23
27
|
const MAX_OFFLINE_EVIDENCE_INPUT_BYTES = 16 * 1024 * 1024;
|
|
@@ -46,7 +50,8 @@ function parseInput(value) {
|
|
|
46
50
|
}
|
|
47
51
|
const document = record(parsed);
|
|
48
52
|
if (typeof document.schema !== 'string' || document.version !== 1
|
|
49
|
-
&& !(document.schema === LOCAL_MMDB_QUERY_SCHEMA && document.version === LOCAL_MMDB_QUERY_VERSION)
|
|
53
|
+
&& !(document.schema === LOCAL_MMDB_QUERY_SCHEMA && document.version === LOCAL_MMDB_QUERY_VERSION)
|
|
54
|
+
&& !(document.schema === DOMAIN_HISTORY_INPUT_SCHEMA && document.version === DOMAIN_HISTORY_INPUT_VERSION)) {
|
|
50
55
|
throw new CliUsageError('Offline evidence review requires a supported versioned input schema.');
|
|
51
56
|
}
|
|
52
57
|
return document;
|
|
@@ -57,7 +62,27 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
|
|
|
57
62
|
throw new CliUsageError('Local MMDB review requires --mmdb <database-file>.');
|
|
58
63
|
let kind;
|
|
59
64
|
let result;
|
|
60
|
-
if (
|
|
65
|
+
if (input.schema === CASE_CONTAINMENT_INPUT_SCHEMA) {
|
|
66
|
+
kind = 'internal_containment';
|
|
67
|
+
result = readCaseContainmentInput(input, generatedAt);
|
|
68
|
+
}
|
|
69
|
+
else if (input.schema === INFRASTRUCTURE_OBSERVATION_SCHEMA) {
|
|
70
|
+
if (typeof value === 'string' && new TextEncoder().encode(value).byteLength > MAX_INFRASTRUCTURE_OBSERVATION_BYTES)
|
|
71
|
+
throw new TypeError('Infrastructure snapshot input exceeds its byte bound.');
|
|
72
|
+
kind = 'infrastructure';
|
|
73
|
+
const retained = infrastructureObservationFacts(input);
|
|
74
|
+
result = { ...retained, state: retained.snapshot.coverage.state === 'complete' && !retained.snapshot.coverage.truncated ? 'complete' : 'partial' };
|
|
75
|
+
}
|
|
76
|
+
else if (input.schema === INFRASTRUCTURE_COMPARISON_INPUT_SCHEMA) {
|
|
77
|
+
if (typeof value === 'string' && new TextEncoder().encode(value).byteLength > MAX_INFRASTRUCTURE_COMPARISON_INPUT_BYTES)
|
|
78
|
+
throw new TypeError('Infrastructure comparison input exceeds its byte bound.');
|
|
79
|
+
const pair = exact(input, ['schema', 'version', 'earlier', 'later'], 'Infrastructure comparison input');
|
|
80
|
+
if (new TextEncoder().encode(JSON.stringify(pair)).byteLength > MAX_INFRASTRUCTURE_COMPARISON_INPUT_BYTES)
|
|
81
|
+
throw new TypeError('Infrastructure comparison input exceeds its byte bound.');
|
|
82
|
+
kind = 'infrastructure_comparison';
|
|
83
|
+
result = compareInfrastructureObservations(pair.earlier, pair.later);
|
|
84
|
+
}
|
|
85
|
+
else if (CONTEXT_INPUT_SCHEMAS.includes(input.schema)) {
|
|
61
86
|
const review = reviewContextInput(input, generatedAt);
|
|
62
87
|
kind = review.kind;
|
|
63
88
|
result = review;
|
|
@@ -173,13 +198,15 @@ async function buildOfflineEvidenceReviewWithLocalResources(value, generatedAt =
|
|
|
173
198
|
version: OFFLINE_EVIDENCE_REVIEW_VERSION,
|
|
174
199
|
generatedAt,
|
|
175
200
|
kind: 'geoip',
|
|
176
|
-
result: await reviewLocalMmdb(input, options.mmdbPath, generatedAt),
|
|
201
|
+
result: await reviewLocalMmdb(input, options.mmdbPath, generatedAt, options.signal ? { signal: options.signal } : {}),
|
|
177
202
|
limitations: Object.freeze([
|
|
178
203
|
'The review is local and uses only the supplied query metadata and analyst-supplied database. It does not transmit the address or refresh the database.',
|
|
179
204
|
]),
|
|
180
205
|
});
|
|
181
206
|
}
|
|
182
207
|
function formatOfflineEvidenceReview(document) {
|
|
208
|
+
if (document.kind === 'internal_containment')
|
|
209
|
+
return formatCaseContainmentHandoff(document.result);
|
|
183
210
|
const result = record(document.result);
|
|
184
211
|
if (document.kind === 'geoip' && result.schema === LOCAL_MMDB_REVIEW_SCHEMA && result.version !== LOCAL_MMDB_REVIEW_VERSION) {
|
|
185
212
|
throw new CliUsageError('Offline evidence review does not support this MMDB result version.');
|
|
@@ -204,7 +231,26 @@ function formatOfflineEvidenceReview(document) {
|
|
|
204
231
|
`Kind ${document.kind.replaceAll('_', ' ')}`,
|
|
205
232
|
`State ${state.replaceAll('_', ' ')}`,
|
|
206
233
|
];
|
|
207
|
-
if (
|
|
234
|
+
if (document.kind === 'infrastructure') {
|
|
235
|
+
const snapshot = record(result.snapshot), scope = record(snapshot.scope), coverage = record(snapshot.coverage);
|
|
236
|
+
lines.push(`Snapshot ${safeTerminalValue(snapshot.id, '')} · ${safeTerminalValue(snapshot.target, '')} · ${safeTerminalValue(snapshot.observedAt, '')}`, `Coverage ${safeTerminalValue(coverage.state, 'unknown')} · ${safeTerminalValue(coverage.detail, '')}`, `Selected hosts ${listLength(scope.hostnames)}`);
|
|
237
|
+
for (const item of Array.isArray(result.facts) ? result.facts : []) {
|
|
238
|
+
const row = record(item), source = record(row.source);
|
|
239
|
+
lines.push(`${safeTerminalValue(row.hostname, '')} · ${safeTerminalValue(row.family, '')}: ${safeTerminalValue(row.value, '')} · ${safeTerminalValue(source.name, '')} (${safeTerminalValue(source.evidenceClass, '')}) · ${safeTerminalValue(row.observedAt, '')}`);
|
|
240
|
+
}
|
|
241
|
+
lines.push('Wildcard patterns are not enumerated hosts. DNS failure, missing values and history do not establish current absence. No requests or watchlist changes were made.');
|
|
242
|
+
}
|
|
243
|
+
else if (document.kind === 'infrastructure_comparison') {
|
|
244
|
+
for (const item of Array.isArray(result.rows) ? result.rows : []) {
|
|
245
|
+
const row = record(item), source = record(row.source);
|
|
246
|
+
// Bound individual identities, not the combined label: a valid DNS query
|
|
247
|
+
// and owner may each occupy all 253 hostname characters.
|
|
248
|
+
const cohort = Object.entries(record(row.cohort)).map(([key, value]) => `${safeTerminalValue(key)}=${safeTerminalValue(value, '', 253)}`).join(' · ');
|
|
249
|
+
const times = (value) => Array.isArray(value) ? value.map(time => safeTerminalValue(time, '')).join(', ') : '';
|
|
250
|
+
lines.push(`${safeTerminalValue(row.hostname, '')} · ${safeTerminalValue(row.family, '')} · ${safeTerminalValue(source.name, '')} (${safeTerminalValue(source.evidenceClass, '')}) · ${safeTerminalValue(row.state, '')}`, ` Cohort: ${cohort}`, ` Earlier observations: ${times(row.beforeTimes)}; later observations: ${times(row.afterTimes)}`, ` ${safeTerminalValue(JSON.stringify(row.before), '')} → ${safeTerminalValue(JSON.stringify(row.after), '')}`, ` ${safeTerminalValue(row.detail, '')}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
else if (CONTEXT_REVIEW_KINDS.includes(document.kind)) {
|
|
208
254
|
lines.push(safeTerminalValue(result.summary, ''));
|
|
209
255
|
for (const item of Array.isArray(result.observations) ? result.observations : []) {
|
|
210
256
|
const row = record(item);
|
package/cli/review-arguments.mjs
CHANGED
|
@@ -45,10 +45,10 @@ function parseCaseArguments(parsed) {
|
|
|
45
45
|
if (operation === 'note' ? text === null && !noteSource : text !== null || noteSource !== null) {
|
|
46
46
|
throw new CliUsageError('Only case note accepts and requires --text or --note-file.');
|
|
47
47
|
}
|
|
48
|
-
if (['pin', 'link', 'withdraw-link', 'assess', 'recheck'].includes(operation)
|
|
48
|
+
if (['pin', 'link', 'withdraw-link', 'assess', 'recheck', 'incident-link', 'action', 'action-event', 'recheck-question', 'close-object'].includes(operation)
|
|
49
49
|
? !inputSource
|
|
50
50
|
: inputSource !== null) {
|
|
51
|
-
throw new CliUsageError('
|
|
51
|
+
throw new CliUsageError('This Case mutation requires --input; show, open and note do not accept it.');
|
|
52
52
|
}
|
|
53
53
|
if (expectedFileDigest !== null && (!source || !/^sha256:[a-f0-9]{64}$/u.test(expectedFileDigest))) {
|
|
54
54
|
throw new CliUsageError('--expect-file-digest requires a source file and sha256:<64 lowercase hexadecimal characters>.');
|
|
@@ -95,6 +95,7 @@ export const REVIEW_ARGUMENT_PARSERS = Object.freeze({
|
|
|
95
95
|
action: 'intake',
|
|
96
96
|
kind: parsed.positionalValue('kind'),
|
|
97
97
|
reportedActions: parsed.optionValues('--reported-action'),
|
|
98
|
+
...(parsed.hasOption('--intake-context') ? { intakeContextSource: parsed.optionValue('--intake-context') } : {}),
|
|
98
99
|
...(parsed.hasOption('--trusted-auth-header')
|
|
99
100
|
? { trustedAuthHeaders: parsed.optionValues('--trusted-auth-header') }
|
|
100
101
|
: {}),
|
|
@@ -2,6 +2,8 @@ import { CASE_SCHEMA_VERSION, CLI_CASE_PACK_INPUT_CASE_VERSIONS, MAX_CASE_STORE_
|
|
|
2
2
|
import { MESSAGE_INTAKE_KINDS, MESSAGE_INTAKE_SCHEMA } from "../packages/contracts/message-intake.mjs";
|
|
3
3
|
import { IDENTITY_EVENTS_INPUT_SCHEMA } from "../packages/contracts/identity-events.mjs";
|
|
4
4
|
import { CONTEXT_INPUT_SCHEMAS, CONTEXT_REVIEW_SCHEMA } from "../packages/contracts/context-review.mjs";
|
|
5
|
+
import { CASE_CONTAINMENT_INPUT_SCHEMA, CASE_CONTAINMENT_HANDOFF_SCHEMA } from "../packages/cases/case-containment-handoff.mjs";
|
|
6
|
+
import { INTAKE_CONTEXT_SCHEMA } from "../packages/investigation/intake-context.mjs";
|
|
5
7
|
import { MANAGED_INDICATOR_SET_SCHEMA, MAX_MANAGED_INDICATOR_SET_BYTES, MAX_MANAGED_INDICATOR_PLAN_BYTES } from "../packages/contracts/analyst-interchange.mjs";
|
|
6
8
|
import { CLI_CASE_OPERATIONS, CLI_INDICATOR_OPERATIONS, positional, OPTIONAL_FILE_POSITIONAL, constraint, EMPTY_CONSTRAINTS, commandSeed } from "./command-definition.mjs";
|
|
7
9
|
export const REVIEW_COMMAND_DEFINITIONS = Object.freeze({
|
|
@@ -154,23 +156,23 @@ export const REVIEW_COMMAND_DEFINITIONS = Object.freeze({
|
|
|
154
156
|
}),
|
|
155
157
|
intake: commandSeed({
|
|
156
158
|
reference: {
|
|
157
|
-
description: 'Review
|
|
159
|
+
description: 'Review selected messages, indicators, document links, QR destinations, HTTP archives or identity events locally. Plain text offers phone candidates; --intake-context adds explicit selections and source declarations.',
|
|
158
160
|
example: 'whoisleuth intake email message.eml --json',
|
|
159
|
-
boundary: 'Offline only: no destination, embedded command or QR payload is opened or executed. Output omits original bodies, subjects, address local parts, URL paths, queries and fragments. Authentication headers remain attributed claims. Selected PDF and DOCX review retains part identities and extraction coverage; embedded raster QR support does not imply complete rendered-page review. HAR retains minimised request order and timings, never replay authority, headers, cookies or bodies. Use --strict-exit to return 4 for partial coverage.',
|
|
161
|
+
boundary: 'Offline only: no destination, embedded command, phone number or QR payload is opened, contacted or executed. Output omits original bodies, subjects, address local parts, URL paths, queries and fragments. Literal IPs and labelled hashes are bounded text observations, not lookup targets or verdicts; headers, URL contents, QR, HAR and identity-event fields are not scanned for them. Plain-text phone discovery writes only counts, source digest, ranges and status to stderr; --quiet suppresses it. Optional --intake-context reads one separate file up to 8 KiB, not stdin. Version 1 carries distribution declarations; version 2 can also bind explicit phone selections and a supplied destination pair to the exact input digest. Selected phone strings and source declarations enter output; unselected candidates and exact private URLs do not. Authentication headers remain attributed claims. Selected PDF and DOCX review retains part identities and extraction coverage; embedded raster QR support does not imply complete rendered-page review. HAR retains minimised request order and timings, never replay authority, headers, cookies or bodies. Use --strict-exit to return 4 for partial coverage.',
|
|
160
162
|
},
|
|
161
|
-
collection: { scope: 'Reads one selected text, MIME email, calendar, PNG, PDF, DOCX, HAR or identity-event JSON file. No collection or automatic Case write.' },
|
|
163
|
+
collection: { scope: 'Reads one selected text, MIME email, calendar, PNG, PDF, DOCX, HAR or identity-event JSON file and, only with --intake-context, one bounded declaration/selection file. No collection or automatic Case write.' },
|
|
162
164
|
summary: 'Review selected files and identity events offline',
|
|
163
|
-
options: ['--json', '--reported-action', '--trusted-auth-header', '--strict-exit', '--quiet', '--no-color'],
|
|
165
|
+
options: ['--json', '--reported-action', '--trusted-auth-header', '--intake-context', '--strict-exit', '--quiet', '--no-color'],
|
|
164
166
|
positionals: Object.freeze([positional('kind', 'enum', 1, 1, MESSAGE_INTAKE_KINDS), positional('source', 'file', 0, 1, [], 'argv_or_stdin')]),
|
|
165
167
|
constraints: EMPTY_CONSTRAINTS, handlerOwner: 'review', networkEffect: 'offline', common: true,
|
|
166
|
-
schemaIdentifiers: Object.freeze([MESSAGE_INTAKE_SCHEMA, IDENTITY_EVENTS_INPUT_SCHEMA]), primaryArtefacts: Object.freeze([]),
|
|
168
|
+
schemaIdentifiers: Object.freeze([MESSAGE_INTAKE_SCHEMA, INTAKE_CONTEXT_SCHEMA, IDENTITY_EVENTS_INPUT_SCHEMA]), primaryArtefacts: Object.freeze([]),
|
|
167
169
|
planSupport: false, additionalOutputFormats: Object.freeze([]), bootstrapProfile: 'allowed',
|
|
168
170
|
}),
|
|
169
171
|
"review-evidence": commandSeed({
|
|
170
172
|
reference: {
|
|
171
|
-
description: 'Review versioned protocol evidence, incident sequences, domain history, platform objects, storefronts
|
|
173
|
+
description: 'Review versioned protocol evidence, source-qualified infrastructure snapshots/comparisons, incident sequences, domain history, platform objects, storefronts, connector configuration or an explicitly selected internal containment handoff offline.',
|
|
172
174
|
example: 'whoisleuth review-evidence domain-change.json --json --strict-exit',
|
|
173
|
-
boundary: 'The command reads only the supplied document and, with --mmdb, one explicitly selected local database up to 512 MiB. MMDB parsing and lookup run in one disposable worker with a ten-second processing deadline and an 8 KiB result limit. Version-2 MMDB queries require a justified age policy; unavailable or partial current reviews return 4 under --strict-exit. Historical version-1 MMDB queries preserve their output without freshness admission. No database is bundled, downloaded, updated or transmitted. It performs no DNS, RDAP, BGP, GeoIP-provider, TLS, HTTP, certificate-authority, or SMTP request.',
|
|
175
|
+
boundary: 'The command reads only the supplied document and, with --mmdb, one explicitly selected local database up to 512 MiB. MMDB parsing and lookup run in one disposable worker with a ten-second processing deadline and an 8 KiB result limit. Version-2 MMDB queries require a justified age policy; unavailable or partial current reviews return 4 under --strict-exit. Historical version-1 MMDB queries preserve their output without freshness admission. Domain-history v2 accepts explicit source-linked registration boundaries; v1 retains its original semantics. Internal containment selects retained next-step assertions and supporting pins after explicit audience-disclosure review; it does not change their state or execute controls. No database is bundled, downloaded, updated or transmitted. It performs no DNS, RDAP, BGP, GeoIP-provider, TLS, HTTP, certificate-authority, or SMTP request.',
|
|
174
176
|
},
|
|
175
177
|
collection: { scope: 'Reads one bounded versioned evidence or request-planning document and, only with --mmdb, one selected bounded local database. Performs no collection.' },
|
|
176
178
|
summary: 'Review supplied evidence offline',
|
|
@@ -182,6 +184,8 @@ export const REVIEW_COMMAND_DEFINITIONS = Object.freeze({
|
|
|
182
184
|
common: true,
|
|
183
185
|
schemaIdentifiers: Object.freeze([
|
|
184
186
|
CONTEXT_REVIEW_SCHEMA,
|
|
187
|
+
CASE_CONTAINMENT_INPUT_SCHEMA,
|
|
188
|
+
CASE_CONTAINMENT_HANDOFF_SCHEMA,
|
|
185
189
|
...CONTEXT_INPUT_SCHEMAS,
|
|
186
190
|
'whoisleuth.cli.offline-evidence-review',
|
|
187
191
|
'whoisleuth.rdap-search-input',
|
|
@@ -192,6 +196,8 @@ export const REVIEW_COMMAND_DEFINITIONS = Object.freeze({
|
|
|
192
196
|
'whoisleuth.local-mmdb-query',
|
|
193
197
|
'whoisleuth.local-mmdb-review',
|
|
194
198
|
'whoisleuth.encrypted-dns-plan-input',
|
|
199
|
+
'whoisleuth.infrastructure-observation',
|
|
200
|
+
'whoisleuth.infrastructure-comparison.input',
|
|
195
201
|
]),
|
|
196
202
|
primaryArtefacts: Object.freeze([]),
|
|
197
203
|
planSupport: false,
|
|
@@ -220,7 +226,7 @@ export const REVIEW_COMMAND_DEFINITIONS = Object.freeze({
|
|
|
220
226
|
}),
|
|
221
227
|
case: commandSeed({
|
|
222
228
|
reference: {
|
|
223
|
-
description: 'Show or open a local Case, append a note or evidence pin, link evidence,
|
|
229
|
+
description: 'Show or open a local Case, append a note or evidence pin, link evidence, record an assessment or offline recheck, retain an incident-link, create an action, append an action-event, save a recheck-question, or close-object. JSON mutations use --input; notes use --text or --note-file. Link input contains fromPinId, toPinId, kind and basis. Action-event input contains id and transition. Explicit responseObjects bind an action; a typed objectOutcome requires an explicit affected subset on its event. Object closure never closes other objects or the Case. Mutations require --output and write the complete current Case export.',
|
|
224
230
|
example: 'whoisleuth case open --domain example.test --output cases.json\n whoisleuth case show cases.json\n whoisleuth case note cases.json --text "Review the retained observation" --output cases.json --force',
|
|
225
231
|
boundary: 'No database, browser launch, request or external report is created. Select --case-id when a file contains multiple Cases. Existing files require --force; --expect-file-digest sha256:<digest> additionally checks the exact file reviewed earlier. Source and output leases reject concurrent changes. Interrupted .workflow.lock files require deliberate inspection. Recheck records supplied observations; it does not collect them. Not reproduced requires an existing saved question, a complete observation and comparable conditions. Working exports include private analyst content and file references, not attached file bytes.',
|
|
226
232
|
},
|
|
@@ -192,8 +192,9 @@ async function runOfflineEvidenceReviewCommand(args, dependencies, context) {
|
|
|
192
192
|
? await dependencies.readArtifactInput(args.source)
|
|
193
193
|
: await context.readInput(args.source, MAX_OFFLINE_EVIDENCE_INPUT_BYTES, 'Offline evidence input'), 'offline evidence input', 'review-evidence requires one JSON file or a document on stdin.');
|
|
194
194
|
const document = args.mmdbSource
|
|
195
|
-
? await buildOfflineEvidenceReviewWithLocalResources(input, context.now(), { mmdbPath: args.mmdbSource })
|
|
195
|
+
? await buildOfflineEvidenceReviewWithLocalResources(input, context.now(), { mmdbPath: args.mmdbSource, ...(dependencies.signal ? { signal: dependencies.signal } : {}) })
|
|
196
196
|
: buildOfflineEvidenceReview(input, context.now());
|
|
197
|
+
dependencies.signal?.throwIfAborted();
|
|
197
198
|
writeReviewReport(context, args, document, formatOfflineEvidenceReview);
|
|
198
199
|
if (args.strictExit) {
|
|
199
200
|
const result = document.result && typeof document.result === 'object' && !Array.isArray(document.result)
|
|
@@ -205,9 +206,11 @@ async function runOfflineEvidenceReviewCommand(args, dependencies, context) {
|
|
|
205
206
|
const zoneMismatch = document.kind === 'zone_intent' && (result.complete !== true
|
|
206
207
|
|| (result.counts && typeof result.counts === 'object' && !Array.isArray(result.counts)
|
|
207
208
|
&& ['different', 'missing', 'unexpected', 'incomplete'].some((key) => Number(result.counts[key]) > 0)));
|
|
208
|
-
const contextPartial = CONTEXT_REVIEW_KINDS.includes(document.kind) && result.state === 'partial';
|
|
209
|
+
const contextPartial = (CONTEXT_REVIEW_KINDS.includes(document.kind) || document.kind === 'internal_containment') && result.state === 'partial';
|
|
209
210
|
const mmdbIncomplete = result.schema === LOCAL_MMDB_REVIEW_SCHEMA && result.version === LOCAL_MMDB_REVIEW_VERSION && result.completeness !== 'complete';
|
|
210
|
-
|
|
211
|
+
const infrastructureIncomplete = document.kind === 'infrastructure' ? result.state !== 'complete'
|
|
212
|
+
: document.kind === 'infrastructure_comparison' && result.state !== 'compared';
|
|
213
|
+
if (gate?.pass === false || zoneMismatch || contextPartial || mmdbIncomplete || infrastructureIncomplete)
|
|
211
214
|
return EXIT_CODES.PARTIAL_FAILURE;
|
|
212
215
|
}
|
|
213
216
|
return EXIT_CODES.SUCCESS;
|
package/cli/risk-calibration.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { scanBoundedJson } from "../packages/analysis/bounded-json.mjs";
|
|
|
5
5
|
import { canonicalRegistrableDomain } from "../packages/analysis/registrable-domain.mjs";
|
|
6
6
|
import { ANALYST_REVIEW_REASON_VALUES, analystInteroperabilityTags } from "../packages/analysis/analyst-taxonomy.mjs";
|
|
7
7
|
import { buildRiskCalibrationSummaryReport, parseRiskCalibrationSummaryReport, RISK_CALIBRATION_SUMMARY_INTERPRETATION, RISK_CALIBRATION_SUMMARY_THRESHOLDS, } from "../packages/analysis/risk-calibration-summary.mjs";
|
|
8
|
-
import { MAX_RISK_CALIBRATION_ACTIVITY_LENGTH, MAX_RISK_CALIBRATION_AVAILABILITY_LENGTH, MAX_RISK_CALIBRATION_DISPOSITION_LENGTH, MAX_RISK_CALIBRATION_DOMAIN_AGE_DAYS, MAX_RISK_CALIBRATION_DOMAIN_LENGTH, MAX_RISK_CALIBRATION_FINDING_CATEGORY_LENGTH, MAX_RISK_CALIBRATION_FINDINGS_PER_PROVIDER, MAX_RISK_CALIBRATION_INPUT_BYTES, MAX_RISK_CALIBRATION_JSON_CONTAINER_ITEMS, MAX_RISK_CALIBRATION_JSON_DEPTH, MAX_RISK_CALIBRATION_JSON_KEYS, MAX_RISK_CALIBRATION_JSON_VALUES, MAX_RISK_CALIBRATION_MUTATIONS, MAX_RISK_CALIBRATION_PROVIDER_ID_LENGTH, MAX_RISK_CALIBRATION_PROVIDER_STATE_LENGTH, MAX_RISK_CALIBRATION_PROVIDERS, MAX_RISK_CALIBRATION_RECORD_ID_LENGTH, MAX_RISK_CALIBRATION_RECORDS, MAX_RISK_CALIBRATION_REVIEW_REASON_LENGTH, MAX_RISK_CALIBRATION_SCAN_DEPTH_LENGTH, MAX_RISK_CALIBRATION_STRING_LENGTH, MAX_RISK_CALIBRATION_TIMESTAMP_LENGTH, RISK_CALIBRATION_DATASET_SCHEMA,
|
|
8
|
+
import { MAX_RISK_CALIBRATION_ACTIVITY_LENGTH, MAX_RISK_CALIBRATION_AVAILABILITY_LENGTH, MAX_RISK_CALIBRATION_DISPOSITION_LENGTH, MAX_RISK_CALIBRATION_DOMAIN_AGE_DAYS, MAX_RISK_CALIBRATION_DOMAIN_LENGTH, MAX_RISK_CALIBRATION_FINDING_CATEGORY_LENGTH, MAX_RISK_CALIBRATION_FINDINGS_PER_PROVIDER, MAX_RISK_CALIBRATION_INPUT_BYTES, MAX_RISK_CALIBRATION_JSON_CONTAINER_ITEMS, MAX_RISK_CALIBRATION_JSON_DEPTH, MAX_RISK_CALIBRATION_JSON_KEYS, MAX_RISK_CALIBRATION_JSON_VALUES, MAX_RISK_CALIBRATION_MUTATIONS, MAX_RISK_CALIBRATION_PROVIDER_ID_LENGTH, MAX_RISK_CALIBRATION_PROVIDER_STATE_LENGTH, MAX_RISK_CALIBRATION_PROVIDERS, MAX_RISK_CALIBRATION_RECORD_ID_LENGTH, MAX_RISK_CALIBRATION_RECORDS, MAX_RISK_CALIBRATION_REVIEW_REASON_LENGTH, MAX_RISK_CALIBRATION_SCAN_DEPTH_LENGTH, MAX_RISK_CALIBRATION_STRING_LENGTH, MAX_RISK_CALIBRATION_TIMESTAMP_LENGTH, RISK_CALIBRATION_DATASET_SCHEMA, RISK_CALIBRATION_REPORT_SCHEMA, RISK_CALIBRATION_REPORT_VERSION, RISK_CALIBRATION_DISPOSITIONS, serializeRiskCalibrationSnapshot, snapshotRiskCalibrationReportForSerialization, SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS, } from "../packages/contracts/risk-calibration.mjs";
|
|
9
9
|
import { CliUsageError } from "./errors.mjs";
|
|
10
10
|
import { RISK_MUTATION_TYPES } from "../packages/analysis/risk-scoring.mjs";
|
|
11
11
|
import { THREAT_INTELLIGENCE_CONTRACT_VERSION, THREAT_INTELLIGENCE_ENVELOPE_VERSION, THREAT_INTELLIGENCE_SCHEMA, } from "../packages/analysis/threat-intelligence-types.mjs";
|
|
@@ -17,7 +17,7 @@ const AVAILABILITY_STATES = new Set(['registered', 'for_sale', 'expiring', 'avai
|
|
|
17
17
|
const ACTIVITY_STATES = new Set(['active', 'parked', 'unreachable', 'no_site']);
|
|
18
18
|
const BOOLEAN_FIELDS = [
|
|
19
19
|
'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField',
|
|
20
|
-
'hasMx', 'hasSpf', 'hasDmarc', 'privacyProtected', 'hasExternalFormAction',
|
|
20
|
+
'hasMx', 'hasSpf', 'hasDmarc', 'privacyProtected', 'hasExternalFormAction', 'hasExternalPasswordForm',
|
|
21
21
|
'idnReferenceMatch', 'pageBaselineMatch', 'hasActiveBrandProfile',
|
|
22
22
|
];
|
|
23
23
|
const REVIEW_REASON_CODES = ANALYST_REVIEW_REASON_VALUES;
|
|
@@ -95,8 +95,10 @@ function projectThreatIntelligence(value, field) {
|
|
|
95
95
|
}),
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
|
-
function projectEvidence(value, field) {
|
|
98
|
+
function projectEvidence(value, field, version) {
|
|
99
99
|
const source = object(value, field);
|
|
100
|
+
if (version < 3 && Object.hasOwn(source, 'hasExternalPasswordForm'))
|
|
101
|
+
throw new CliUsageError(`${field}.hasExternalPasswordForm requires dataset version 3; historical inputs were not reinterpreted.`);
|
|
100
102
|
const availability = boundedString(source.availability ?? source.state, `${field}.availability`, MAX_RISK_CALIBRATION_AVAILABILITY_LENGTH);
|
|
101
103
|
if (!AVAILABILITY_STATES.has(availability))
|
|
102
104
|
throw new CliUsageError(`${field}.availability is unsupported.`);
|
|
@@ -234,7 +236,7 @@ export function parseRiskCalibrationDataset(text) {
|
|
|
234
236
|
domain,
|
|
235
237
|
analystDisposition,
|
|
236
238
|
...(reviewReasonCode ? { reviewReasonCode } : {}),
|
|
237
|
-
evidence: projectEvidence(record.evidence, `${prefix}.evidence
|
|
239
|
+
evidence: projectEvidence(record.evidence, `${prefix}.evidence`, documentVersion),
|
|
238
240
|
};
|
|
239
241
|
});
|
|
240
242
|
return {
|
|
@@ -3,6 +3,8 @@ import { reviewSelectedInput } from "../packages/investigation/selected-input-re
|
|
|
3
3
|
import { assertMessageBytes } from "../packages/investigation/intake-report.mjs";
|
|
4
4
|
import { DOCUMENT_REVIEW_DEADLINE_MS } from "../packages/contracts/document-intake.mjs";
|
|
5
5
|
import { MESSAGE_INTAKE_SCHEMA, MESSAGE_INTAKE_VERSION } from "../packages/contracts/message-intake.mjs";
|
|
6
|
+
import { validateIntakeExtensions } from "../packages/investigation/intake-context.mjs";
|
|
7
|
+
import { validatePhoneReview } from "../packages/investigation/intake-phones.mjs";
|
|
6
8
|
if (!isMainThread && parentPort) {
|
|
7
9
|
const port = parentPort, request = workerData;
|
|
8
10
|
try {
|
|
@@ -45,8 +47,16 @@ export async function reviewSelectedInputInWorker(bytes, kind, reviewedAt, signa
|
|
|
45
47
|
const abort = () => finish(undefined, new Error('Selected input review was cancelled. Nothing was saved.'));
|
|
46
48
|
const timer = setTimeout(() => finish(undefined, new Error('Selected input review exceeded its processing deadline. Nothing was saved.')), DOCUMENT_REVIEW_DEADLINE_MS);
|
|
47
49
|
worker.once('message', (reply) => {
|
|
48
|
-
if (reply && typeof reply === 'object' && 'result' in reply && reply.result?.report.schema === MESSAGE_INTAKE_SCHEMA && reply.result.report.schemaVersion === MESSAGE_INTAKE_VERSION)
|
|
49
|
-
|
|
50
|
+
if (reply && typeof reply === 'object' && 'result' in reply && reply.result?.report.schema === MESSAGE_INTAKE_SCHEMA && reply.result.report.schemaVersion === MESSAGE_INTAKE_VERSION) {
|
|
51
|
+
try {
|
|
52
|
+
validateIntakeExtensions(reply.result.report);
|
|
53
|
+
validatePhoneReview(reply.result);
|
|
54
|
+
finish(reply.result);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
finish(undefined, new Error('Selected input returned an unsupported review. Nothing was retained.'));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
50
60
|
else
|
|
51
61
|
finish();
|
|
52
62
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { REGISTRY_CAPABILITIES_VERSION, registryCapabilityFor } from "../lib/registry-capabilities.mjs";
|
|
2
2
|
import { buildRiskCalibrationSummaryReport } from "../packages/analysis/risk-calibration-summary.mjs";
|
|
3
|
-
import { explainRiskScore,
|
|
3
|
+
import { explainRiskScore, explainRiskScoreV8, RISK_MODEL_VERSION, RISK_REVIEW_THRESHOLD } from "../packages/analysis/risk-scoring.mjs";
|
|
4
4
|
import { buildCliCommandCatalogue, formatCliCommandCatalogue, selectCliCommands } from "./command-catalogue.mjs";
|
|
5
5
|
import { CLI_COMMAND_REGISTRY, CLI_COMMANDS, COMMAND_COLLECTION, COMMAND_DETAILS, COMMAND_USAGE, } from "./command-reference.mjs";
|
|
6
6
|
import { buildShellCompletion } from "./completion.mjs";
|
|
@@ -169,8 +169,8 @@ async function runRiskCalibrationCommand(args, dependencies, context) {
|
|
|
169
169
|
modelVersion: dependencies.riskModelVersion || RISK_MODEL_VERSION,
|
|
170
170
|
reviewThreshold: dependencies.riskReviewThreshold || RISK_REVIEW_THRESHOLD,
|
|
171
171
|
...(!dependencies.explainRiskScore ? {
|
|
172
|
-
previousModelVersion:
|
|
173
|
-
explainPreviousRiskScore:
|
|
172
|
+
previousModelVersion: 8,
|
|
173
|
+
explainPreviousRiskScore: explainRiskScoreV8,
|
|
174
174
|
} : {}),
|
|
175
175
|
});
|
|
176
176
|
if (!args.quiet) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { parseBoundedJson } from "../packages/analysis/bounded-json.mjs";
|
|
2
|
+
import { applyCandidateWatchHandoff, planCandidateWatchHandoff, } from "../packages/workspace/candidate-watch-handoff.mjs";
|
|
3
|
+
import { buildWatchlistExport, mergeWatchlistStores, } from "../packages/workspace/watchlist-store.mjs";
|
|
4
|
+
import { MAX_WATCHLIST_IMPORT_BYTES } from "../packages/contracts/workspace-portability.mjs";
|
|
5
|
+
import { ordinaryWorkspaceRecord } from "../packages/workspace/hostile-input.mjs";
|
|
6
|
+
import { CANDIDATE_WATCH_INPUT_SCHEMA, CANDIDATE_WATCH_INPUT_VERSION, } from "../packages/contracts/candidate-watch-review.mjs";
|
|
7
|
+
export function parseCandidateWatchInput(input) {
|
|
8
|
+
const value = typeof input === 'string'
|
|
9
|
+
? parseBoundedJson(input, {
|
|
10
|
+
maximumBytes: MAX_WATCHLIST_IMPORT_BYTES,
|
|
11
|
+
label: 'Candidate watch selection',
|
|
12
|
+
})
|
|
13
|
+
: input;
|
|
14
|
+
const raw = ordinaryWorkspaceRecord(value, 'Candidate watch selection');
|
|
15
|
+
if (!raw ||
|
|
16
|
+
raw.schema !== CANDIDATE_WATCH_INPUT_SCHEMA ||
|
|
17
|
+
raw.version !== CANDIDATE_WATCH_INPUT_VERSION ||
|
|
18
|
+
Object.keys(raw).some((key) => !['schema', 'version', 'watchlists', 'selection'].includes(key)))
|
|
19
|
+
throw new TypeError('Use candidate-watch-input schema 1 with watchlists and an explicit selection.');
|
|
20
|
+
const selection = ordinaryWorkspaceRecord(raw.selection, 'Candidate watch selection');
|
|
21
|
+
if (!selection ||
|
|
22
|
+
Object.keys(selection).some((key) => ![
|
|
23
|
+
'name',
|
|
24
|
+
'candidates',
|
|
25
|
+
'brandProfileId',
|
|
26
|
+
'priority',
|
|
27
|
+
'reason',
|
|
28
|
+
'reviewDueAt',
|
|
29
|
+
'replaceExistingContext',
|
|
30
|
+
].includes(key)))
|
|
31
|
+
throw new TypeError('The candidate selection contains undeclared fields.');
|
|
32
|
+
const local = raw.watchlists === null ? {} : mergeWatchlistStores({}, raw.watchlists).watchlists;
|
|
33
|
+
planCandidateWatchHandoff(local, selection);
|
|
34
|
+
return raw;
|
|
35
|
+
}
|
|
36
|
+
export function reviewCandidateWatchInput(input, operation, now) {
|
|
37
|
+
const raw = parseCandidateWatchInput(input), selection = raw.selection;
|
|
38
|
+
const local = raw.watchlists === null ? {} : mergeWatchlistStores({}, raw.watchlists).watchlists;
|
|
39
|
+
if (operation === 'plan')
|
|
40
|
+
return planCandidateWatchHandoff(local, selection);
|
|
41
|
+
const result = applyCandidateWatchHandoff(local, selection, now);
|
|
42
|
+
return buildWatchlistExport(result.watchlists, now);
|
|
43
|
+
}
|