@slicedearth/whoisleuth-cli 2.2.0 → 2.4.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/NOTICE +6 -0
- package/README.md +20 -15
- package/bin/whoisleuth.mjs +8 -1
- package/cli/archive-content-digest.mjs +14 -0
- package/cli/archive-inspect.mjs +20 -53
- package/cli/arguments.mjs +454 -1748
- package/cli/artifact-validation/case-response.mjs +66 -35
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +12 -6
- package/cli/artifact-validation/signed-review.mjs +112 -72
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +90 -24
- package/cli/assurance-command-runner.mjs +39 -4
- package/cli/bagit-folder.mjs +57 -0
- package/cli/bagit-review.mjs +23 -0
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +34 -3
- package/cli/bulk-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +66 -111
- package/cli/command-argument-grammar.mjs +215 -0
- package/cli/command-reference.mjs +342 -285
- package/cli/completion.mjs +303 -80
- package/cli/config-profile.mjs +7 -5
- package/cli/ct-event-intake.mjs +12 -12
- package/cli/discover.mjs +4 -3
- package/cli/discovery-observation-snapshot.mjs +59 -18
- package/cli/discovery-scan-command-runner.mjs +21 -0
- package/cli/discovery-scan.mjs +13 -4
- package/cli/discovery-workflow.mjs +1 -1
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +149 -102
- package/cli/errors.mjs +33 -1
- package/cli/evidence-command-runner.mjs +16 -5
- package/cli/evidence-signing.mjs +2 -2
- package/cli/external-observation-mapping.mjs +7 -8
- package/cli/formatters/terminal-lookup.mjs +18 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/inline-command-families.mjs +2 -0
- package/cli/input.mjs +116 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +18 -8
- package/cli/investigation-artifacts.mjs +176 -0
- package/cli/investigation-checkpoint.mjs +10 -0
- package/cli/investigation-folder.mjs +114 -0
- package/cli/investigation-manifest.mjs +1 -112
- package/cli/investigation-package-review.mjs +103 -0
- package/cli/investigation-plan.mjs +32 -19
- package/cli/investigation-run.mjs +188 -33
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +17 -6
- package/cli/lookup-diff.mjs +37 -3
- package/cli/lookup-plan.mjs +11 -5
- package/cli/lookup-reconcile.mjs +10 -8
- package/cli/mail-header-review.mjs +467 -0
- package/cli/mail-review.mjs +66 -14
- package/cli/manual.mjs +1 -0
- package/cli/network-command-runner.mjs +2 -0
- package/cli/output-file.mjs +31 -5
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +2 -2
- package/cli/review-command-runner.mjs +70 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +62 -7
- package/cli/sharing-review.mjs +21 -34
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +74 -83
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-command-runner.mjs +68 -13
- package/docs/cli-reference.md +77 -24
- package/docs/cli.md +300 -25
- package/lib/abort.mjs +4 -1
- package/lib/availability.mjs +161 -112
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +51 -8
- package/lib/browser-library-profile.mjs +42 -51
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-search.mjs +11 -4
- package/lib/dns-convergence-review.mjs +1 -0
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +73 -26
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +49 -16
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +221 -117
- package/lib/domain-control-manifest.mjs +129 -69
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +201 -8
- package/lib/domain-posture.mjs +147 -29
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -543
- package/lib/evidence-report.mjs +5 -3
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +12 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +2335 -940
- package/lib/hostname.mjs +2 -38
- package/lib/html-signals.mjs +111 -108
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/local-geoip-evidence.mjs +19 -3
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-diagnostics.mjs +24 -1
- package/lib/lookup-progress.mjs +30 -13
- package/lib/lookup-response-contract.mjs +1048 -0
- package/lib/lookup-response.mjs +8 -10
- package/lib/lookup-source-progress.mjs +40 -14
- package/lib/lookup.mjs +48 -27
- package/lib/nameserver-preflight-review.mjs +1 -0
- package/lib/observed-network-context.mjs +43 -27
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +69 -133
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash.mjs +43 -14
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-capabilities.mjs +1 -1
- package/lib/rdap-client.mjs +15 -7
- package/lib/rdap-normalization.mjs +33 -18
- package/lib/rdap-search-workbench.mjs +1 -1
- package/lib/rdap-transport.mjs +47 -20
- package/lib/rdap-types.mjs +2 -1
- package/lib/rdap.mjs +18 -3
- package/lib/registrable-domain.mjs +21 -1
- package/lib/registrar-standing-contract.mjs +1 -0
- package/lib/registry-capabilities.mjs +5 -1
- package/lib/registry-capability-catalogue.mjs +2 -1
- package/lib/registry-dates.mjs +2 -190
- package/lib/registry-insights.mjs +85 -34
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +18 -6
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +42 -7
- package/lib/service-binding-dns.mjs +8 -0
- package/lib/smtp-transport-review.mjs +27 -15
- package/lib/sslbl-certificates.generated.mjs +136 -135
- package/lib/static-html-analysis.mjs +178 -151
- package/lib/structured-data-identity.mjs +1 -0
- package/lib/technology-markup.mjs +100 -0
- package/lib/threat-intelligence-manifests.mjs +11 -139
- package/lib/threat-intelligence-runtime.mjs +24 -52
- package/lib/threat-intelligence-targets.mjs +6 -34
- package/lib/threat-intelligence-types.mjs +39 -0
- package/lib/typosquat-generator.mjs +19 -15
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +153 -45
- package/lib/whois-authority.mjs +14 -2
- package/lib/whois-chain.mjs +9 -2
- package/lib/whois-common-formats.mjs +5 -5
- package/lib/whois-contracts.mjs +2 -1
- package/lib/whois-dialects.mjs +2 -2
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-transport.mjs +23 -7
- package/lib/zone-intent-review.mjs +16 -6
- package/package.json +2 -1
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-model.mjs +156 -62
- package/packages/cases/case-export-input.mjs +53 -0
- package/packages/cases/case-migration-model.mjs +127 -44
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +12 -22
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +95 -31
- package/packages/cases/case-record-projection.mjs +221 -0
- package/packages/cases/case-report.mjs +70 -54
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +64 -1115
- package/packages/cases/case-response-outcomes.mjs +317 -0
- package/packages/cases/case-response-packet-render.mjs +153 -0
- package/packages/cases/case-response-packet-types.mjs +1 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
- package/packages/cases/case-response-packet.mjs +74 -572
- package/packages/cases/case-response-records.mjs +94 -0
- package/packages/cases/case-response-review-inputs.mjs +371 -0
- package/packages/cases/case-response-values.mjs +85 -0
- package/packages/cases/case-review-package.mjs +45 -0
- package/packages/cases/case-selection.mjs +46 -0
- package/packages/cases/case-storage-model.mjs +31 -2
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
- package/packages/comparison/page-similarity.mjs +5 -2
- package/packages/comparison/relationship-evidence.mjs +85 -8
- package/packages/comparison/relationship-provenance.mjs +93 -0
- package/packages/contracts/analyst-interchange.mjs +216 -0
- package/packages/contracts/analyst-review-state-contract.mjs +11 -0
- package/packages/contracts/analyst-review-state.mjs +5 -4
- package/packages/contracts/case-portability.mjs +366 -83
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +94 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
- package/packages/contracts/domain-control-manifest.mjs +127 -73
- package/packages/contracts/domain-control-monitor.mjs +48 -45
- package/packages/contracts/domain-control-review.mjs +179 -121
- package/packages/contracts/domain-name.mjs +21 -0
- package/packages/contracts/external-observation-interchange.mjs +2 -1
- package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +16 -0
- package/packages/contracts/ip-address.mjs +34 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/offline-comparison.mjs +1 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/risk-calibration.mjs +15 -11
- package/packages/contracts/schema-compatibility.mjs +1 -1
- package/packages/contracts/schema-lifecycle.mjs +83 -153
- package/packages/contracts/selected-file-limits.mjs +6 -0
- package/packages/contracts/vulnerability-identifiers.mjs +5 -0
- package/packages/contracts/web-capture.mjs +10 -0
- package/packages/contracts/workspace-portability.mjs +183 -211
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/domain-control-runtime.mjs +201 -33
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +104 -0
- package/packages/evidence/observation.mjs +16 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/record-identity.mjs +72 -0
- package/packages/evidence/registry-dates.mjs +190 -0
- package/packages/evidence/retained-file.mjs +69 -0
- package/packages/evidence/review-age.mjs +14 -0
- package/packages/interchange/bagit.mjs +342 -0
- package/packages/interchange/bounded-zip-extraction.mjs +278 -0
- package/packages/interchange/crc32.mjs +17 -0
- package/packages/interchange/external-findings-import.mjs +92 -51
- package/packages/interchange/web-capture-import.mjs +382 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -1
- package/packages/investigation/investigation-bagit.mjs +14 -0
- package/packages/investigation/investigation-capsule.mjs +33 -5
- package/packages/investigation/investigation-manifest.mjs +200 -0
- package/packages/investigation/investigation-package-crypto.mjs +98 -0
- package/packages/investigation/investigation-package.mjs +200 -0
- package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
- package/packages/investigation/lookup-asset-graph.mjs +193 -165
- package/packages/investigation/lookup-claim-readiness.mjs +18 -0
- package/packages/investigation/lookup-investigation-brief.mjs +3 -3
- package/packages/investigation/lookup-task-guidance.mjs +58 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +276 -22
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +23 -11
- package/packages/workspace/bulk-session-model.mjs +100 -24
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/detection-rule-model.mjs +70 -22
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-template-model.mjs +19 -4
- package/packages/workspace/page-baseline.mjs +18 -15
- package/packages/workspace/relationship-observation-model.mjs +41 -23
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +164 -26
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +87 -21
- package/third-party-notices.txt +29 -2
- package/cli/evidence-command-arguments.mjs +0 -156
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { MAX_INVESTIGATION_RUN_BYTES } from "../packages/contracts/investigation-run.mjs";
|
|
2
|
+
import { prepareLocalDocumentWrite } from "./local-document-checkpoint.mjs";
|
|
3
|
+
/** Preserve the workflow checkpoint interface and shared on-disk lease convention. */
|
|
4
|
+
export async function prepareInvestigationCheckpoint(options) {
|
|
5
|
+
const checkpoint = await prepareLocalDocumentWrite({
|
|
6
|
+
...options, source: options.resumeSource, label: 'Workflow state',
|
|
7
|
+
maximumInputBytes: MAX_INVESTIGATION_RUN_BYTES, maximumOutputBytes: MAX_INVESTIGATION_RUN_BYTES,
|
|
8
|
+
});
|
|
9
|
+
return Object.freeze({ resumeInput: checkpoint.sourceInput, publish: checkpoint.publish, release: checkpoint.release });
|
|
10
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { lstat, mkdir, opendir, realpath } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { readBoundedRegularFileWithin } from "../lib/bounded-file.mjs";
|
|
4
|
+
import { writePrivateFile } from "./output-file.mjs";
|
|
5
|
+
import { INVESTIGATION_PACKAGE_MANIFEST_PATH, MAX_INVESTIGATION_PACKAGE_ENTRIES, investigationPackagePath, prepareInvestigationPackageEntries, } from "../packages/investigation/investigation-package.mjs";
|
|
6
|
+
import { MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES, MAX_INVESTIGATION_MANIFEST_DOCUMENT_BYTES, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES, } from "../packages/investigation/investigation-manifest.mjs";
|
|
7
|
+
export function selectedEvidenceDirectory(value) {
|
|
8
|
+
if (typeof value !== 'string' || !value || value === '-' || value.length > 4096 || /[\x00-\x1f\x7f]/u.test(value))
|
|
9
|
+
throw new TypeError('Select one bounded local folder path.');
|
|
10
|
+
return resolve(value);
|
|
11
|
+
}
|
|
12
|
+
export async function evidenceDirectoryIdentity(path) {
|
|
13
|
+
const value = await lstat(path);
|
|
14
|
+
if (!value.isDirectory() || value.isSymbolicLink())
|
|
15
|
+
throw new TypeError('Evidence folders must be ordinary directories, not symbolic links.');
|
|
16
|
+
return { device: value.dev, inode: value.ino, modified: value.mtimeMs, changed: value.ctimeMs };
|
|
17
|
+
}
|
|
18
|
+
/** Enumerate only the two declared levels; never walk an arbitrary source tree. */
|
|
19
|
+
export async function readInvestigationFolder(value, signal) {
|
|
20
|
+
signal?.throwIfAborted();
|
|
21
|
+
const selected = selectedEvidenceDirectory(value);
|
|
22
|
+
const before = await evidenceDirectoryIdentity(selected), root = await realpath(selected);
|
|
23
|
+
const paths = [];
|
|
24
|
+
let nestedIdentity = null;
|
|
25
|
+
const directory = await opendir(root);
|
|
26
|
+
for await (const entry of directory) {
|
|
27
|
+
signal?.throwIfAborted();
|
|
28
|
+
if (entry.name === 'artifacts' && entry.isDirectory()) {
|
|
29
|
+
const nested = join(root, 'artifacts');
|
|
30
|
+
nestedIdentity = await evidenceDirectoryIdentity(nested);
|
|
31
|
+
for await (const file of await opendir(nested)) {
|
|
32
|
+
signal?.throwIfAborted();
|
|
33
|
+
if (!file.isFile())
|
|
34
|
+
throw new TypeError('Evidence folder entries must be regular files.');
|
|
35
|
+
if (paths.length >= MAX_INVESTIGATION_PACKAGE_ENTRIES)
|
|
36
|
+
throw new TypeError('Evidence folder has too many entries.');
|
|
37
|
+
paths.push(investigationPackagePath(`artifacts/${file.name}`));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
else if (entry.name === INVESTIGATION_PACKAGE_MANIFEST_PATH && entry.isFile()) {
|
|
41
|
+
if (paths.length >= MAX_INVESTIGATION_PACKAGE_ENTRIES)
|
|
42
|
+
throw new TypeError('Evidence folder has too many entries.');
|
|
43
|
+
paths.push(entry.name);
|
|
44
|
+
}
|
|
45
|
+
else
|
|
46
|
+
throw new TypeError('Evidence folders contain only manifest.json and the declared artifacts directory.');
|
|
47
|
+
}
|
|
48
|
+
if (!nestedIdentity || !paths.includes(INVESTIGATION_PACKAGE_MANIFEST_PATH) || paths.length < 2)
|
|
49
|
+
throw new TypeError('Evidence folder is incomplete.');
|
|
50
|
+
const files = new Map();
|
|
51
|
+
let total = 0;
|
|
52
|
+
for (const name of paths.sort()) {
|
|
53
|
+
signal?.throwIfAborted();
|
|
54
|
+
const manifest = name === INVESTIGATION_PACKAGE_MANIFEST_PATH;
|
|
55
|
+
const maximumBytes = manifest ? MAX_INVESTIGATION_MANIFEST_DOCUMENT_BYTES : Math.min(MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES - total);
|
|
56
|
+
const bytes = await readBoundedRegularFileWithin(root, name, { maximumBytes, minimumBytes: 1, label: 'Evidence folder entry', ...(signal ? { signal } : {}) });
|
|
57
|
+
if (!manifest)
|
|
58
|
+
total += bytes.byteLength;
|
|
59
|
+
files.set(name, bytes);
|
|
60
|
+
}
|
|
61
|
+
const unchanged = (left, right) => left.device === right.device && left.inode === right.inode && left.modified === right.modified && left.changed === right.changed;
|
|
62
|
+
if (await realpath(selected) !== root || !unchanged(before, await evidenceDirectoryIdentity(selected))
|
|
63
|
+
|| !unchanged(nestedIdentity, await evidenceDirectoryIdentity(join(root, 'artifacts'))))
|
|
64
|
+
throw new TypeError('Evidence folder changed while it was read.');
|
|
65
|
+
signal?.throwIfAborted();
|
|
66
|
+
return files;
|
|
67
|
+
}
|
|
68
|
+
/** Never replace an existing folder. The manifest is published only after files. */
|
|
69
|
+
export async function writeInvestigationFolder(value, input, generatedAt, version, signal) {
|
|
70
|
+
const prepared = await prepareInvestigationPackageEntries(input, generatedAt, version);
|
|
71
|
+
await writePreparedEvidenceFolder(value, prepared.files, 'artifacts', INVESTIGATION_PACKAGE_MANIFEST_PATH, signal);
|
|
72
|
+
return prepared.manifest;
|
|
73
|
+
}
|
|
74
|
+
/** Only first-party, generated layouts may use this fresh-directory writer. */
|
|
75
|
+
export async function writePreparedEvidenceFolder(value, files, payloadDirectory, completionFile, signal) {
|
|
76
|
+
const target = selectedEvidenceDirectory(value);
|
|
77
|
+
if (!files.has(completionFile) || !/^[a-z0-9.-]+$/u.test(completionFile)
|
|
78
|
+
|| [...files.keys()].some(path => path !== `${payloadDirectory}/` && !/^[a-z0-9.-]+$/u.test(path) && !new RegExp(`^${payloadDirectory}/artifact-[1-9][0-9]{0,2}$`, 'u').test(path)))
|
|
79
|
+
throw new TypeError('The generated evidence folder layout is invalid.');
|
|
80
|
+
signal?.throwIfAborted();
|
|
81
|
+
try {
|
|
82
|
+
await mkdir(target, { mode: 0o700 });
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
throw new TypeError('The output folder must not already exist, and its parent must be writable. Existing files were not replaced.');
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const root = await realpath(target), identity = await evidenceDirectoryIdentity(target);
|
|
89
|
+
await mkdir(join(root, payloadDirectory), { mode: 0o700 });
|
|
90
|
+
const artifactsIdentity = await evidenceDirectoryIdentity(join(root, payloadDirectory));
|
|
91
|
+
const checkLocation = async () => {
|
|
92
|
+
signal?.throwIfAborted();
|
|
93
|
+
const current = await evidenceDirectoryIdentity(target), nested = await evidenceDirectoryIdentity(join(root, payloadDirectory));
|
|
94
|
+
if (await realpath(target) !== root || current.device !== identity.device || current.inode !== identity.inode
|
|
95
|
+
|| nested.device !== artifactsIdentity.device || nested.inode !== artifactsIdentity.inode
|
|
96
|
+
|| await realpath(join(root, payloadDirectory)) !== join(root, payloadDirectory))
|
|
97
|
+
throw new TypeError('Evidence output folder changed.');
|
|
98
|
+
};
|
|
99
|
+
for (const [name, content] of files) {
|
|
100
|
+
if (name === completionFile || name.endsWith('/'))
|
|
101
|
+
continue;
|
|
102
|
+
await checkLocation();
|
|
103
|
+
await writePrivateFile(join(root, name), content, { beforePublish: checkLocation });
|
|
104
|
+
}
|
|
105
|
+
await checkLocation();
|
|
106
|
+
await writePrivateFile(join(root, completionFile), files.get(completionFile), { beforePublish: checkLocation });
|
|
107
|
+
await checkLocation();
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Retain only this newly created partial output for deliberate inspection;
|
|
111
|
+
// recursive cleanup could remove unrelated files added by another process.
|
|
112
|
+
throw new TypeError('The new evidence folder could not be completed or confirmed. It may contain private partial output; inspect or remove it before choosing a new destination. Existing folders were not replaced.');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -1,112 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
|
-
import { canonicalArtifactJsonV2, SORTED_JSON_V2, sha256ArtifactDigestV2, } from "../packages/evidence/artifact-integrity.mjs";
|
|
4
|
-
import { requireBoundedString, requireIsoTimestamp, } from "../lib/bounded-contract-normalizers.mjs";
|
|
5
|
-
import { normalizeBoundedSemanticVersion } from "../lib/semantic-version.mjs";
|
|
6
|
-
import { parseBoundedJsonObject } from "./bounded-json.mjs";
|
|
7
|
-
export const INVESTIGATION_MANIFEST_SCHEMA = 'whoisleuth.investigation-manifest';
|
|
8
|
-
export const INVESTIGATION_MANIFEST_VERSION = 2;
|
|
9
|
-
export const MAX_INVESTIGATION_MANIFEST_ARTIFACTS = 16;
|
|
10
|
-
export const MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES = 15 * 1024 * 1024;
|
|
11
|
-
export const MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES = 32 * 1024 * 1024;
|
|
12
|
-
const SHA256_RE = /^sha256:[a-f0-9]{64}$/u;
|
|
13
|
-
function digest(value) {
|
|
14
|
-
return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`;
|
|
15
|
-
}
|
|
16
|
-
function artifactVersion(value) {
|
|
17
|
-
const candidate = value.version ?? value.schemaVersion;
|
|
18
|
-
return Number.isSafeInteger(candidate) && Number(candidate) > 0 && Number(candidate) <= 1_000
|
|
19
|
-
? Number(candidate)
|
|
20
|
-
: null;
|
|
21
|
-
}
|
|
22
|
-
export async function buildInvestigationManifest(input, generatedAtValue, applicationVersionValue) {
|
|
23
|
-
if (!Array.isArray(input.artifacts)
|
|
24
|
-
|| input.artifacts.length < 1
|
|
25
|
-
|| input.artifacts.length > MAX_INVESTIGATION_MANIFEST_ARTIFACTS) {
|
|
26
|
-
throw new TypeError(`A manifest requires between 1 and ${MAX_INVESTIGATION_MANIFEST_ARTIFACTS} JSON artefacts.`);
|
|
27
|
-
}
|
|
28
|
-
const generatedAt = requireIsoTimestamp(generatedAtValue, 'generatedAt');
|
|
29
|
-
const workflow = requireBoundedString(input.workflow, 'workflow', 160);
|
|
30
|
-
const applicationVersion = normalizeBoundedSemanticVersion(applicationVersionValue, 'Application');
|
|
31
|
-
const configurationDigestSha256 = input.configurationDigestSha256;
|
|
32
|
-
if (configurationDigestSha256 !== null && !SHA256_RE.test(configurationDigestSha256)) {
|
|
33
|
-
throw new TypeError('configurationDigestSha256 must be a sha256: hexadecimal digest.');
|
|
34
|
-
}
|
|
35
|
-
let totalBytes = 0;
|
|
36
|
-
const artifacts = input.artifacts.map((artifact, index) => {
|
|
37
|
-
if (typeof artifact.content !== 'string')
|
|
38
|
-
throw new TypeError(`Artifact ${index + 1} must be UTF-8 JSON text.`);
|
|
39
|
-
const byteLength = Buffer.byteLength(artifact.content, 'utf8');
|
|
40
|
-
totalBytes += byteLength;
|
|
41
|
-
if (byteLength < 1 || byteLength > MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES) {
|
|
42
|
-
throw new TypeError(`Artifact ${index + 1} must be between 1 byte and ${MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES} bytes.`);
|
|
43
|
-
}
|
|
44
|
-
if (totalBytes > MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES) {
|
|
45
|
-
throw new TypeError(`Manifest artefacts exceed the ${MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES}-byte combined limit.`);
|
|
46
|
-
}
|
|
47
|
-
const value = parseBoundedJsonObject(artifact.content, {
|
|
48
|
-
label: `Artifact ${index + 1}`,
|
|
49
|
-
maximumBytes: MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES,
|
|
50
|
-
});
|
|
51
|
-
const schema = value.schema === undefined
|
|
52
|
-
? null
|
|
53
|
-
: requireBoundedString(value.schema, `Artifact ${index + 1} schema`, 160);
|
|
54
|
-
const canonical = canonicalArtifactJsonV2(value);
|
|
55
|
-
const contentDigestSha256 = digest(artifact.content);
|
|
56
|
-
return Object.freeze({
|
|
57
|
-
sequence: index + 1,
|
|
58
|
-
id: `artifact-${index + 1}`,
|
|
59
|
-
schema,
|
|
60
|
-
version: artifactVersion(value),
|
|
61
|
-
byteLength,
|
|
62
|
-
contentDigestSha256,
|
|
63
|
-
canonicalDigestSha256: digest(canonical),
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
const unsigned = Object.freeze({
|
|
67
|
-
schema: INVESTIGATION_MANIFEST_SCHEMA,
|
|
68
|
-
version: INVESTIGATION_MANIFEST_VERSION,
|
|
69
|
-
generatedAt,
|
|
70
|
-
application: Object.freeze({ name: 'WHOISleuth CLI', version: applicationVersion }),
|
|
71
|
-
workflow,
|
|
72
|
-
configuration: Object.freeze({ digestSha256: configurationDigestSha256 }),
|
|
73
|
-
artifacts: Object.freeze(artifacts),
|
|
74
|
-
steps: Object.freeze(artifacts.map((artifact) => Object.freeze({
|
|
75
|
-
sequence: artifact.sequence,
|
|
76
|
-
artifactId: artifact.id,
|
|
77
|
-
contentDigestSha256: artifact.contentDigestSha256,
|
|
78
|
-
}))),
|
|
79
|
-
summary: Object.freeze({ artifactCount: artifacts.length, totalBytes }),
|
|
80
|
-
limitations: Object.freeze([
|
|
81
|
-
'The manifest records ordered content digests, schema metadata, tool version, and an optional configuration digest; it deliberately omits source file paths and content values.',
|
|
82
|
-
'Reproduction still depends on access to the exact input artefacts, compatible source services, and any separately retained configuration represented by the supplied digest.',
|
|
83
|
-
'Matching digests establish content identity, not the accuracy, provenance, or current validity of an observation.',
|
|
84
|
-
]),
|
|
85
|
-
});
|
|
86
|
-
return Object.freeze({
|
|
87
|
-
...unsigned,
|
|
88
|
-
integrity: Object.freeze({
|
|
89
|
-
algorithm: 'SHA-256',
|
|
90
|
-
canonicalization: SORTED_JSON_V2,
|
|
91
|
-
digestSha256: await sha256ArtifactDigestV2(unsigned),
|
|
92
|
-
}),
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
export function formatInvestigationManifest(manifest) {
|
|
96
|
-
const lines = [
|
|
97
|
-
'Investigation manifest',
|
|
98
|
-
`Workflow ${manifest.workflow}`,
|
|
99
|
-
`Tool version ${manifest.application.version}`,
|
|
100
|
-
`Artifacts ${manifest.summary.artifactCount}`,
|
|
101
|
-
`Total bytes ${manifest.summary.totalBytes}`,
|
|
102
|
-
`Configuration ${manifest.configuration.digestSha256 ?? 'not supplied'}`,
|
|
103
|
-
`Integrity ${manifest.integrity.digestSha256}`,
|
|
104
|
-
'',
|
|
105
|
-
'Ordered artefacts:',
|
|
106
|
-
];
|
|
107
|
-
for (const artifact of manifest.artifacts) {
|
|
108
|
-
lines.push(` ${artifact.sequence}. ${artifact.schema ?? 'unversioned JSON'}${artifact.version ? ` v${artifact.version}` : ''} · ${artifact.contentDigestSha256}`);
|
|
109
|
-
}
|
|
110
|
-
lines.push('', 'No source file paths or artefact contents are retained in this manifest.', '');
|
|
111
|
-
return lines.join('\n');
|
|
112
|
-
}
|
|
1
|
+
export * from "../packages/investigation/investigation-manifest.mjs";
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { sha256ArtifactBytes } from "../packages/evidence/artifact-integrity.mjs";
|
|
2
|
+
import { MAX_INVESTIGATION_PACKAGE_BYTES, encodeInvestigationPackageEntries, inspectInvestigationPackage } from "../packages/investigation/investigation-package.mjs";
|
|
3
|
+
import { INVESTIGATION_MANIFEST_SCHEMA } from "../packages/investigation/investigation-manifest.mjs";
|
|
4
|
+
import { hasEncryptedInvestigationPackagePrefix, MAX_ENCRYPTED_INVESTIGATION_PACKAGE_BYTES } from "../packages/contracts/investigation-package-limits.mjs";
|
|
5
|
+
import { decryptInvestigationPackage } from "../packages/investigation/investigation-package-crypto.mjs";
|
|
6
|
+
import { readEditableCaseExport } from "../packages/cases/case-export-input.mjs";
|
|
7
|
+
import { matchCaseReviewFiles } from "../packages/cases/case-review-package.mjs";
|
|
8
|
+
import { OFFLINE_ARTIFACT_VERIFICATION_SCHEMA, OFFLINE_ARTIFACT_VERIFICATION_VERSION, UnsupportedOfflineArtifactError, isCompleteOfflineArtifactVerification, verifyOfflineArtifact, } from "./artifact-verify.mjs";
|
|
9
|
+
export async function verifyOfflineInvestigationFolder(files) {
|
|
10
|
+
const report = await verifyOfflineInvestigationPackage(encodeInvestigationPackageEntries(files));
|
|
11
|
+
return Object.freeze({ ...report, limitations: Object.freeze([
|
|
12
|
+
'Folder input was converted in memory to the canonical stored-ZIP representation. Package digest and input byte count identify that representation, not filesystem metadata. No ZIP was written.',
|
|
13
|
+
...report.limitations,
|
|
14
|
+
]) });
|
|
15
|
+
}
|
|
16
|
+
export async function verifyOfflineInvestigationPackage(input, passphrase) {
|
|
17
|
+
if (!(input instanceof Uint8Array) || input.byteLength > MAX_ENCRYPTED_INVESTIGATION_PACKAGE_BYTES)
|
|
18
|
+
throw new TypeError('Investigation package exceeds its input limit.');
|
|
19
|
+
if (hasEncryptedInvestigationPackagePrefix(input)) {
|
|
20
|
+
if (typeof passphrase !== 'string')
|
|
21
|
+
throw new TypeError('Encrypted evidence packages require --passphrase-file. No content was decrypted.');
|
|
22
|
+
const decrypted = await decryptInvestigationPackage(input, passphrase);
|
|
23
|
+
try {
|
|
24
|
+
const report = await verifyOfflineInvestigationPackage(decrypted.bytes);
|
|
25
|
+
return Object.freeze({ ...report,
|
|
26
|
+
checks: Object.freeze({ ...report.checks, authenticatedEncryption: 'verified' }),
|
|
27
|
+
summary: Object.freeze({ ...report.summary, inputBytes: input.byteLength, ciphertextBytes: decrypted.ciphertextBytes }),
|
|
28
|
+
limitations: Object.freeze(['The complete encrypted container authenticated. Its package digest identifies the decrypted ZIP, not the encrypted file. Encryption does not establish source identity or factual accuracy.', ...report.limitations]),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
decrypted.bytes.fill(0);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (passphrase !== undefined)
|
|
36
|
+
throw new TypeError('A passphrase was supplied for an unencrypted evidence package. No encrypted-container assurance can be given.');
|
|
37
|
+
if (!(input instanceof Uint8Array) || input.byteLength > MAX_INVESTIGATION_PACKAGE_BYTES)
|
|
38
|
+
throw new TypeError('Investigation package exceeds its input limit.');
|
|
39
|
+
const bytes = new Uint8Array(input);
|
|
40
|
+
const inspected = await inspectInvestigationPackage(bytes);
|
|
41
|
+
const entries = [];
|
|
42
|
+
const caseFiles = [];
|
|
43
|
+
for (const reviewed of inspected.entries) {
|
|
44
|
+
let state = reviewed.state === 'rejected' ? 'rejected' : reviewed.interpretation === 'opaque' ? 'opaque' : 'unsupported';
|
|
45
|
+
let verification = null;
|
|
46
|
+
let issue = reviewed.issue ?? (reviewed.interpretation === 'unsupported_json_value'
|
|
47
|
+
? 'JSON content identity is verified, but this value has no supported offline source-format reader.' : null);
|
|
48
|
+
if (reviewed.state === 'identity_verified' && reviewed.interpretation === 'not_checked') {
|
|
49
|
+
try {
|
|
50
|
+
const raw = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(inspected.contents.get(reviewed.entry.id));
|
|
51
|
+
const result = await verifyOfflineArtifact(raw);
|
|
52
|
+
verification = { artifact: result.artifact, state: result.state, checks: result.checks, summary: result.summary };
|
|
53
|
+
state = isCompleteOfflineArtifactVerification(result) ? 'admitted' : 'review_required';
|
|
54
|
+
if (state === 'review_required')
|
|
55
|
+
issue = 'The supported reader requires additional verification before complete assurance.';
|
|
56
|
+
if (result.artifact.kind === 'case_export') {
|
|
57
|
+
const candidates = inspected.entries.filter(item => item.entry.id !== reviewed.entry.id && item.state === 'identity_verified')
|
|
58
|
+
.map(item => ({ id: item.entry.id, digestSha256: item.entry.contentDigestSha256, byteLength: item.entry.byteLength }));
|
|
59
|
+
const cases = readEditableCaseExport(raw);
|
|
60
|
+
let references = 0, missing = 0;
|
|
61
|
+
for (const record of cases) {
|
|
62
|
+
const matched = matchCaseReviewFiles(record, candidates);
|
|
63
|
+
references += matched.length;
|
|
64
|
+
missing += matched.filter(item => !item.entries.length).length;
|
|
65
|
+
}
|
|
66
|
+
// Keep incident identifiers and filenames out of redacted reports.
|
|
67
|
+
caseFiles.push({ entryId: reviewed.entry.id, caseCount: cases.length, references, matched: references - missing, missing });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (cause) {
|
|
71
|
+
state = cause instanceof UnsupportedOfflineArtifactError ? 'unsupported' : 'rejected';
|
|
72
|
+
issue = state === 'unsupported' ? 'No supported offline reader accepts this declared source format.' : 'The source file failed its independent format or integrity checks.';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
entries.push({ id: reviewed.entry.id, byteLength: reviewed.entry.byteLength,
|
|
76
|
+
mediaType: 'mediaType' in reviewed.entry ? reviewed.entry.mediaType : 'application/json',
|
|
77
|
+
state, identity: reviewed.state === 'identity_verified' ? 'verified' : 'failed', verification, issue });
|
|
78
|
+
}
|
|
79
|
+
const complete = entries.every((entry) => entry.state === 'admitted' || entry.state === 'opaque')
|
|
80
|
+
&& inspected.links.every((link) => link.state === 'linked')
|
|
81
|
+
&& inspected.captureManifests.every(capture => capture.state === 'matched')
|
|
82
|
+
&& caseFiles.every(item => item.missing === 0);
|
|
83
|
+
return Object.freeze({
|
|
84
|
+
schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA, version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
|
|
85
|
+
artifact: Object.freeze({ kind: 'investigation_package', schema: INVESTIGATION_MANIFEST_SCHEMA, version: inspected.manifest.version }),
|
|
86
|
+
state: complete ? 'verified' : 'partial',
|
|
87
|
+
checks: Object.freeze({ structure: 'verified', contentIntegrity: inspected.identityVerified ? 'verified' : 'failed',
|
|
88
|
+
contentIntegrityScope: 'manifest_and_files', authenticatedEncryption: 'not_applicable' }),
|
|
89
|
+
summary: Object.freeze({ inputBytes: bytes.byteLength, sectionCount: entries.length, recordCount: null, ciphertextBytes: null }),
|
|
90
|
+
manifestIdentity: null,
|
|
91
|
+
package: Object.freeze({ digestSha256: await sha256ArtifactBytes(bytes), audience: 'private', storageEffect: 'none',
|
|
92
|
+
signatureTrust: 'not_checked', timestampAssurance: 'not_checked', factualAccuracy: 'not_established',
|
|
93
|
+
entries: Object.freeze(entries), links: inspected.links, captureManifests: inspected.captureManifests, caseFiles: Object.freeze(caseFiles) }),
|
|
94
|
+
limitations: Object.freeze([
|
|
95
|
+
'The manifest and selected file bytes are checked independently. Re-compressing the ZIP can change its archive digest without changing its verified file content.',
|
|
96
|
+
'Opaque files are retained byte-for-byte, not rendered, executed or validated as images or documents. Unsupported JSON remains separate from admitted source formats.',
|
|
97
|
+
'Capture attachment checks bind selected bytes to the capture manifest declaration, not to a trusted publisher or real website observation.',
|
|
98
|
+
'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
|
+
'Source declarations and packaging times are not authenticated. Signatures and timestamp tokens require their own verification and trust decisions.',
|
|
100
|
+
'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
|
+
]),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { domainToASCII } from 'node:url';
|
|
2
2
|
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
3
|
+
import { CLI_DOMAIN_CONTROL_MONITOR_SCHEMA } from "../packages/contracts/domain-control-monitor.mjs";
|
|
4
|
+
import { DOMAIN_CONTROL_REVIEW_SCHEMA } from "../packages/contracts/domain-control-review.mjs";
|
|
5
|
+
import { EXTERNAL_FINDINGS_SCHEMA } from "../packages/interchange/external-findings-import.mjs";
|
|
6
|
+
import { CLI_LOOKUP_BRIEF_SCHEMA } from "./lookup-brief.mjs";
|
|
3
7
|
import { INVESTIGATION_PLAN_RECIPES, RUNNABLE_INVESTIGATION_PLAN_RECIPES, cliInvocationNetworkEffect, } from "./command-reference.mjs";
|
|
4
8
|
export const CLI_INVESTIGATION_PLAN_SCHEMA = 'whoisleuth.cli.investigation-plan';
|
|
5
9
|
export const CLI_INVESTIGATION_PLAN_VERSION = 1;
|
|
@@ -10,16 +14,19 @@ const RECIPES = Object.freeze({
|
|
|
10
14
|
'domain-triage': Object.freeze({
|
|
11
15
|
id: 'domain-triage',
|
|
12
16
|
label: 'New domain triage',
|
|
17
|
+
standardInputs: Object.freeze([
|
|
18
|
+
{ stepId: 'export', input: 1, sourceStepId: 'collect' },
|
|
19
|
+
{ stepId: 'verify', input: 1, sourceStepId: 'export' },
|
|
20
|
+
]),
|
|
13
21
|
subjectRequirement: 'domain',
|
|
14
22
|
objective: 'Collect and preserve separately attributed registration, DNS, HTTP, TLS, page, and network-context evidence.',
|
|
15
|
-
runnable: true,
|
|
16
23
|
limitations: Object.freeze([
|
|
17
24
|
'Collection remains analyst-triggered and source limitations remain explicit.',
|
|
18
|
-
'Disposition, reviewed response actions, monitoring, and closure continue in the
|
|
25
|
+
'Disposition, reviewed response actions, monitoring, and closure continue in the saved Case workspace; this CLI recipe does not submit reports.',
|
|
19
26
|
]),
|
|
20
27
|
steps: (domain) => Object.freeze([
|
|
21
28
|
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.'),
|
|
22
|
-
step('export', 'Create a portable evidence report', 'export', ['<saved-lookup.json>'], 'offline', 'analyst_selection', 'whoisleuth.lookup-evidence', '
|
|
29
|
+
step('export', 'Create a portable evidence report', 'export', ['<saved-lookup.json>'], 'offline', 'analyst_selection', 'whoisleuth.lookup-evidence', 'Reuse the collected Lookup by default, or select a different reviewed Lookup file.'),
|
|
23
30
|
step('verify', 'Verify the exported artefact', 'verify-artifact', ['<evidence.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.offline-artifact-verification', 'Keep verification distinct from a claim that the observations are correct or current.'),
|
|
24
31
|
]),
|
|
25
32
|
}),
|
|
@@ -28,7 +35,6 @@ const RECIPES = Object.freeze({
|
|
|
28
35
|
label: 'Lookalike candidate review',
|
|
29
36
|
subjectRequirement: 'brand_or_domain',
|
|
30
37
|
objective: 'Generate a bounded candidate queue, collect only the selected scope, and retain a reviewed candidate lookup.',
|
|
31
|
-
runnable: true,
|
|
32
38
|
limitations: Object.freeze([
|
|
33
39
|
'Candidate generation does not establish registration, control, intent, or maliciousness.',
|
|
34
40
|
'Official-reference collection and page comparison require analyst-selected saved evidence; use page-compare after retaining the reference and candidate observations.',
|
|
@@ -44,20 +50,22 @@ const RECIPES = Object.freeze({
|
|
|
44
50
|
label: 'Owned domain posture review',
|
|
45
51
|
subjectRequirement: 'domain',
|
|
46
52
|
objective: 'Review current passive posture and compare supplied observations with an analyst-authored control manifest.',
|
|
47
|
-
runnable: true,
|
|
48
53
|
limitations: Object.freeze(['Use only for a domain the analyst owns or is authorised to review.']),
|
|
49
54
|
steps: (domain) => Object.freeze([
|
|
50
55
|
step('posture', 'Collect bounded DNS posture', 'posture', [domain, '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.posture', 'Review mail profile and delegation evidence before interpreting missing records.'),
|
|
51
56
|
step('lookup', 'Collect supporting Deep evidence', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'Retain separately attributed registration, DNS, TLS, and page observations.'),
|
|
52
|
-
step('manifest', 'Review the domain control manifest', 'domain-control', ['<review-input.json>', '--json'], 'offline', 'analyst_selection',
|
|
57
|
+
step('manifest', 'Review the domain control manifest', 'domain-control', ['<review-input.json>', '--json'], 'offline', 'analyst_selection', DOMAIN_CONTROL_REVIEW_SCHEMA, 'Only complete supplied observations may produce drift.'),
|
|
53
58
|
]),
|
|
54
59
|
}),
|
|
55
60
|
'historical-comparison': Object.freeze({
|
|
56
61
|
id: 'historical-comparison',
|
|
57
62
|
label: 'Historical observation comparison',
|
|
63
|
+
standardInputs: Object.freeze([
|
|
64
|
+
{ stepId: 'diff', input: 2, sourceStepId: 'current' },
|
|
65
|
+
{ stepId: 'timeline', input: 3, sourceStepId: 'current' },
|
|
66
|
+
]),
|
|
58
67
|
subjectRequirement: 'domain',
|
|
59
68
|
objective: 'Collect a current observation and compare it with analyst-selected saved observations without merging source states.',
|
|
60
|
-
runnable: true,
|
|
61
69
|
limitations: Object.freeze(['A later observation does not retroactively refresh retained evidence.']),
|
|
62
70
|
steps: (domain) => Object.freeze([
|
|
63
71
|
step('current', 'Collect the current lookup', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'A current request does not refresh or validate older provider-reported history.'),
|
|
@@ -70,12 +78,11 @@ const RECIPES = Object.freeze({
|
|
|
70
78
|
label: 'Campaign candidate review',
|
|
71
79
|
subjectRequirement: 'brand_or_domain',
|
|
72
80
|
objective: 'Prepare a bounded candidate set, collect a deliberately selected queue, and review retained evidence without asserting campaign attribution.',
|
|
73
|
-
runnable: false,
|
|
74
81
|
limitations: Object.freeze(['Grouping candidates is analyst triage and does not prove common ownership, control, infrastructure, or intent.']),
|
|
75
82
|
steps: (subject) => Object.freeze([
|
|
76
83
|
step('prepare', 'Prepare candidates offline', 'discover', [subject, '--preset', 'all', '--json'], 'offline', 'none', 'whoisleuth.cli.discover', 'Review mutation families and bounded omissions before selecting a collection scope.'),
|
|
77
84
|
step('collect', 'Collect the selected candidate queue', 'discover-scan', [subject, '--fast', '--scan-limit', '50', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.discovery-scan', 'Treat partial and inconclusive authority results as explicit outcomes.'),
|
|
78
|
-
step('review', '
|
|
85
|
+
step('review', 'Prepare a selected candidate brief', 'brief', ['<saved-lookup.json>', '--json'], 'offline', 'analyst_selection', CLI_LOOKUP_BRIEF_SCHEMA, 'Select a saved candidate Lookup; keep any campaign grouping analyst-authored.'),
|
|
79
86
|
]),
|
|
80
87
|
}),
|
|
81
88
|
'certificate-anomaly': Object.freeze({
|
|
@@ -83,20 +90,22 @@ const RECIPES = Object.freeze({
|
|
|
83
90
|
label: 'Certificate anomaly review',
|
|
84
91
|
subjectRequirement: 'domain',
|
|
85
92
|
objective: 'Review bounded certificate observations alongside current source-qualified domain evidence without treating issuance as proof of control or intent.',
|
|
86
|
-
runnable: false,
|
|
87
93
|
limitations: Object.freeze(['Certificate observations are separately attributed and do not establish current service control.']),
|
|
88
94
|
steps: (domain) => Object.freeze([
|
|
89
95
|
step('search', 'Collect bounded certificate observations', 'ct-search', [domain, '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.ct-search', 'Review source availability, truncation, and observation timing.'),
|
|
90
|
-
step('intake', 'Normalise the selected observations', 'ct-intake', ['<certificate-events.json>', '--json'], 'offline', 'analyst_selection', '
|
|
96
|
+
step('intake', 'Normalise the selected observations', 'ct-intake', ['<certificate-events.json>', '--json'], 'offline', 'analyst_selection', EXTERNAL_FINDINGS_SCHEMA, 'Select a certificate-event batch; a certificate-search report is not interchangeable with that input.'),
|
|
91
97
|
step('corroborate', 'Collect supporting domain evidence', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'Compare evidence families without collapsing certificate and registration identities.'),
|
|
92
98
|
]),
|
|
93
99
|
}),
|
|
94
100
|
'registry-disagreement': Object.freeze({
|
|
95
101
|
id: 'registry-disagreement',
|
|
96
102
|
label: 'Registry disagreement review',
|
|
103
|
+
standardInputs: Object.freeze([
|
|
104
|
+
{ stepId: 'compare', input: 1, sourceStepId: 'collect' },
|
|
105
|
+
{ stepId: 'report', input: 1, sourceStepId: 'collect' },
|
|
106
|
+
]),
|
|
97
107
|
subjectRequirement: 'domain',
|
|
98
108
|
objective: 'Collect separately attributed registration evidence and review conflicting publications without selecting an arbitrary source as truth.',
|
|
99
|
-
runnable: false,
|
|
100
109
|
limitations: Object.freeze(['Only authority-aware registration evidence may decide availability; disagreement remains explicit.']),
|
|
101
110
|
steps: (domain) => Object.freeze([
|
|
102
111
|
step('collect', 'Collect source-qualified registration evidence', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'Retain RDAP, registrar RDAP, WHOIS, and authority states separately.'),
|
|
@@ -107,12 +116,12 @@ const RECIPES = Object.freeze({
|
|
|
107
116
|
'evidence-handoff': Object.freeze({
|
|
108
117
|
id: 'evidence-handoff',
|
|
109
118
|
label: 'Reviewed evidence handoff',
|
|
119
|
+
standardInputs: Object.freeze([{ stepId: 'lint', input: 1, sourceStepId: 'package' }]),
|
|
110
120
|
subjectRequirement: 'review_label',
|
|
111
121
|
objective: 'Verify, minimise, and package analyst-selected evidence for a deliberate handoff without transmitting or submitting it.',
|
|
112
|
-
runnable: false,
|
|
113
122
|
limitations: Object.freeze(['The recipe prepares local material only; sharing remains a separate deliberate action.']),
|
|
114
123
|
steps: () => Object.freeze([
|
|
115
|
-
step('verify', 'Verify the selected artefact', 'verify-artifact', ['<evidence.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.offline-artifact-verification', 'Verification checks structure and integrity, not the truth or currency of observations.'),
|
|
124
|
+
step('verify', 'Verify the selected artefact', 'verify-artifact', ['<evidence.json>', '--json', '--strict-exit'], 'offline', 'analyst_selection', 'whoisleuth.offline-artifact-verification', 'Verification checks structure and integrity, not the truth or currency of observations.'),
|
|
116
125
|
step('package', 'Build a reviewed public Case-pack', 'case-pack', ['<cases.json>', '--audience', 'public', '--reviewed', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.case-pack', 'Review minimisation and audience projection before retaining the separate package.'),
|
|
117
126
|
step('lint', 'Review deliberate-sharing metadata', 'sharing-review', ['<package.json>', '--marking', 'clear', '--recipient-scope', 'public', '--purpose', 'reviewed evidence handoff', '--human-reviewed', '--personal-data-reviewed', '--redactions-confirmed', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.sharing-review', 'A clear lint result does not send, upload, publish, or authorise the artefact.'),
|
|
118
127
|
]),
|
|
@@ -122,10 +131,9 @@ const RECIPES = Object.freeze({
|
|
|
122
131
|
label: 'Planned domain change',
|
|
123
132
|
subjectRequirement: 'domain',
|
|
124
133
|
objective: 'Review an analyst-authored desired state and prepare bounded change material without changing DNS, registry, mail, or hosted configuration.',
|
|
125
|
-
runnable: false,
|
|
126
134
|
limitations: Object.freeze(['Planning and packaging never apply, submit, schedule, or enforce a change.']),
|
|
127
135
|
steps: () => Object.freeze([
|
|
128
|
-
step('control', 'Review the desired-state manifest', 'domain-control', ['<review-input.json>', '--json'], 'offline', 'analyst_selection',
|
|
136
|
+
step('control', 'Review the desired-state manifest', 'domain-control', ['<review-input.json>', '--json'], 'offline', 'analyst_selection', DOMAIN_CONTROL_REVIEW_SCHEMA, 'Only supplied complete observations may produce drift.'),
|
|
129
137
|
step('assure', 'Review change and recovery assumptions', 'assurance', ['<assurance-input.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.domain-assurance', 'Record uncertainty, rollback dependencies, and unavailable evidence explicitly.'),
|
|
130
138
|
step('package', 'Build a reviewed change packet', 'change-packet', ['<change-packet-input.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.domain-change-packet', 'The packet is local reviewed material and performs no submission or enforcement.'),
|
|
131
139
|
]),
|
|
@@ -135,20 +143,25 @@ const RECIPES = Object.freeze({
|
|
|
135
143
|
label: 'Post-change verification',
|
|
136
144
|
subjectRequirement: 'domain',
|
|
137
145
|
objective: 'Perform one explicit later observation and compare it with analyst-selected retained evidence after an authorised change.',
|
|
138
|
-
runnable: false,
|
|
139
146
|
limitations: Object.freeze(['This is a one-time recheck, not monitoring setup or proof that every resolver or service has converged.']),
|
|
140
147
|
steps: () => Object.freeze([
|
|
141
|
-
step('recheck', 'Run one bounded retained-manifest review', 'monitor-once', ['<manifest.json>', '--limit', '1', '--json'], 'network', 'network_disclosure',
|
|
148
|
+
step('recheck', 'Run one bounded retained-manifest review', 'monitor-once', ['<manifest.json>', '--limit', '1', '--json'], 'network', 'network_disclosure', CLI_DOMAIN_CONTROL_MONITOR_SCHEMA, 'One later observation may remain partial, unavailable, stale, or conflicting.'),
|
|
142
149
|
step('compare', 'Compare selected before and after evidence', 'diff', ['<before.json>', '<after.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.lookup-diff', 'Materiality is derived from compatible retained evidence and does not infer intent.'),
|
|
143
150
|
step('record', 'Record reviewed completion material', 'change-packet', ['<post-change-input.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.domain-change-packet', 'Recording reviewed material does not submit it or start automatic monitoring.'),
|
|
144
151
|
]),
|
|
145
152
|
}),
|
|
146
153
|
});
|
|
147
154
|
export const INVESTIGATION_RECIPE_REGISTRY = RECIPES;
|
|
155
|
+
export function workflowStandardInputs(recipe) {
|
|
156
|
+
return RECIPES[recipe].standardInputs ?? [];
|
|
157
|
+
}
|
|
148
158
|
export const INVESTIGATION_PLAN_RECIPE_LABELS = Object.freeze(Object.fromEntries(INVESTIGATION_PLAN_RECIPES.map((recipe) => [recipe, RECIPES[recipe].label])));
|
|
149
159
|
export function isRunnableInvestigationRecipe(value) {
|
|
150
160
|
return RUNNABLE_INVESTIGATION_PLAN_RECIPES.includes(value);
|
|
151
161
|
}
|
|
162
|
+
export function workflowReviewDeclarations(step) {
|
|
163
|
+
return step.arguments.filter(argument => ['--reviewed', '--human-reviewed', '--personal-data-reviewed', '--redactions-confirmed'].includes(argument));
|
|
164
|
+
}
|
|
152
165
|
function recipeCatalogueEntry(recipe) {
|
|
153
166
|
const exampleSubject = recipe.subjectRequirement === 'domain' ? 'example.test' : 'Example Organisation';
|
|
154
167
|
const steps = recipe.steps(exampleSubject).map((item) => Object.freeze({
|
|
@@ -166,7 +179,7 @@ function recipeCatalogueEntry(recipe) {
|
|
|
166
179
|
label: recipe.label,
|
|
167
180
|
objective: recipe.objective,
|
|
168
181
|
subjectRequirement: recipe.subjectRequirement,
|
|
169
|
-
runnableByWorkflowRun: recipe.
|
|
182
|
+
runnableByWorkflowRun: isRunnableInvestigationRecipe(recipe.id),
|
|
170
183
|
networkModes: Object.freeze([...new Set(steps.map((item) => item.mode))]),
|
|
171
184
|
approvals: Object.freeze([...new Set(steps.map((item) => item.approval))]),
|
|
172
185
|
steps: Object.freeze(steps),
|