@slicedearth/whoisleuth-cli 1.36.1 → 1.43.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/README.md +32 -2
- package/bin/whoisleuth.mjs +52 -24
- package/cli/archive-inspect.mjs +37 -2
- package/cli/arguments.mjs +493 -157
- package/cli/artifact-verify.mjs +91 -15
- package/cli/bulk-checkpoint.mjs +7 -11
- package/cli/bulk-command-runner.mjs +19 -2
- package/cli/case-pack.mjs +136 -0
- package/cli/ci-report.mjs +73 -0
- package/cli/collection-preflight.mjs +59 -0
- package/cli/command-reference.mjs +395 -0
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +49 -11
- package/cli/config-profile.mjs +180 -0
- package/cli/ct-event-intake.mjs +159 -0
- package/cli/discovery-scan-command-runner.mjs +15 -1
- package/cli/doctor.mjs +11 -5
- package/cli/domain-control-monitor.mjs +104 -0
- package/cli/domain-control-observations.mjs +235 -0
- package/cli/evidence-command-arguments.mjs +12 -1
- package/cli/evidence-command-runner.mjs +3 -2
- package/cli/evidence-signing.mjs +4 -4
- package/cli/external-observation-mapping.mjs +162 -0
- package/cli/fail-policy.mjs +64 -0
- package/cli/formatters/html.mjs +2 -2
- package/cli/formatters/json.mjs +13 -1
- package/cli/formatters/terminal.mjs +7 -3
- package/cli/investigation-manifest.mjs +113 -0
- package/cli/investigation-plan.mjs +2 -2
- package/cli/investigation-run.mjs +112 -0
- package/cli/local-mmdb-review.mjs +76 -0
- package/cli/lookalike-calibration.mjs +121 -0
- package/cli/lookup-brief.mjs +116 -0
- package/cli/lookup-command-runner.mjs +12 -2
- package/cli/lookup-diff.mjs +17 -19
- package/cli/lookup-plan.mjs +3 -3
- package/cli/lookup-reconcile.mjs +141 -0
- package/cli/lookup-timeline.mjs +1 -1
- package/cli/mail-review.mjs +1 -1
- package/cli/network-command-runner.mjs +4 -1
- package/cli/offline-evidence-review.mjs +125 -10
- package/cli/open-asset-model-bridge.mjs +169 -0
- package/cli/registry-cohort.mjs +121 -0
- package/cli/registry-doctor.mjs +220 -0
- package/cli/registry-fixture-scaffold.mjs +73 -0
- package/cli/risk-calibration.mjs +117 -9
- package/cli/runner.mjs +561 -280
- package/cli/saved-lookup.mjs +2 -2
- package/cli/sharing-review.mjs +202 -0
- package/cli/source-reliability.mjs +1 -1
- package/docs/cli-reference.md +1336 -0
- package/docs/cli.md +259 -940
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +7 -7
- package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
- package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
- package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
- package/frontend/src/lib/analysis/case-migration-model.js +6 -1
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-core.js +5 -1
- package/frontend/src/lib/analysis/case-record-operations.js +6 -1
- package/frontend/src/lib/analysis/case-report.js +573 -0
- package/frontend/src/lib/analysis/case-response-model.js +30 -1
- package/frontend/src/lib/analysis/case-response-packet.js +7 -7
- package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
- package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
- package/frontend/src/lib/analysis/external-findings-import.js +320 -0
- package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
- package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +487 -0
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +64 -0
- package/frontend/src/lib/analysis/lookup-response.js +2 -0
- package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
- package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
- package/frontend/src/lib/analysis/page-similarity.js +1 -1
- package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
- package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
- package/frontend/src/lib/analysis/scoring.js +9 -89
- package/frontend/src/lib/analysis/shortlist-model.js +8 -4
- package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
- package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
- package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
- package/frontend/src/lib/analysis/visualization-models.js +369 -0
- package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive.js +4 -2
- package/lib/abusech-auth.mjs +1 -1
- package/lib/analyst-taxonomy.mjs +40 -0
- package/lib/availability.mjs +2 -2
- package/lib/bounded-contract-normalizers.mjs +33 -0
- package/lib/browser-library-profile.mjs +15 -2
- package/lib/certificate-extension-profile.mjs +71 -0
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/dns-convergence-review.mjs +183 -0
- package/lib/dns-delegation-health.mjs +196 -3
- package/lib/domain-assurance.mjs +368 -0
- package/lib/domain-change-packet.mjs +133 -0
- package/lib/domain-change-review.mjs +307 -0
- package/lib/domain-control-flight-recorder.mjs +268 -0
- package/lib/domain-control-manifest.mjs +50 -7
- package/lib/domain-portfolio-review.mjs +151 -0
- package/lib/domain-posture.mjs +9 -9
- package/lib/encrypted-dns-contract.mjs +1 -1
- package/lib/evidence-export.mjs +1 -1
- package/lib/evidence-report-markdown.mjs +1 -1
- package/lib/evidence-report.mjs +1 -1
- package/lib/generated/cisa-kev-catalog.mjs +1668 -0
- package/lib/html-signals.mjs +5 -11
- package/lib/ip-prefix.mjs +34 -1
- package/lib/local-geoip-evidence.mjs +2 -2
- package/lib/lookup-response-contract.mjs +346 -0
- package/lib/nameserver-preflight-review.mjs +159 -0
- package/lib/observed-network-context.mjs +1 -1
- package/lib/opportunity-scoring.mjs +107 -0
- package/lib/page-fingerprints.mjs +1 -1
- package/lib/page-language-signals.mjs +124 -0
- package/lib/rdap-capabilities.mjs +6 -6
- package/lib/rdap-search-workbench.mjs +96 -6
- package/lib/registry-access.mjs +1 -1
- package/lib/registry-capability-catalogue.mjs +4 -4
- package/lib/registry-insights.mjs +6 -6
- package/lib/risk-scoring.mjs +247 -83
- package/lib/scoring-evidence-quality.mjs +107 -0
- package/lib/security-txt.mjs +3 -3
- package/lib/static-html-analysis.mjs +29 -1
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threatfox-intelligence.mjs +1 -1
- package/lib/tls-intelligence.mjs +13 -3
- package/lib/trust-store-comparison.mjs +146 -0
- package/lib/urlhaus-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +5 -5
- package/lib/website-technology.mjs +25 -11
- package/lib/whois-chain.mjs +1 -1
- package/lib/zone-intent-review.mjs +534 -0
- package/package.json +5 -1
- package/third-party-notices.txt +911 -19
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { canonicalArtifactJson, sha256ArtifactDigest, } from "../frontend/src/lib/analysis/artifact-integrity.js";
|
|
4
|
+
import { recordOrNull, requireBoundedString, requireIsoTimestamp, } from "../lib/bounded-contract-normalizers.mjs";
|
|
5
|
+
import { normalizeBoundedSemanticVersion } from "../lib/semantic-version.mjs";
|
|
6
|
+
export const INVESTIGATION_MANIFEST_SCHEMA = 'whoisleuth.investigation-manifest';
|
|
7
|
+
export const INVESTIGATION_MANIFEST_VERSION = 1;
|
|
8
|
+
export const MAX_INVESTIGATION_MANIFEST_ARTIFACTS = 16;
|
|
9
|
+
export const MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES = 15 * 1024 * 1024;
|
|
10
|
+
export const MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES = 32 * 1024 * 1024;
|
|
11
|
+
const SHA256_RE = /^sha256:[a-f0-9]{64}$/u;
|
|
12
|
+
function digest(value) {
|
|
13
|
+
return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`;
|
|
14
|
+
}
|
|
15
|
+
function artifactVersion(value) {
|
|
16
|
+
const candidate = value.version ?? value.schemaVersion;
|
|
17
|
+
return Number.isSafeInteger(candidate) && Number(candidate) > 0 && Number(candidate) <= 1_000
|
|
18
|
+
? Number(candidate)
|
|
19
|
+
: null;
|
|
20
|
+
}
|
|
21
|
+
export async function buildInvestigationManifest(input, generatedAtValue, applicationVersionValue) {
|
|
22
|
+
if (!Array.isArray(input.artifacts)
|
|
23
|
+
|| input.artifacts.length < 1
|
|
24
|
+
|| input.artifacts.length > MAX_INVESTIGATION_MANIFEST_ARTIFACTS) {
|
|
25
|
+
throw new TypeError(`A manifest requires between 1 and ${MAX_INVESTIGATION_MANIFEST_ARTIFACTS} JSON artefacts.`);
|
|
26
|
+
}
|
|
27
|
+
const generatedAt = requireIsoTimestamp(generatedAtValue, 'generatedAt');
|
|
28
|
+
const workflow = requireBoundedString(input.workflow, 'workflow', 160);
|
|
29
|
+
const applicationVersion = normalizeBoundedSemanticVersion(applicationVersionValue, 'Application');
|
|
30
|
+
const configurationDigestSha256 = input.configurationDigestSha256;
|
|
31
|
+
if (configurationDigestSha256 !== null && !SHA256_RE.test(configurationDigestSha256)) {
|
|
32
|
+
throw new TypeError('configurationDigestSha256 must be a sha256: hexadecimal digest.');
|
|
33
|
+
}
|
|
34
|
+
let totalBytes = 0;
|
|
35
|
+
const artifacts = input.artifacts.map((artifact, index) => {
|
|
36
|
+
if (typeof artifact.content !== 'string')
|
|
37
|
+
throw new TypeError(`Artifact ${index + 1} must be UTF-8 JSON text.`);
|
|
38
|
+
const byteLength = Buffer.byteLength(artifact.content, 'utf8');
|
|
39
|
+
totalBytes += byteLength;
|
|
40
|
+
if (byteLength < 1 || byteLength > MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES) {
|
|
41
|
+
throw new TypeError(`Artifact ${index + 1} must be between 1 byte and ${MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES} bytes.`);
|
|
42
|
+
}
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(artifact.content);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new TypeError(`Artifact ${index + 1} must be valid JSON.`);
|
|
49
|
+
}
|
|
50
|
+
const value = recordOrNull(parsed);
|
|
51
|
+
if (!value)
|
|
52
|
+
throw new TypeError(`Artifact ${index + 1} must contain one JSON object.`);
|
|
53
|
+
const schema = value.schema === undefined
|
|
54
|
+
? null
|
|
55
|
+
: requireBoundedString(value.schema, `Artifact ${index + 1} schema`, 160);
|
|
56
|
+
const canonical = canonicalArtifactJson(value);
|
|
57
|
+
const contentDigestSha256 = digest(artifact.content);
|
|
58
|
+
return Object.freeze({
|
|
59
|
+
sequence: index + 1,
|
|
60
|
+
id: `artifact-${index + 1}`,
|
|
61
|
+
schema,
|
|
62
|
+
version: artifactVersion(value),
|
|
63
|
+
byteLength,
|
|
64
|
+
contentDigestSha256,
|
|
65
|
+
canonicalDigestSha256: digest(canonical),
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
if (totalBytes > MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES) {
|
|
69
|
+
throw new TypeError(`Manifest artefacts exceed the ${MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES}-byte combined limit.`);
|
|
70
|
+
}
|
|
71
|
+
const unsigned = Object.freeze({
|
|
72
|
+
schema: INVESTIGATION_MANIFEST_SCHEMA,
|
|
73
|
+
version: INVESTIGATION_MANIFEST_VERSION,
|
|
74
|
+
generatedAt,
|
|
75
|
+
application: Object.freeze({ name: 'WHOISleuth CLI', version: applicationVersion }),
|
|
76
|
+
workflow,
|
|
77
|
+
configuration: Object.freeze({ digestSha256: configurationDigestSha256 }),
|
|
78
|
+
artifacts: Object.freeze(artifacts),
|
|
79
|
+
steps: Object.freeze(artifacts.map((artifact) => Object.freeze({
|
|
80
|
+
sequence: artifact.sequence,
|
|
81
|
+
artifactId: artifact.id,
|
|
82
|
+
contentDigestSha256: artifact.contentDigestSha256,
|
|
83
|
+
}))),
|
|
84
|
+
summary: Object.freeze({ artifactCount: artifacts.length, totalBytes }),
|
|
85
|
+
limitations: Object.freeze([
|
|
86
|
+
'The manifest records ordered content digests, schema metadata, tool version, and an optional configuration digest; it deliberately omits source file paths and content values.',
|
|
87
|
+
'Reproduction still depends on access to the exact input artefacts, compatible source services, and any separately retained configuration represented by the supplied digest.',
|
|
88
|
+
'Matching digests establish content identity, not the accuracy, provenance, or current validity of an observation.',
|
|
89
|
+
]),
|
|
90
|
+
});
|
|
91
|
+
return Object.freeze({
|
|
92
|
+
...unsigned,
|
|
93
|
+
integrity: Object.freeze({ algorithm: 'SHA-256', digestSha256: await sha256ArtifactDigest(unsigned) }),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
export function formatInvestigationManifest(manifest) {
|
|
97
|
+
const lines = [
|
|
98
|
+
'Investigation manifest',
|
|
99
|
+
`Workflow ${manifest.workflow}`,
|
|
100
|
+
`Tool version ${manifest.application.version}`,
|
|
101
|
+
`Artifacts ${manifest.summary.artifactCount}`,
|
|
102
|
+
`Total bytes ${manifest.summary.totalBytes}`,
|
|
103
|
+
`Configuration ${manifest.configuration.digestSha256 ?? 'not supplied'}`,
|
|
104
|
+
`Integrity ${manifest.integrity.digestSha256}`,
|
|
105
|
+
'',
|
|
106
|
+
'Ordered artefacts:',
|
|
107
|
+
];
|
|
108
|
+
for (const artifact of manifest.artifacts) {
|
|
109
|
+
lines.push(` ${artifact.sequence}. ${artifact.schema ?? 'unversioned JSON'}${artifact.version ? ` v${artifact.version}` : ''} · ${artifact.contentDigestSha256}`);
|
|
110
|
+
}
|
|
111
|
+
lines.push('', 'No source file paths or artefact contents are retained in this manifest.', '');
|
|
112
|
+
return lines.join('\n');
|
|
113
|
+
}
|
|
@@ -15,7 +15,7 @@ const RECIPES = Object.freeze({
|
|
|
15
15
|
steps: (domain) => Object.freeze([
|
|
16
16
|
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.'),
|
|
17
17
|
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.'),
|
|
18
|
-
step('verify', 'Verify the exported
|
|
18
|
+
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.'),
|
|
19
19
|
]),
|
|
20
20
|
}),
|
|
21
21
|
'lookalike-review': Object.freeze({
|
|
@@ -89,7 +89,7 @@ export function buildInvestigationPlan(recipeId, subjectValue, generatedAtValue
|
|
|
89
89
|
limitations: Object.freeze([
|
|
90
90
|
'This document is a fixed plan. It does not execute commands, expand placeholders, make requests, read files, change cases, or submit reports.',
|
|
91
91
|
'Network steps require deliberate execution and disclose the selected target to the sources described by that command.',
|
|
92
|
-
'Analyst-selection steps require reviewed local
|
|
92
|
+
'Analyst-selection steps require reviewed local artefacts; placeholders are never interpreted as file paths by this planner.',
|
|
93
93
|
]),
|
|
94
94
|
});
|
|
95
95
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { buildInvestigationPlan, } from "./investigation-plan.mjs";
|
|
3
|
+
import { CliUsageError } from "./errors.mjs";
|
|
4
|
+
export const CLI_INVESTIGATION_RUN_SCHEMA = 'whoisleuth.cli.investigation-run';
|
|
5
|
+
export const CLI_INVESTIGATION_RUN_VERSION = 1;
|
|
6
|
+
export const MAX_INVESTIGATION_RUN_BYTES = 24 * 1024 * 1024;
|
|
7
|
+
function boundedResult(value) {
|
|
8
|
+
if (Buffer.byteLength(value, 'utf8') > MAX_INVESTIGATION_RUN_BYTES) {
|
|
9
|
+
throw new CliUsageError(`Investigation step output is limited to ${MAX_INVESTIGATION_RUN_BYTES} bytes.`);
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(value);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '').slice(0, MAX_INVESTIGATION_RUN_BYTES);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function parseResumeState(input, recipe, subject) {
|
|
19
|
+
if (!input)
|
|
20
|
+
return [];
|
|
21
|
+
if (Buffer.byteLength(input, 'utf8') > MAX_INVESTIGATION_RUN_BYTES)
|
|
22
|
+
throw new CliUsageError('Investigation resume state exceeds the 24 MiB limit.');
|
|
23
|
+
let parsed;
|
|
24
|
+
try {
|
|
25
|
+
parsed = JSON.parse(input.replace(/^\uFEFF/u, ''));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw new CliUsageError('Investigation resume state must be valid JSON.');
|
|
29
|
+
}
|
|
30
|
+
const root = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
31
|
+
if (root.schema !== CLI_INVESTIGATION_RUN_SCHEMA || root.version !== CLI_INVESTIGATION_RUN_VERSION || root.recipe !== recipe || root.subject !== subject || !Array.isArray(root.completedSteps)) {
|
|
32
|
+
throw new CliUsageError('Investigation resume state must match this versioned recipe and subject.');
|
|
33
|
+
}
|
|
34
|
+
return root.completedSteps.slice(0, 16).flatMap((value) => {
|
|
35
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
36
|
+
return [];
|
|
37
|
+
const item = value;
|
|
38
|
+
if (typeof item.id !== 'string' || typeof item.command !== 'string' || !Array.isArray(item.arguments) || !['offline', 'network'].includes(String(item.mode)) || !Number.isSafeInteger(item.exitCode))
|
|
39
|
+
return [];
|
|
40
|
+
return [Object.freeze({
|
|
41
|
+
id: item.id,
|
|
42
|
+
command: item.command,
|
|
43
|
+
arguments: Object.freeze(item.arguments.filter((entry) => typeof entry === 'string').slice(0, 32)),
|
|
44
|
+
mode: item.mode,
|
|
45
|
+
exitCode: Number(item.exitCode),
|
|
46
|
+
result: item.result,
|
|
47
|
+
})];
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
export async function runInvestigationRecipe(recipe, subjectValue, options) {
|
|
51
|
+
const plan = buildInvestigationPlan(recipe, subjectValue, options.generatedAt);
|
|
52
|
+
const prior = parseResumeState(options.resumeInput, recipe, plan.subject);
|
|
53
|
+
const allowedStepIds = new Set(plan.steps.map((step) => step.id));
|
|
54
|
+
if (prior.some((step) => !allowedStepIds.has(step.id)))
|
|
55
|
+
throw new CliUsageError('Investigation resume state contains a step outside the fixed recipe.');
|
|
56
|
+
const completed = [...prior];
|
|
57
|
+
let state = 'complete';
|
|
58
|
+
let currentStep = null;
|
|
59
|
+
for (const step of plan.steps) {
|
|
60
|
+
if (completed.some((item) => item.id === step.id))
|
|
61
|
+
continue;
|
|
62
|
+
currentStep = step;
|
|
63
|
+
if (step.arguments.some((argument) => /^<[^>]+>$/u.test(argument))) {
|
|
64
|
+
state = 'awaiting_analyst_selection';
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
if (step.mode === 'network' && !options.approveNetwork) {
|
|
68
|
+
state = 'awaiting_network_approval';
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
const result = await options.execute(step.command, step.arguments);
|
|
72
|
+
completed.push(Object.freeze({
|
|
73
|
+
id: step.id,
|
|
74
|
+
command: step.command,
|
|
75
|
+
arguments: step.arguments,
|
|
76
|
+
mode: step.mode,
|
|
77
|
+
exitCode: result.exitCode,
|
|
78
|
+
result: boundedResult(result.stdout),
|
|
79
|
+
}));
|
|
80
|
+
if (result.exitCode !== 0 && result.exitCode !== 2) {
|
|
81
|
+
state = 'step_failed';
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
currentStep = null;
|
|
85
|
+
}
|
|
86
|
+
return Object.freeze({
|
|
87
|
+
schema: CLI_INVESTIGATION_RUN_SCHEMA,
|
|
88
|
+
version: CLI_INVESTIGATION_RUN_VERSION,
|
|
89
|
+
generatedAt: options.generatedAt,
|
|
90
|
+
recipe,
|
|
91
|
+
subject: plan.subject,
|
|
92
|
+
state,
|
|
93
|
+
networkApprovedForThisRun: options.approveNetwork,
|
|
94
|
+
completedSteps: Object.freeze(completed),
|
|
95
|
+
currentStep,
|
|
96
|
+
limitations: Object.freeze([
|
|
97
|
+
'Only commands and arguments from the installed fixed recipe can execute; no shell, script, arbitrary command, or enforcement action is accepted.',
|
|
98
|
+
'Network steps run only with --approve-network for the current invocation. Analyst-selection placeholders always pause and are never interpreted as paths or values.',
|
|
99
|
+
'A resume file is a local checkpoint, not proof that prior evidence remains current or that a human reviewed each stored result.',
|
|
100
|
+
]),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
export function formatInvestigationRun(document) {
|
|
104
|
+
return [
|
|
105
|
+
`Investigation run: ${document.recipe}`,
|
|
106
|
+
`Subject ${document.subject}`,
|
|
107
|
+
`State ${document.state.replaceAll('_', ' ')}`,
|
|
108
|
+
`Completed ${document.completedSteps.length}`,
|
|
109
|
+
...(document.currentStep ? [`Next ${document.currentStep.label}`, `Approval ${document.currentStep.approval.replaceAll('_', ' ')}`] : []),
|
|
110
|
+
'',
|
|
111
|
+
].join('\n');
|
|
112
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
import { open } from 'maxmind';
|
|
4
|
+
import { formatIpPrefix, parseIpPrefix } from "../lib/ip-prefix.mjs";
|
|
5
|
+
import { CliUsageError } from "./errors.mjs";
|
|
6
|
+
export const LOCAL_MMDB_QUERY_SCHEMA = 'whoisleuth.local-mmdb-query';
|
|
7
|
+
export const MAX_LOCAL_MMDB_BYTES = 512 * 1024 * 1024;
|
|
8
|
+
function record(value) {
|
|
9
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
10
|
+
}
|
|
11
|
+
function boundedText(value, maximum) {
|
|
12
|
+
return typeof value === 'string' && value.length <= maximum && !/[\u0000-\u001f\u007f]/u.test(value)
|
|
13
|
+
? value.replace(/\s+/gu, ' ').trim() || null
|
|
14
|
+
: null;
|
|
15
|
+
}
|
|
16
|
+
function names(value) {
|
|
17
|
+
const source = record(value);
|
|
18
|
+
return boundedText(source.en, 120);
|
|
19
|
+
}
|
|
20
|
+
export async function reviewLocalMmdb(input, databasePath) {
|
|
21
|
+
const address = boundedText(input.address, 64)?.toLowerCase() ?? '';
|
|
22
|
+
const sourceLabel = boundedText(input.sourceLabel, 120);
|
|
23
|
+
const databaseVersion = boundedText(input.databaseVersion, 80);
|
|
24
|
+
const license = boundedText(input.license, 240);
|
|
25
|
+
if (!isIP(address))
|
|
26
|
+
throw new CliUsageError('Local MMDB review requires one valid IP address.');
|
|
27
|
+
if (!sourceLabel || !databaseVersion || !license) {
|
|
28
|
+
throw new CliUsageError('Local MMDB review requires bounded sourceLabel, databaseVersion, and licence metadata.');
|
|
29
|
+
}
|
|
30
|
+
let info;
|
|
31
|
+
try {
|
|
32
|
+
info = await stat(databasePath);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new CliUsageError('The supplied MMDB file could not be read.');
|
|
36
|
+
}
|
|
37
|
+
if (!info.isFile() || info.size <= 0 || info.size > MAX_LOCAL_MMDB_BYTES) {
|
|
38
|
+
throw new CliUsageError(`The supplied MMDB must be a file no larger than ${MAX_LOCAL_MMDB_BYTES} bytes.`);
|
|
39
|
+
}
|
|
40
|
+
let reader;
|
|
41
|
+
try {
|
|
42
|
+
reader = await open(databasePath, { cache: { max: 1_000 } });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new CliUsageError('The supplied file is not a readable MaxMind DB database.');
|
|
46
|
+
}
|
|
47
|
+
const [rawMatch, prefixLength] = reader.getWithPrefixLength(address);
|
|
48
|
+
const match = record(rawMatch);
|
|
49
|
+
const traits = record(match.traits);
|
|
50
|
+
const country = record(match.country);
|
|
51
|
+
const registeredCountry = record(match.registered_country);
|
|
52
|
+
const subdivisions = Array.isArray(match.subdivisions) ? match.subdivisions : [];
|
|
53
|
+
const subdivision = record(subdivisions[0]);
|
|
54
|
+
const city = record(match.city);
|
|
55
|
+
const asnValue = match.autonomous_system_number ?? traits.autonomous_system_number;
|
|
56
|
+
const asn = typeof asnValue === 'number' && Number.isSafeInteger(asnValue) && asnValue >= 0 ? asnValue : null;
|
|
57
|
+
const prefix = parseIpPrefix(`${address}/${prefixLength}`);
|
|
58
|
+
return Object.freeze({
|
|
59
|
+
state: rawMatch ? 'matched' : 'not_found',
|
|
60
|
+
address,
|
|
61
|
+
match: rawMatch ? Object.freeze({
|
|
62
|
+
network: prefix ? formatIpPrefix(prefix) : null,
|
|
63
|
+
countryCode: boundedText(country.iso_code ?? registeredCountry.iso_code, 2)?.toUpperCase() ?? null,
|
|
64
|
+
region: names(subdivision.names) ?? boundedText(subdivision.iso_code, 120),
|
|
65
|
+
city: names(city.names),
|
|
66
|
+
asn,
|
|
67
|
+
asName: boundedText(match.autonomous_system_organization ?? traits.autonomous_system_organization, 240),
|
|
68
|
+
}) : null,
|
|
69
|
+
source: Object.freeze({ label: sourceLabel, version: databaseVersion, license }),
|
|
70
|
+
limitations: Object.freeze([
|
|
71
|
+
'This CLI-only result comes from an analyst-supplied local MMDB file. WHOISleuth does not bundle, download, update, license, or transmit the database.',
|
|
72
|
+
'Geolocation and network attribution are provider estimates and may be stale, coarse, shared, or incorrect.',
|
|
73
|
+
'A location or ASN association does not establish the operator, owner, user, intent, safety, or maliciousness of an address.',
|
|
74
|
+
]),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
const LOOKALIKE_CALIBRATION_INPUT_SCHEMA = 'whoisleuth.lookalike-calibration-input';
|
|
3
|
+
const LOOKALIKE_CALIBRATION_SCHEMA = 'whoisleuth.lookalike-calibration';
|
|
4
|
+
const LOOKALIKE_CALIBRATION_VERSION = 1;
|
|
5
|
+
const MAX_LOOKALIKE_CALIBRATION_BYTES = 2 * 1024 * 1024;
|
|
6
|
+
const MAX_LOOKALIKE_CALIBRATION_RECORDS = 5_000;
|
|
7
|
+
const MIN_LOOKALIKE_CALIBRATION_SAMPLE = 20;
|
|
8
|
+
const DISPOSITIONS = ['suspicious', 'confirmed_abuse', 'false_positive', 'expected', 'closed_no_action'];
|
|
9
|
+
function record(value) {
|
|
10
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
11
|
+
}
|
|
12
|
+
function token(value, field) {
|
|
13
|
+
if (typeof value !== 'string')
|
|
14
|
+
throw new TypeError(`${field} must be text.`);
|
|
15
|
+
const normalized = value.trim().toLowerCase();
|
|
16
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,79}$/u.test(normalized))
|
|
17
|
+
throw new TypeError(`${field} must be a bounded identifier.`);
|
|
18
|
+
return normalized;
|
|
19
|
+
}
|
|
20
|
+
function ratio(value, total) {
|
|
21
|
+
return total ? Number((value / total).toFixed(4)) : 0;
|
|
22
|
+
}
|
|
23
|
+
function emptyCounts() {
|
|
24
|
+
return { suspicious: 0, confirmed_abuse: 0, false_positive: 0, expected: 0, closed_no_action: 0 };
|
|
25
|
+
}
|
|
26
|
+
function buildLookalikeCalibration(raw, generatedAt = new Date().toISOString()) {
|
|
27
|
+
if (Buffer.byteLength(raw, 'utf8') > MAX_LOOKALIKE_CALIBRATION_BYTES) {
|
|
28
|
+
throw new TypeError(`Lookalike calibration input is limited to ${MAX_LOOKALIKE_CALIBRATION_BYTES} bytes.`);
|
|
29
|
+
}
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = JSON.parse(raw);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new TypeError('Lookalike calibration input must be valid JSON.');
|
|
36
|
+
}
|
|
37
|
+
const input = record(parsed);
|
|
38
|
+
if (input.schema !== LOOKALIKE_CALIBRATION_INPUT_SCHEMA || input.version !== LOOKALIKE_CALIBRATION_VERSION) {
|
|
39
|
+
throw new TypeError(`Lookalike calibration input must use ${LOOKALIKE_CALIBRATION_INPUT_SCHEMA} version ${LOOKALIKE_CALIBRATION_VERSION}.`);
|
|
40
|
+
}
|
|
41
|
+
if (!Array.isArray(input.records) || input.records.length < 1 || input.records.length > MAX_LOOKALIKE_CALIBRATION_RECORDS) {
|
|
42
|
+
throw new TypeError(`Lookalike calibration input must contain from 1 to ${MAX_LOOKALIKE_CALIBRATION_RECORDS} reviewed records.`);
|
|
43
|
+
}
|
|
44
|
+
const ids = new Set();
|
|
45
|
+
const familyCounts = new Map();
|
|
46
|
+
const totalCounts = emptyCounts();
|
|
47
|
+
for (let index = 0; index < input.records.length; index++) {
|
|
48
|
+
const item = record(input.records[index]);
|
|
49
|
+
const id = token(item.id, `records[${index}].id`);
|
|
50
|
+
if (ids.has(id))
|
|
51
|
+
throw new TypeError(`Lookalike calibration record id "${id}" is duplicated.`);
|
|
52
|
+
ids.add(id);
|
|
53
|
+
if (typeof item.disposition !== 'string' || !DISPOSITIONS.includes(item.disposition)) {
|
|
54
|
+
throw new TypeError(`records[${index}].disposition is not a supported reviewed disposition.`);
|
|
55
|
+
}
|
|
56
|
+
const disposition = item.disposition;
|
|
57
|
+
if (!Array.isArray(item.mutationFamilies) || item.mutationFamilies.length < 1 || item.mutationFamilies.length > 12) {
|
|
58
|
+
throw new TypeError(`records[${index}].mutationFamilies must contain from 1 to 12 identifiers.`);
|
|
59
|
+
}
|
|
60
|
+
const families = [...new Set(item.mutationFamilies.map((value) => token(value, `records[${index}].mutationFamilies`)))];
|
|
61
|
+
totalCounts[disposition] += 1;
|
|
62
|
+
for (const family of families) {
|
|
63
|
+
const counts = familyCounts.get(family) || emptyCounts();
|
|
64
|
+
counts[disposition] += 1;
|
|
65
|
+
familyCounts.set(family, counts);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const families = [...familyCounts.entries()].map(([id, dispositions]) => {
|
|
69
|
+
const reviewedCandidates = Object.values(dispositions).reduce((sum, count) => sum + count, 0);
|
|
70
|
+
return {
|
|
71
|
+
id,
|
|
72
|
+
reviewedCandidates,
|
|
73
|
+
sampleState: reviewedCandidates >= MIN_LOOKALIKE_CALIBRATION_SAMPLE ? 'reviewed' : 'insufficient',
|
|
74
|
+
dispositions,
|
|
75
|
+
reviewLeadRate: ratio(dispositions.suspicious + dispositions.confirmed_abuse, reviewedCandidates),
|
|
76
|
+
falsePositiveRate: ratio(dispositions.false_positive, reviewedCandidates),
|
|
77
|
+
};
|
|
78
|
+
}).sort((left, right) => right.reviewedCandidates - left.reviewedCandidates || left.id.localeCompare(right.id));
|
|
79
|
+
const reviewedCandidates = input.records.length;
|
|
80
|
+
return {
|
|
81
|
+
schema: LOOKALIKE_CALIBRATION_SCHEMA,
|
|
82
|
+
version: LOOKALIKE_CALIBRATION_VERSION,
|
|
83
|
+
generatedAt,
|
|
84
|
+
summary: {
|
|
85
|
+
reviewedCandidates,
|
|
86
|
+
mutationFamilies: families.length,
|
|
87
|
+
sampleState: reviewedCandidates >= MIN_LOOKALIKE_CALIBRATION_SAMPLE ? 'reviewed' : 'insufficient',
|
|
88
|
+
reviewLeadRate: ratio(totalCounts.suspicious + totalCounts.confirmed_abuse, reviewedCandidates),
|
|
89
|
+
falsePositiveRate: ratio(totalCounts.false_positive, reviewedCandidates),
|
|
90
|
+
expectedRate: ratio(totalCounts.expected, reviewedCandidates),
|
|
91
|
+
},
|
|
92
|
+
families,
|
|
93
|
+
privacy: { candidateIdsRetained: 0, domainsRetained: 0, notesRetained: 0 },
|
|
94
|
+
limitations: [
|
|
95
|
+
'This offline report summarises analyst dispositions for the supplied reviewed sample and never changes mutation generation, ordering, Risk, or filtering automatically.',
|
|
96
|
+
'Suspicious and confirmed-abuse dispositions are counted as review leads, not proof of maliciousness; expected and false-positive dispositions are not proof of safety.',
|
|
97
|
+
`A family remains an insufficient sample below ${MIN_LOOKALIKE_CALIBRATION_SAMPLE} reviewed candidates, and even larger local samples may not generalize across brands, languages, registries, or time.`,
|
|
98
|
+
'Candidate identifiers, domains, notes, and evidence are intentionally omitted from output. Retain the reviewed source dataset under an appropriate local privacy policy.',
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function formatLookalikeCalibration(report) {
|
|
103
|
+
const output = [
|
|
104
|
+
'Lookalike review-yield calibration',
|
|
105
|
+
`Reviewed candidates ${report.summary.reviewedCandidates}`,
|
|
106
|
+
`Mutation families ${report.summary.mutationFamilies}`,
|
|
107
|
+
`Sample state ${report.summary.sampleState}`,
|
|
108
|
+
`Review-lead rate ${(report.summary.reviewLeadRate * 100).toFixed(1)}%`,
|
|
109
|
+
`False-positive rate ${(report.summary.falsePositiveRate * 100).toFixed(1)}%`,
|
|
110
|
+
'',
|
|
111
|
+
];
|
|
112
|
+
for (const family of report.families) {
|
|
113
|
+
output.push(`${family.id} [${family.sampleState}]`);
|
|
114
|
+
output.push(` ${family.reviewedCandidates} reviewed · ${(family.reviewLeadRate * 100).toFixed(1)}% review leads · ${(family.falsePositiveRate * 100).toFixed(1)}% false positives`);
|
|
115
|
+
}
|
|
116
|
+
output.push('', 'Limitations:');
|
|
117
|
+
for (const limitation of report.limitations)
|
|
118
|
+
output.push(` - ${limitation}`);
|
|
119
|
+
return `${output.join('\n')}\n`;
|
|
120
|
+
}
|
|
121
|
+
export { LOOKALIKE_CALIBRATION_INPUT_SCHEMA, LOOKALIKE_CALIBRATION_SCHEMA, LOOKALIKE_CALIBRATION_VERSION, MAX_LOOKALIKE_CALIBRATION_BYTES, MAX_LOOKALIKE_CALIBRATION_RECORDS, MIN_LOOKALIKE_CALIBRATION_SAMPLE, buildLookalikeCalibration, formatLookalikeCalibration, };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { parseSavedLookupDocument } from "./saved-lookup.mjs";
|
|
2
|
+
export const CLI_LOOKUP_BRIEF_SCHEMA = 'whoisleuth.cli.lookup-brief';
|
|
3
|
+
export const CLI_LOOKUP_BRIEF_VERSION = 2;
|
|
4
|
+
function record(value) {
|
|
5
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
6
|
+
}
|
|
7
|
+
function text(value, maximum = 300) {
|
|
8
|
+
if (typeof value !== 'string')
|
|
9
|
+
return null;
|
|
10
|
+
const normalized = value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum);
|
|
11
|
+
return normalized || null;
|
|
12
|
+
}
|
|
13
|
+
function sourceState(value) {
|
|
14
|
+
return text(value, 40)?.toLowerCase() ?? 'unavailable';
|
|
15
|
+
}
|
|
16
|
+
export function buildCliLookupBrief(input, generatedAt = new Date().toISOString()) {
|
|
17
|
+
const document = parseSavedLookupDocument(input, { label: 'Lookup brief input' });
|
|
18
|
+
const diagnostics = record(document.diagnostics);
|
|
19
|
+
const availability = record(document.availability);
|
|
20
|
+
const rdap = record(record(document.rdap).parsed);
|
|
21
|
+
const whois = record(record(document.whois).parsed);
|
|
22
|
+
const preferred = Object.keys(rdap).length ? rdap : whois;
|
|
23
|
+
const sources = ['rdap', 'whois', 'availability', 'dns', 'http', 'tls'].map((id) => {
|
|
24
|
+
const nested = id === 'dns' || id === 'http' || id === 'tls' ? record(availability[id]) : record(diagnostics[id]);
|
|
25
|
+
return Object.freeze({ id, state: sourceState(nested.status ?? nested.state), observedAt: text(nested.observedAt ?? nested.fetchedAt ?? nested.queriedAt, 64) });
|
|
26
|
+
});
|
|
27
|
+
const facts = [
|
|
28
|
+
['Registration state', availability.state, 'Authority-aware availability'],
|
|
29
|
+
['Registrar', record(preferred.registrar).name ?? preferred.registrar, Object.keys(rdap).length ? 'Registry RDAP' : 'WHOIS'],
|
|
30
|
+
['Created', record(preferred.lifecycle).createdIso ?? preferred.createdDateIso, Object.keys(rdap).length ? 'Registry RDAP' : 'WHOIS'],
|
|
31
|
+
['Expires', record(preferred.lifecycle).expiryIso ?? preferred.expiryDateIso, Object.keys(rdap).length ? 'Registry RDAP' : 'WHOIS'],
|
|
32
|
+
['Website activity', availability.activityStatus, 'HTTP'],
|
|
33
|
+
['Page title', availability.pageTitle, 'Static page identity'],
|
|
34
|
+
].flatMap(([label, value, source]) => {
|
|
35
|
+
const normalized = text(value);
|
|
36
|
+
return normalized ? [Object.freeze({ label: String(label), value: normalized, source: String(source) })] : [];
|
|
37
|
+
});
|
|
38
|
+
const incomplete = sources.filter((item) => !['complete', 'success'].includes(item.state));
|
|
39
|
+
const analysis = record(document.analysis);
|
|
40
|
+
const contradictions = [analysis.registryComparison, document.registryComparison, document.registrarPublicationComparison]
|
|
41
|
+
.flatMap((value) => Array.isArray(record(value).fields) ? record(value).fields : [])
|
|
42
|
+
.flatMap((value) => {
|
|
43
|
+
const item = record(value);
|
|
44
|
+
return item.status === 'conflict' ? [text(item.label, 120) ?? 'Registration publication conflict'] : [];
|
|
45
|
+
}).slice(0, 16);
|
|
46
|
+
const actionPlan = [
|
|
47
|
+
...(contradictions.length ? [{
|
|
48
|
+
id: 'registration-publication-review',
|
|
49
|
+
action: 'Review the separately attributed registration publications before relying on the conflicting fields.',
|
|
50
|
+
reason: `${contradictions.length} conflicting publication field${contradictions.length === 1 ? '' : 's'} were retained.`,
|
|
51
|
+
expectedOutcome: 'Establish which observation is current, authoritative, or still unresolved.',
|
|
52
|
+
evidence: 'registry publication comparison',
|
|
53
|
+
}] : []),
|
|
54
|
+
...(incomplete.length ? [{
|
|
55
|
+
id: 'source-state-review',
|
|
56
|
+
action: `Refresh or explain the ${incomplete.map((item) => item.id).join(', ')} source state before treating missing values as meaningful.`,
|
|
57
|
+
reason: 'At least one source was not complete in the saved observation.',
|
|
58
|
+
expectedOutcome: 'Determine whether each limitation is transient, persistent, or expected for that source.',
|
|
59
|
+
evidence: 'source health',
|
|
60
|
+
}] : []),
|
|
61
|
+
...(document.mode === 'fast' ? [{
|
|
62
|
+
id: 'collection-depth-review',
|
|
63
|
+
action: 'Run a deliberate Deep lookup if DNS, HTTP, TLS, page, or network evidence is required.',
|
|
64
|
+
reason: 'The saved observation used the intentionally narrower Fast contract.',
|
|
65
|
+
expectedOutcome: 'Collect the explicitly selected additional source classes without implying that unavailable evidence is absent.',
|
|
66
|
+
evidence: 'collection mode',
|
|
67
|
+
}] : []),
|
|
68
|
+
{
|
|
69
|
+
id: 'case-evidence-review',
|
|
70
|
+
action: 'Pin only the facts needed for the decision and keep analyst hypotheses separate from observations.',
|
|
71
|
+
reason: 'The brief organises one observation but does not create an analyst decision.',
|
|
72
|
+
expectedOutcome: 'Preserve a reviewable boundary between observed facts, hypotheses, unknowns, and decisions.',
|
|
73
|
+
evidence: 'case workflow',
|
|
74
|
+
},
|
|
75
|
+
].slice(0, 6).map((item) => Object.freeze(item));
|
|
76
|
+
const recommendedActions = actionPlan.map((item) => item.action);
|
|
77
|
+
return Object.freeze({
|
|
78
|
+
schema: CLI_LOOKUP_BRIEF_SCHEMA,
|
|
79
|
+
version: CLI_LOOKUP_BRIEF_VERSION,
|
|
80
|
+
generatedAt,
|
|
81
|
+
target: document.registrableDomain,
|
|
82
|
+
observedAt: document.generatedAt,
|
|
83
|
+
mode: document.mode,
|
|
84
|
+
facts: Object.freeze(facts),
|
|
85
|
+
sourceHealth: Object.freeze(sources),
|
|
86
|
+
contradictions: Object.freeze(contradictions),
|
|
87
|
+
unknowns: Object.freeze(incomplete.map((item) => `${item.id}: ${item.state}`)),
|
|
88
|
+
actionPlan: Object.freeze(actionPlan),
|
|
89
|
+
recommendedActions: Object.freeze(recommendedActions.slice(0, 6)),
|
|
90
|
+
limitations: Object.freeze([
|
|
91
|
+
'This brief is derived from one saved Lookup and makes no request.',
|
|
92
|
+
'It organises bounded facts and uncertainty but does not create an analyst assertion, decide maliciousness, or establish current state.',
|
|
93
|
+
'Raw registry, provider, HTTP and page payloads are excluded.',
|
|
94
|
+
]),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
export function formatCliLookupBrief(document) {
|
|
98
|
+
return [
|
|
99
|
+
`Lookup brief: ${document.target}`,
|
|
100
|
+
`Observed ${document.observedAt}`,
|
|
101
|
+
`Mode ${document.mode}`,
|
|
102
|
+
'',
|
|
103
|
+
'Verified facts',
|
|
104
|
+
...(document.facts.length ? document.facts.map((item) => ` ${item.label}: ${item.value} (${item.source})`) : [' No bounded fact was available.']),
|
|
105
|
+
'',
|
|
106
|
+
'Unknown or incomplete',
|
|
107
|
+
...(document.unknowns.length ? document.unknowns.map((item) => ` ${item}`) : [' No incomplete source state was identified.']),
|
|
108
|
+
'',
|
|
109
|
+
'Recommended manual actions',
|
|
110
|
+
...document.actionPlan.flatMap((item, index) => [
|
|
111
|
+
` ${index + 1}. ${item.action}`,
|
|
112
|
+
` Expected outcome: ${item.expectedOutcome}`,
|
|
113
|
+
]),
|
|
114
|
+
'',
|
|
115
|
+
].join('\n');
|
|
116
|
+
}
|
|
@@ -11,6 +11,8 @@ import { formatTerminalLookup } from "./formatters/terminal.mjs";
|
|
|
11
11
|
import { buildCliLookupPlan, formatCliLookupPlan } from "./lookup-plan.mjs";
|
|
12
12
|
import { createCliProgressEvents } from "./progress-events.mjs";
|
|
13
13
|
import { lookupStrictExitFindings } from "./strict-exit.mjs";
|
|
14
|
+
import { evaluateCliFailPolicies, formatFailPolicyNotice } from "./fail-policy.mjs";
|
|
15
|
+
import { formatCliJunit } from "./ci-report.mjs";
|
|
14
16
|
async function runLookupCommand(args, dependencies, context) {
|
|
15
17
|
const eventProgress = createCliProgressEvents(context.stderr, {
|
|
16
18
|
command: 'lookup',
|
|
@@ -68,10 +70,15 @@ async function runLookupCommand(args, dependencies, context) {
|
|
|
68
70
|
context.endProgress();
|
|
69
71
|
}
|
|
70
72
|
const now = context.now();
|
|
71
|
-
const document = buildCliLookupDocument(query, classified, result, now, args.deep ? 'deep' : 'fast'
|
|
73
|
+
const document = buildCliLookupDocument(query, classified, result, now, args.deep ? 'deep' : 'fast', {
|
|
74
|
+
...(args.observerLabel ? { observerLabel: args.observerLabel } : {}),
|
|
75
|
+
...(args.vantageLabel ? { vantageLabel: args.vantageLabel } : {}),
|
|
76
|
+
});
|
|
72
77
|
if (!args.quiet) {
|
|
73
78
|
if (args.output === 'json')
|
|
74
79
|
context.writeStdout(formatJsonDocument(document));
|
|
80
|
+
else if (args.output === 'junit')
|
|
81
|
+
context.writeStdout(formatCliJunit(document));
|
|
75
82
|
else if (args.output === 'markdown' || args.output === 'html') {
|
|
76
83
|
const loadEvidence = dependencies.loadEvidenceExport || (() => import("../lib/evidence-export.mjs"));
|
|
77
84
|
const evidenceModule = await loadEvidence();
|
|
@@ -85,10 +92,13 @@ async function runLookupCommand(args, dependencies, context) {
|
|
|
85
92
|
}
|
|
86
93
|
}
|
|
87
94
|
const strictFindings = args.strictExit ? lookupStrictExitFindings(document) : [];
|
|
88
|
-
const
|
|
95
|
+
const policyFindings = evaluateCliFailPolicies(document, args.failOn || []);
|
|
96
|
+
const exitCode = strictFindings.length || policyFindings.length ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
|
|
89
97
|
if (strictFindings.length && !args.events) {
|
|
90
98
|
context.writeStderr(`Strict exit: ${strictFindings.length} requested source state${strictFindings.length === 1 ? '' : 's'} were incomplete.\n`);
|
|
91
99
|
}
|
|
100
|
+
if (policyFindings.length && !args.events)
|
|
101
|
+
context.writeStderr(formatFailPolicyNotice(policyFindings));
|
|
92
102
|
eventProgress.emit({ event: 'completed', exitCode });
|
|
93
103
|
return exitCode;
|
|
94
104
|
}
|