@slicedearth/whoisleuth-cli 2.0.1 → 2.2.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/cli/arguments.mjs +21 -5
- package/cli/artifact-validation/case-response.mjs +771 -0
- package/cli/artifact-validation/investigation-capsule.mjs +426 -0
- package/cli/artifact-validation/lookup-evidence.mjs +718 -0
- package/cli/artifact-validation/signed-review.mjs +598 -0
- package/cli/artifact-validation/structure-primitives.mjs +105 -0
- package/cli/artifact-verify.mjs +1 -1
- package/cli/assurance-command-runner.mjs +324 -0
- package/cli/command-reference.mjs +1175 -843
- package/cli/completion.mjs +56 -9
- package/cli/discriminated-command-handlers.mjs +11 -0
- package/cli/doctor.mjs +2 -2
- package/cli/evidence-command-runner.mjs +41 -31
- package/cli/export-evidence.mjs +1 -1
- package/cli/fail-policy.mjs +33 -8
- package/cli/formatters/terminal-command-formats.mjs +437 -0
- package/cli/formatters/terminal-lookup.mjs +758 -0
- package/cli/formatters/terminal-metadata.mjs +58 -0
- package/cli/formatters/terminal-shared.mjs +83 -0
- package/cli/formatters/terminal.mjs +5 -1232
- package/cli/history-command-runner.mjs +132 -0
- package/cli/inline-command-families.mjs +45 -0
- package/cli/inline-command-runner.mjs +55 -0
- package/cli/investigation-plan.mjs +9 -2
- package/cli/investigation-run.mjs +114 -9
- package/cli/lookup-brief.mjs +141 -23
- package/cli/lookup-browser-view.mjs +289 -0
- package/cli/lookup-browser.mjs +3 -288
- package/cli/mail-review.mjs +2 -6
- package/cli/offline-artifact-validation.mjs +19 -0
- package/cli/review-command-runner.mjs +269 -0
- package/cli/runner.mjs +15 -972
- package/cli/saved-lookup.mjs +19 -2
- package/cli/support-command-runner.mjs +229 -0
- package/cli/workflow-command-runner.mjs +129 -0
- package/docs/cli-reference.md +16 -9
- package/docs/cli.md +16 -2
- package/lib/availability.mjs +12 -9
- package/lib/bounded-contract-normalizers.mjs +2 -9
- package/lib/browser-library-profile.mjs +1 -2
- package/lib/client-behavior-profile.mjs +1 -2
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/domain-posture-analysis.mjs +10 -1
- package/lib/domain-posture.mjs +51 -23
- package/lib/evidence-export.mjs +47 -5
- package/lib/evidence-report-markdown.mjs +4 -0
- package/lib/evidence-report.mjs +50 -0
- package/lib/generated/cisa-kev-catalog.mjs +34 -4
- package/lib/generated/registrar-standing-catalogue.mjs +367 -0
- package/lib/json-record.mjs +12 -0
- package/lib/lookup-child-profile-contract.mjs +858 -0
- package/lib/lookup-contract-primitives.mjs +98 -0
- package/lib/lookup-diagnostics.mjs +96 -0
- package/lib/lookup-network-evidence-bounds.mjs +1 -0
- package/lib/lookup-response.mjs +367 -0
- package/lib/lookup.mjs +28 -443
- package/lib/page-role-profile.mjs +1 -3
- package/lib/registrar-standing-catalogue-contract.mjs +70 -0
- package/lib/registrar-standing-contract.mjs +300 -0
- package/lib/registrar-standing.mjs +269 -0
- package/lib/registry-insights.mjs +2 -8
- package/lib/risk-scoring.mjs +26 -10
- package/lib/sslbl-certificates.generated.mjs +138 -136
- package/lib/structured-data-identity.mjs +1 -3
- package/lib/technology-evidence-role.mjs +51 -0
- package/lib/threat-intelligence-runtime.mjs +3 -3
- package/lib/threat-intelligence-types.mjs +13 -0
- package/lib/threatfox-intelligence.mjs +1 -3
- package/lib/tls-intelligence.mjs +1 -2
- package/lib/urlhaus-intelligence.mjs +1 -3
- package/lib/urlscan-intelligence.mjs +1 -3
- package/lib/website-security-posture.mjs +1 -2
- package/lib/website-technology.mjs +72 -23
- package/lib/whois-common-formats.mjs +276 -0
- package/lib/whois-dialects.mjs +534 -0
- package/lib/whois-parser-support.mjs +305 -0
- package/lib/whois-parser.mjs +135 -0
- package/lib/whois.mjs +6 -1187
- package/package.json +2 -2
- package/packages/cases/case-evidence-model.mjs +9 -0
- package/packages/cases/case-migration-model.mjs +17 -11
- package/packages/cases/case-record-operations.mjs +235 -0
- package/packages/cases/case-response-model.mjs +13 -9
- package/packages/cases/case-response-packet.mjs +297 -112
- package/packages/comparison/page-similarity.mjs +23 -0
- package/packages/contracts/case-portability.mjs +235 -61
- package/packages/contracts/cli-lookup.mjs +4 -3
- package/packages/contracts/domain-control-manifest.mjs +1 -1
- package/packages/contracts/investigation-portability.mjs +1 -1
- package/packages/contracts/risk-calibration.mjs +2 -2
- package/packages/contracts/workspace-portability.mjs +34 -6
- package/packages/interchange/external-findings-import.mjs +113 -45
- package/packages/investigation/evidence-coverage-ledger.mjs +21 -0
- package/packages/investigation/lookup-claim-readiness.mjs +2 -2
- package/packages/workspace/website-snapshot-model.mjs +79 -6
- package/third-party-notices.txt +1 -1
- package/cli/artifact-structure.mjs +0 -2507
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
|
|
3
|
+
import EXIT_CODES from "./exit-codes.mjs";
|
|
4
|
+
import { buildCliEvidenceExport, formatCliEvidenceExport } from "./export-evidence.mjs";
|
|
5
|
+
import { formatJsonDocument } from "./formatters/json.mjs";
|
|
6
|
+
import { formatLookupEvidenceHtml } from "./formatters/html.mjs";
|
|
7
|
+
import { formatLookupEvidenceMarkdown } from "./formatters/markdown.mjs";
|
|
8
|
+
import { MAX_LOOKUP_RECONCILIATION_INPUT_BYTES, buildCliLookupReconciliation, formatCliLookupReconciliation, } from "./lookup-reconcile.mjs";
|
|
9
|
+
import { MAX_LOOKUP_TIMELINE_INPUT_BYTES, buildCliLookupTimeline, formatCliLookupTimeline, } from "./lookup-timeline.mjs";
|
|
10
|
+
import { MAX_RETAINED_ARTIFACT_DIFF_BYTES, buildCliRetainedArtifactDiff, formatCliRetainedArtifactDiff, } from "./retained-artifact-diff.mjs";
|
|
11
|
+
import { MAX_SAVED_LOOKUP_INPUT_BYTES } from "./saved-lookup.mjs";
|
|
12
|
+
import { HISTORY_INLINE_COMMANDS } from "./inline-command-families.mjs";
|
|
13
|
+
import { runDiscriminatedCommandHandler } from "./discriminated-command-handlers.mjs";
|
|
14
|
+
async function runRetainedDiffCommand(args, dependencies, context) {
|
|
15
|
+
context.setFailureLabel('Retained artifact diff');
|
|
16
|
+
const readDiffInput = dependencies.readDiffInput
|
|
17
|
+
|| ((source) => context.readInput(source, MAX_RETAINED_ARTIFACT_DIFF_BYTES, 'Retained diff input'));
|
|
18
|
+
let leftInput;
|
|
19
|
+
let rightInput;
|
|
20
|
+
try {
|
|
21
|
+
[leftInput, rightInput] = await Promise.all([
|
|
22
|
+
readDiffInput(args.leftSource),
|
|
23
|
+
readDiffInput(args.rightSource),
|
|
24
|
+
]);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (error instanceof CliUsageError)
|
|
28
|
+
throw error;
|
|
29
|
+
throw new CliUsageError(`Could not read retained diff input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
30
|
+
}
|
|
31
|
+
const document = buildCliRetainedArtifactDiff(leftInput, rightInput, { leftSessionId: args.leftSessionId, rightSessionId: args.rightSessionId }, context.now());
|
|
32
|
+
if (!args.quiet) {
|
|
33
|
+
context.writeStdout(args.output === 'json'
|
|
34
|
+
? formatJsonDocument(document)
|
|
35
|
+
: context.terminal(formatCliRetainedArtifactDiff(document), args.color));
|
|
36
|
+
}
|
|
37
|
+
return EXIT_CODES.SUCCESS;
|
|
38
|
+
}
|
|
39
|
+
async function runReconciliationCommand(args, dependencies, context) {
|
|
40
|
+
context.setFailureLabel('Lookup observation reconciliation');
|
|
41
|
+
const readReconciliationInput = dependencies.readDiffInput
|
|
42
|
+
|| ((source) => context.readInput(source, MAX_SAVED_LOOKUP_INPUT_BYTES, 'Lookup reconciliation input'));
|
|
43
|
+
const inputs = [];
|
|
44
|
+
let totalBytes = 0;
|
|
45
|
+
try {
|
|
46
|
+
for (const source of args.sources) {
|
|
47
|
+
const input = await readReconciliationInput(source);
|
|
48
|
+
totalBytes += Buffer.byteLength(input, 'utf8');
|
|
49
|
+
if (totalBytes > MAX_LOOKUP_RECONCILIATION_INPUT_BYTES) {
|
|
50
|
+
throw new CliUsageError(`Lookup reconciliation input is limited to ${MAX_LOOKUP_RECONCILIATION_INPUT_BYTES} bytes in total.`);
|
|
51
|
+
}
|
|
52
|
+
inputs.push(input);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error instanceof CliUsageError)
|
|
57
|
+
throw error;
|
|
58
|
+
throw new CliUsageError(`Could not read Lookup reconciliation input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
59
|
+
}
|
|
60
|
+
const document = buildCliLookupReconciliation(inputs, context.now());
|
|
61
|
+
if (!args.quiet) {
|
|
62
|
+
context.writeStdout(args.output === 'json'
|
|
63
|
+
? formatJsonDocument(document)
|
|
64
|
+
: context.terminal(formatCliLookupReconciliation(document), args.color));
|
|
65
|
+
}
|
|
66
|
+
return EXIT_CODES.SUCCESS;
|
|
67
|
+
}
|
|
68
|
+
async function runTimelineCommand(args, dependencies, context) {
|
|
69
|
+
context.setFailureLabel('Lookup observation timeline');
|
|
70
|
+
const readTimelineInput = dependencies.readDiffInput
|
|
71
|
+
|| ((source) => context.readInput(source, MAX_SAVED_LOOKUP_INPUT_BYTES, 'Lookup timeline input'));
|
|
72
|
+
const inputs = [];
|
|
73
|
+
let totalBytes = 0;
|
|
74
|
+
try {
|
|
75
|
+
for (const source of args.sources) {
|
|
76
|
+
const input = await readTimelineInput(source);
|
|
77
|
+
totalBytes += Buffer.byteLength(input, 'utf8');
|
|
78
|
+
if (totalBytes > MAX_LOOKUP_TIMELINE_INPUT_BYTES) {
|
|
79
|
+
throw new CliUsageError(`Lookup timeline input is limited to ${MAX_LOOKUP_TIMELINE_INPUT_BYTES} bytes in total.`);
|
|
80
|
+
}
|
|
81
|
+
inputs.push(input);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (error instanceof CliUsageError)
|
|
86
|
+
throw error;
|
|
87
|
+
throw new CliUsageError(`Could not read Lookup timeline input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
88
|
+
}
|
|
89
|
+
const document = buildCliLookupTimeline(inputs, context.now());
|
|
90
|
+
if (!args.quiet) {
|
|
91
|
+
context.writeStdout(args.output === 'json'
|
|
92
|
+
? formatJsonDocument(document)
|
|
93
|
+
: context.terminal(formatCliLookupTimeline(document), args.color));
|
|
94
|
+
}
|
|
95
|
+
return EXIT_CODES.SUCCESS;
|
|
96
|
+
}
|
|
97
|
+
async function runExportCommand(args, dependencies, context) {
|
|
98
|
+
context.setFailureLabel('Evidence export');
|
|
99
|
+
let input;
|
|
100
|
+
try {
|
|
101
|
+
input = dependencies.readExportInput
|
|
102
|
+
? await dependencies.readExportInput(args.source)
|
|
103
|
+
: await context.readInput(args.source, MAX_SAVED_LOOKUP_INPUT_BYTES, 'Evidence export input');
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error instanceof CliUsageError)
|
|
107
|
+
throw error;
|
|
108
|
+
throw new CliUsageError(`Could not read evidence export input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
109
|
+
}
|
|
110
|
+
if (!input.trim())
|
|
111
|
+
throw new CliUsageError('export requires one lookup JSON file or a lookup document on stdin.');
|
|
112
|
+
const loadEvidence = dependencies.loadEvidenceExport || (() => import("../lib/evidence-export.mjs"));
|
|
113
|
+
const evidenceModule = await loadEvidence();
|
|
114
|
+
const document = buildCliEvidenceExport(input, evidenceModule, context.now());
|
|
115
|
+
const output = args.format === 'markdown'
|
|
116
|
+
? formatLookupEvidenceMarkdown(document, { includeAttribution: args.includeAttribution })
|
|
117
|
+
: args.format === 'html'
|
|
118
|
+
? formatLookupEvidenceHtml(document, { includeAttribution: args.includeAttribution })
|
|
119
|
+
: formatCliEvidenceExport(document, args.compact);
|
|
120
|
+
context.writeStdout(output);
|
|
121
|
+
return EXIT_CODES.SUCCESS;
|
|
122
|
+
}
|
|
123
|
+
const HISTORY_COMMAND_HANDLERS = Object.freeze({
|
|
124
|
+
'diff': runRetainedDiffCommand,
|
|
125
|
+
'reconcile': runReconciliationCommand,
|
|
126
|
+
'timeline': runTimelineCommand,
|
|
127
|
+
'export': runExportCommand,
|
|
128
|
+
});
|
|
129
|
+
function runHistoryCommand(args, dependencies, context) {
|
|
130
|
+
return runDiscriminatedCommandHandler(HISTORY_COMMAND_HANDLERS, args, dependencies, context);
|
|
131
|
+
}
|
|
132
|
+
export { HISTORY_COMMAND_HANDLERS, runHistoryCommand };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const SUPPORT_INLINE_COMMANDS = Object.freeze([
|
|
2
|
+
'completion',
|
|
3
|
+
'commands',
|
|
4
|
+
'manual',
|
|
5
|
+
'doctor',
|
|
6
|
+
'registry-support',
|
|
7
|
+
'registry-doctor',
|
|
8
|
+
'registry-cohort',
|
|
9
|
+
'registry-scaffold',
|
|
10
|
+
'risk-calibrate',
|
|
11
|
+
'lookalike-calibrate',
|
|
12
|
+
]);
|
|
13
|
+
const REVIEW_INLINE_COMMANDS = Object.freeze([
|
|
14
|
+
'verify-artifact',
|
|
15
|
+
'interchange-report',
|
|
16
|
+
'source-report',
|
|
17
|
+
'compare',
|
|
18
|
+
'page-compare',
|
|
19
|
+
'mail-review',
|
|
20
|
+
'review-evidence',
|
|
21
|
+
'brief',
|
|
22
|
+
'case-pack',
|
|
23
|
+
]);
|
|
24
|
+
const ASSURANCE_INLINE_COMMANDS = Object.freeze([
|
|
25
|
+
'manifest',
|
|
26
|
+
'map-observations',
|
|
27
|
+
'oam-export',
|
|
28
|
+
'domain-control',
|
|
29
|
+
'assurance',
|
|
30
|
+
'change-packet',
|
|
31
|
+
'sharing-review',
|
|
32
|
+
'ct-intake',
|
|
33
|
+
]);
|
|
34
|
+
const WORKFLOW_INLINE_COMMANDS = Object.freeze([
|
|
35
|
+
'monitor-once',
|
|
36
|
+
'workflow-plan',
|
|
37
|
+
'workflow-run',
|
|
38
|
+
]);
|
|
39
|
+
const HISTORY_INLINE_COMMANDS = Object.freeze([
|
|
40
|
+
'diff',
|
|
41
|
+
'reconcile',
|
|
42
|
+
'timeline',
|
|
43
|
+
'export',
|
|
44
|
+
]);
|
|
45
|
+
export { ASSURANCE_INLINE_COMMANDS, HISTORY_INLINE_COMMANDS, REVIEW_INLINE_COMMANDS, SUPPORT_INLINE_COMMANDS, WORKFLOW_INLINE_COMMANDS, };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { CLI_COMMANDS, commandDefinition } from "./command-reference.mjs";
|
|
2
|
+
import { ASSURANCE_INLINE_COMMANDS, HISTORY_INLINE_COMMANDS, REVIEW_INLINE_COMMANDS, SUPPORT_INLINE_COMMANDS, WORKFLOW_INLINE_COMMANDS, } from "./inline-command-families.mjs";
|
|
3
|
+
const FAMILY_COMMANDS = Object.freeze([
|
|
4
|
+
Object.freeze({ family: 'support', commands: SUPPORT_INLINE_COMMANDS }),
|
|
5
|
+
Object.freeze({ family: 'review', commands: REVIEW_INLINE_COMMANDS }),
|
|
6
|
+
Object.freeze({ family: 'assurance', commands: ASSURANCE_INLINE_COMMANDS }),
|
|
7
|
+
Object.freeze({ family: 'workflow', commands: WORKFLOW_INLINE_COMMANDS }),
|
|
8
|
+
Object.freeze({ family: 'history', commands: HISTORY_INLINE_COMMANDS }),
|
|
9
|
+
]);
|
|
10
|
+
function buildInlineCommandOwnership() {
|
|
11
|
+
const ownership = new Map();
|
|
12
|
+
for (const { family, commands } of FAMILY_COMMANDS) {
|
|
13
|
+
for (const command of commands) {
|
|
14
|
+
if (ownership.has(command))
|
|
15
|
+
throw new Error(`Inline CLI command ${command} has more than one command-family owner.`);
|
|
16
|
+
ownership.set(command, family);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return ownership;
|
|
20
|
+
}
|
|
21
|
+
const INLINE_COMMAND_OWNERSHIP = buildInlineCommandOwnership();
|
|
22
|
+
const INLINE_CLI_COMMANDS = Object.freeze(CLI_COMMANDS.filter((command) => commandDefinition(command).execution.handlerOwner === 'inline'));
|
|
23
|
+
function validateInlineCommandOwnership() {
|
|
24
|
+
const missing = INLINE_CLI_COMMANDS.filter((command) => !INLINE_COMMAND_OWNERSHIP.has(command));
|
|
25
|
+
const unexpected = [...INLINE_COMMAND_OWNERSHIP.keys()].filter((command) => !INLINE_CLI_COMMANDS.includes(command));
|
|
26
|
+
if (missing.length || unexpected.length) {
|
|
27
|
+
throw new Error(`Inline CLI command-family ownership is inconsistent (missing: ${missing.join(', ') || 'none'}; unexpected: ${unexpected.join(', ') || 'none'}).`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
validateInlineCommandOwnership();
|
|
31
|
+
async function runInlineCommand(args, dependencies, context) {
|
|
32
|
+
const family = INLINE_COMMAND_OWNERSHIP.get(args.action);
|
|
33
|
+
if (family === 'support') {
|
|
34
|
+
const { runSupportCommand } = await import("./support-command-runner.mjs");
|
|
35
|
+
return runSupportCommand(args, dependencies, context);
|
|
36
|
+
}
|
|
37
|
+
if (family === 'review') {
|
|
38
|
+
const { runReviewCommand } = await import("./review-command-runner.mjs");
|
|
39
|
+
return runReviewCommand(args, dependencies, context);
|
|
40
|
+
}
|
|
41
|
+
if (family === 'assurance') {
|
|
42
|
+
const { runAssuranceCommand } = await import("./assurance-command-runner.mjs");
|
|
43
|
+
return runAssuranceCommand(args, dependencies, context);
|
|
44
|
+
}
|
|
45
|
+
if (family === 'workflow') {
|
|
46
|
+
const { runWorkflowCommand } = await import("./workflow-command-runner.mjs");
|
|
47
|
+
return runWorkflowCommand(args, dependencies, context);
|
|
48
|
+
}
|
|
49
|
+
if (family === 'history') {
|
|
50
|
+
const { runHistoryCommand } = await import("./history-command-runner.mjs");
|
|
51
|
+
return runHistoryCommand(args, dependencies, context);
|
|
52
|
+
}
|
|
53
|
+
throw new Error('No inline CLI command-family route is registered for the parsed command.');
|
|
54
|
+
}
|
|
55
|
+
export { FAMILY_COMMANDS, INLINE_CLI_COMMANDS, runInlineCommand, };
|
|
@@ -13,7 +13,10 @@ const RECIPES = Object.freeze({
|
|
|
13
13
|
subjectRequirement: 'domain',
|
|
14
14
|
objective: 'Collect and preserve separately attributed registration, DNS, HTTP, TLS, page, and network-context evidence.',
|
|
15
15
|
runnable: true,
|
|
16
|
-
limitations: Object.freeze([
|
|
16
|
+
limitations: Object.freeze([
|
|
17
|
+
'Collection remains analyst-triggered and source limitations remain explicit.',
|
|
18
|
+
'Disposition, reviewed response actions, monitoring, and closure continue in the browser-local Case workspace; this CLI recipe does not submit reports.',
|
|
19
|
+
]),
|
|
17
20
|
steps: (domain) => Object.freeze([
|
|
18
21
|
step('collect', 'Collect a Deep lookup', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'Review source health and limitations before using missing fields.'),
|
|
19
22
|
step('export', 'Create a portable evidence report', 'export', ['<saved-lookup.json>'], 'offline', 'analyst_selection', 'whoisleuth.lookup-evidence', 'Select the reviewed lookup file; the plan never guesses a path.'),
|
|
@@ -26,7 +29,10 @@ const RECIPES = Object.freeze({
|
|
|
26
29
|
subjectRequirement: 'brand_or_domain',
|
|
27
30
|
objective: 'Generate a bounded candidate queue, collect only the selected scope, and retain a reviewed candidate lookup.',
|
|
28
31
|
runnable: true,
|
|
29
|
-
limitations: Object.freeze([
|
|
32
|
+
limitations: Object.freeze([
|
|
33
|
+
'Candidate generation does not establish registration, control, intent, or maliciousness.',
|
|
34
|
+
'Official-reference collection and page comparison require analyst-selected saved evidence; use page-compare after retaining the reference and candidate observations.',
|
|
35
|
+
]),
|
|
30
36
|
steps: (subject) => Object.freeze([
|
|
31
37
|
step('generate', 'Generate candidates offline', 'discover', [subject, '--preset', 'all', '--json'], 'offline', 'none', 'whoisleuth.cli.discover', 'Review mutation families and suppressions before collection.'),
|
|
32
38
|
step('scan', 'Collect a bounded candidate queue', 'discover-scan', [subject, '--fast', '--scan-limit', '50', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.discovery-scan', 'Fast collection is a triage boundary; partial or inconclusive authority evidence remains explicit.'),
|
|
@@ -238,6 +244,7 @@ export function buildInvestigationPlan(recipeId, subjectValue, generatedAtValue
|
|
|
238
244
|
execution: 'plan_only',
|
|
239
245
|
steps,
|
|
240
246
|
limitations: Object.freeze([
|
|
247
|
+
...recipe.limitations,
|
|
241
248
|
'This document is a fixed plan. It does not execute commands, expand placeholders, make requests, read files, change cases, or submit reports.',
|
|
242
249
|
'Network steps require deliberate execution and disclose the selected target to the sources described by that command.',
|
|
243
250
|
'Analyst-selection steps require reviewed local artefacts; placeholders are never interpreted as file paths by this planner.',
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
2
|
import { buildInvestigationPlan, isRunnableInvestigationRecipe, } from "./investigation-plan.mjs";
|
|
3
3
|
import { CliUsageError } from "./errors.mjs";
|
|
4
|
+
import { hasUnsafeCliText } from "./errors.mjs";
|
|
4
5
|
import EXIT_CODES from "./exit-codes.mjs";
|
|
5
6
|
import { scanBoundedJson } from "../lib/bounded-json.mjs";
|
|
6
7
|
export const CLI_INVESTIGATION_RUN_SCHEMA = 'whoisleuth.cli.investigation-run';
|
|
7
|
-
export const CLI_INVESTIGATION_RUN_VERSION =
|
|
8
|
+
export const CLI_INVESTIGATION_RUN_VERSION = 2;
|
|
8
9
|
export const MAX_INVESTIGATION_RUN_BYTES = 24 * 1024 * 1024;
|
|
10
|
+
export const MAX_INVESTIGATION_RUN_SELECTIONS = 16;
|
|
11
|
+
export const MAX_INVESTIGATION_RUN_SELECTION_LENGTH = 1_024;
|
|
12
|
+
const PLACEHOLDER_PATTERN = /^<[^>]+>$/u;
|
|
9
13
|
function boundedResult(value) {
|
|
10
14
|
if (Buffer.byteLength(value, 'utf8') > MAX_INVESTIGATION_RUN_BYTES) {
|
|
11
15
|
throw new CliUsageError(`Investigation step output is limited to ${MAX_INVESTIGATION_RUN_BYTES} bytes.`);
|
|
@@ -31,9 +35,95 @@ function sameArguments(left, right) {
|
|
|
31
35
|
&& left.length === right.length
|
|
32
36
|
&& left.every((value, index) => typeof value === 'string' && value === right[index]);
|
|
33
37
|
}
|
|
38
|
+
function selectedArguments(step, values) {
|
|
39
|
+
let selectionIndex = 0;
|
|
40
|
+
return Object.freeze(step.arguments.map((argument) => {
|
|
41
|
+
if (!PLACEHOLDER_PATTERN.test(argument))
|
|
42
|
+
return argument;
|
|
43
|
+
const selected = values[selectionIndex];
|
|
44
|
+
selectionIndex += 1;
|
|
45
|
+
return selected ?? argument;
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
function selectedStep(step, values) {
|
|
49
|
+
return Object.freeze({ ...step, arguments: selectedArguments(step, values) });
|
|
50
|
+
}
|
|
51
|
+
function placeholderCount(step) {
|
|
52
|
+
return step.arguments.filter((argument) => PLACEHOLDER_PATTERN.test(argument)).length;
|
|
53
|
+
}
|
|
54
|
+
function validateSelectionValue(value) {
|
|
55
|
+
return typeof value === 'string'
|
|
56
|
+
&& value.length > 0
|
|
57
|
+
&& value.length <= MAX_INVESTIGATION_RUN_SELECTION_LENGTH
|
|
58
|
+
&& Boolean(value.trim())
|
|
59
|
+
&& !value.startsWith('-')
|
|
60
|
+
&& !hasUnsafeCliText(value);
|
|
61
|
+
}
|
|
62
|
+
function normalizeRetainedSelections(plan, value, label) {
|
|
63
|
+
if (!Array.isArray(value) || value.length > MAX_INVESTIGATION_RUN_SELECTIONS) {
|
|
64
|
+
throw new CliUsageError(`${label} must contain at most ${MAX_INVESTIGATION_RUN_SELECTIONS} bounded analyst selections.`);
|
|
65
|
+
}
|
|
66
|
+
const plannedById = new Map(plan.steps.map((step) => [step.id, step]));
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
const retained = [];
|
|
69
|
+
for (const itemValue of value) {
|
|
70
|
+
if (!itemValue || typeof itemValue !== 'object' || Array.isArray(itemValue)) {
|
|
71
|
+
throw new CliUsageError(`${label} contains a malformed analyst selection.`);
|
|
72
|
+
}
|
|
73
|
+
const item = itemValue;
|
|
74
|
+
if (Object.keys(item).length !== 2 || !Object.hasOwn(item, 'stepId') || !Object.hasOwn(item, 'values')) {
|
|
75
|
+
throw new CliUsageError(`${label} contains a malformed analyst selection.`);
|
|
76
|
+
}
|
|
77
|
+
const stepId = typeof item.stepId === 'string' ? item.stepId : '';
|
|
78
|
+
const planned = plannedById.get(stepId);
|
|
79
|
+
if (!planned || seen.has(stepId) || placeholderCount(planned) === 0 || !Array.isArray(item.values)) {
|
|
80
|
+
throw new CliUsageError(`${label} does not match the installed fixed recipe.`);
|
|
81
|
+
}
|
|
82
|
+
if (item.values.length === 0 || item.values.length > placeholderCount(planned)
|
|
83
|
+
|| item.values.some((selection) => !validateSelectionValue(selection))) {
|
|
84
|
+
throw new CliUsageError(`${label} contains an invalid value for step ${stepId}.`);
|
|
85
|
+
}
|
|
86
|
+
seen.add(stepId);
|
|
87
|
+
retained.push(Object.freeze({ stepId, values: Object.freeze([...item.values]) }));
|
|
88
|
+
}
|
|
89
|
+
return plan.steps.flatMap((step) => {
|
|
90
|
+
const selection = retained.find((item) => item.stepId === step.id);
|
|
91
|
+
return selection ? [selection] : [];
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function normalizeInvocationSelections(plan, selections) {
|
|
95
|
+
if (!Array.isArray(selections) || selections.length > MAX_INVESTIGATION_RUN_SELECTIONS) {
|
|
96
|
+
throw new CliUsageError(`workflow-run accepts at most ${MAX_INVESTIGATION_RUN_SELECTIONS} analyst selections.`);
|
|
97
|
+
}
|
|
98
|
+
const grouped = new Map();
|
|
99
|
+
for (const item of selections) {
|
|
100
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)
|
|
101
|
+
|| typeof item.stepId !== 'string' || !validateSelectionValue(item.value)) {
|
|
102
|
+
throw new CliUsageError('workflow-run selections must use bounded <step-id>=<path-or-value> entries whose values do not start with a hyphen.');
|
|
103
|
+
}
|
|
104
|
+
const values = grouped.get(item.stepId) ?? [];
|
|
105
|
+
values.push(item.value);
|
|
106
|
+
grouped.set(item.stepId, values);
|
|
107
|
+
}
|
|
108
|
+
return normalizeRetainedSelections(plan, [...grouped].map(([stepId, values]) => ({ stepId, values })), 'workflow-run selections');
|
|
109
|
+
}
|
|
110
|
+
function mergeSelections(plan, retained, supplied, completed) {
|
|
111
|
+
const merged = new Map(retained.map((item) => [item.stepId, item.values]));
|
|
112
|
+
for (const item of supplied) {
|
|
113
|
+
const completedStep = completed.find((step) => step.id === item.stepId);
|
|
114
|
+
if (completedStep && !sameArguments(completedStep.arguments, selectedArguments(plan.steps.find((step) => step.id === item.stepId), item.values))) {
|
|
115
|
+
throw new CliUsageError(`workflow-run cannot change analyst selections for completed step ${item.stepId}.`);
|
|
116
|
+
}
|
|
117
|
+
merged.set(item.stepId, item.values);
|
|
118
|
+
}
|
|
119
|
+
return plan.steps.flatMap((step) => {
|
|
120
|
+
const values = merged.get(step.id);
|
|
121
|
+
return values ? [Object.freeze({ stepId: step.id, values: Object.freeze([...values]) })] : [];
|
|
122
|
+
});
|
|
123
|
+
}
|
|
34
124
|
function parseResumeState(input, plan) {
|
|
35
125
|
if (!input)
|
|
36
|
-
return [];
|
|
126
|
+
return Object.freeze({ completed: Object.freeze([]), selections: Object.freeze([]) });
|
|
37
127
|
if (Buffer.byteLength(input, 'utf8') > MAX_INVESTIGATION_RUN_BYTES)
|
|
38
128
|
throw new CliUsageError('Investigation resume state exceeds the 24 MiB limit.');
|
|
39
129
|
const normalizedInput = input.replace(/^\uFEFF/u, '');
|
|
@@ -54,15 +144,25 @@ function parseResumeState(input, plan) {
|
|
|
54
144
|
throw new CliUsageError('Investigation resume state must be valid JSON.');
|
|
55
145
|
}
|
|
56
146
|
const root = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
57
|
-
if (root.schema !== CLI_INVESTIGATION_RUN_SCHEMA
|
|
147
|
+
if (root.schema !== CLI_INVESTIGATION_RUN_SCHEMA
|
|
148
|
+
|| (root.version !== 1 && root.version !== CLI_INVESTIGATION_RUN_VERSION)
|
|
149
|
+
|| root.recipe !== plan.recipe.id || root.subject !== plan.subject || !Array.isArray(root.completedSteps)) {
|
|
58
150
|
throw new CliUsageError('Investigation resume state must match this versioned recipe and subject.');
|
|
59
151
|
}
|
|
152
|
+
const selections = root.version === 1
|
|
153
|
+
? Object.freeze([])
|
|
154
|
+
: Object.freeze(normalizeRetainedSelections(plan, root.selections, 'Investigation resume state'));
|
|
155
|
+
if (root.version === 1 && root.selections !== undefined) {
|
|
156
|
+
throw new CliUsageError('Investigation resume state version 1 cannot contain analyst selections.');
|
|
157
|
+
}
|
|
158
|
+
const selectionsByStep = new Map(selections.map((item) => [item.stepId, item.values]));
|
|
60
159
|
if (root.completedSteps.length > plan.steps.length) {
|
|
61
160
|
throw new CliUsageError('Investigation resume state contains more steps than the installed fixed recipe.');
|
|
62
161
|
}
|
|
63
162
|
const completed = [];
|
|
64
163
|
for (const [index, value] of root.completedSteps.entries()) {
|
|
65
|
-
const
|
|
164
|
+
const baseStep = plan.steps[index];
|
|
165
|
+
const planned = baseStep ? selectedStep(baseStep, selectionsByStep.get(baseStep.id) ?? []) : null;
|
|
66
166
|
if (!planned || !value || typeof value !== 'object' || Array.isArray(value)) {
|
|
67
167
|
throw new CliUsageError('Investigation resume state contains a malformed or out-of-order step.');
|
|
68
168
|
}
|
|
@@ -93,7 +193,7 @@ function parseResumeState(input, plan) {
|
|
|
93
193
|
result: item.result,
|
|
94
194
|
}));
|
|
95
195
|
}
|
|
96
|
-
return completed;
|
|
196
|
+
return Object.freeze({ completed: Object.freeze(completed), selections });
|
|
97
197
|
}
|
|
98
198
|
export async function runInvestigationRecipe(recipe, subjectValue, options) {
|
|
99
199
|
if (!isRunnableInvestigationRecipe(recipe)) {
|
|
@@ -101,11 +201,15 @@ export async function runInvestigationRecipe(recipe, subjectValue, options) {
|
|
|
101
201
|
}
|
|
102
202
|
const plan = buildInvestigationPlan(recipe, subjectValue, options.generatedAt);
|
|
103
203
|
const prior = parseResumeState(options.resumeInput, plan);
|
|
104
|
-
const
|
|
204
|
+
const suppliedSelections = normalizeInvocationSelections(plan, options.selections ?? []);
|
|
205
|
+
const selections = mergeSelections(plan, prior.selections, suppliedSelections, prior.completed);
|
|
206
|
+
const selectionsByStep = new Map(selections.map((item) => [item.stepId, item.values]));
|
|
207
|
+
const completed = [...prior.completed];
|
|
105
208
|
let state = 'complete';
|
|
106
209
|
let currentStep = null;
|
|
107
|
-
for (const
|
|
210
|
+
for (const baseStep of plan.steps) {
|
|
108
211
|
options.signal?.throwIfAborted();
|
|
212
|
+
const step = selectedStep(baseStep, selectionsByStep.get(baseStep.id) ?? []);
|
|
109
213
|
if (completed.some((item) => item.id === step.id))
|
|
110
214
|
continue;
|
|
111
215
|
currentStep = step;
|
|
@@ -147,12 +251,13 @@ export async function runInvestigationRecipe(recipe, subjectValue, options) {
|
|
|
147
251
|
subject: plan.subject,
|
|
148
252
|
state,
|
|
149
253
|
networkApprovedForThisRun: options.approveNetwork,
|
|
254
|
+
selections: Object.freeze(selections),
|
|
150
255
|
completedSteps: Object.freeze(completed),
|
|
151
256
|
currentStep,
|
|
152
257
|
limitations: Object.freeze([
|
|
153
258
|
'Only commands and arguments from the installed fixed recipe can execute; no shell, script, arbitrary command, or enforcement action is accepted.',
|
|
154
|
-
'Network steps run only with --approve-network for the current invocation.
|
|
155
|
-
'A resume file is a local checkpoint
|
|
259
|
+
'Network steps run only with --approve-network for the current invocation. Unresolved analyst selections pause; supplied values replace exact placeholders and are passed as arguments without shell interpretation.',
|
|
260
|
+
'A resume file is a local checkpoint and can retain selected local paths or values. It is not proof that prior evidence remains current or that a human reviewed each stored result.',
|
|
156
261
|
]),
|
|
157
262
|
});
|
|
158
263
|
}
|