@slicedearth/whoisleuth-cli 1.39.1 → 1.45.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 +23 -4
- package/bin/whoisleuth.mjs +31 -9
- package/cli/archive-inspect.mjs +37 -2
- package/cli/arguments.mjs +361 -256
- package/cli/artifact-verify.mjs +35 -1
- package/cli/bulk-checkpoint.mjs +72 -39
- package/cli/bulk-command-runner.mjs +34 -4
- package/cli/bulk.mjs +18 -7
- 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/completion.mjs +67 -13
- package/cli/config-profile.mjs +183 -0
- package/cli/ct-event-intake.mjs +159 -0
- package/cli/discovery-observation-snapshot.mjs +8 -14
- package/cli/discovery-scan-command-runner.mjs +29 -2
- package/cli/discovery-snapshot.mjs +8 -15
- package/cli/domain-control-monitor.mjs +104 -0
- package/cli/domain-control-observations.mjs +235 -0
- package/cli/errors.mjs +7 -1
- package/cli/evidence-command-arguments.mjs +11 -0
- package/cli/evidence-command-runner.mjs +1 -0
- package/cli/external-observation-mapping.mjs +162 -0
- package/cli/fail-policy.mjs +64 -0
- package/cli/formatters/html.mjs +1 -1
- package/cli/formatters/json.mjs +12 -4
- package/cli/formatters/terminal.mjs +1 -1
- package/cli/http.mjs +2 -1
- package/cli/investigation-manifest.mjs +113 -0
- package/cli/investigation-run.mjs +112 -0
- package/cli/lookup-brief.mjs +116 -0
- package/cli/lookup-command-runner.mjs +8 -1
- package/cli/lookup-diff.mjs +17 -19
- package/cli/lookup-reconcile.mjs +1 -1
- package/cli/mail-review.mjs +5 -5
- package/cli/manual.mjs +1 -1
- package/cli/network-command-runner.mjs +4 -1
- package/cli/offline-evidence-review.mjs +105 -8
- package/cli/open-asset-model-bridge.mjs +169 -0
- package/cli/registry-cohort.mjs +121 -0
- package/cli/registry-doctor.mjs +106 -11
- package/cli/registry-fixture-scaffold.mjs +74 -0
- package/cli/runner.mjs +388 -315
- package/docs/cli-reference.md +266 -21
- package/docs/cli.md +43 -7
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +2 -2
- package/frontend/src/lib/analysis/brand-profile-model.js +50 -6
- package/frontend/src/lib/analysis/case-evidence-model.js +1 -1
- package/frontend/src/lib/analysis/case-record-core.js +1 -1
- package/frontend/src/lib/analysis/case-record-operations.js +1 -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/detection-rule-model.js +36 -0
- package/frontend/src/lib/analysis/external-findings-import.js +320 -0
- package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
- package/frontend/src/lib/analysis/lookup-decision-support.js +9 -2
- package/frontend/src/lib/analysis/lookup-presentation.js +14 -30
- package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
- package/frontend/src/lib/analysis/scoring.js +1 -1
- package/frontend/src/lib/analysis/utils.js +1 -1
- package/frontend/src/lib/analysis/visualization-models.js +2 -3
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
- package/frontend/src/lib/analysis/workspace-archive.js +5 -7
- package/lib/abusech-auth.mjs +1 -1
- package/lib/availability.mjs +1 -1
- package/lib/bounded-contract-normalizers.mjs +33 -0
- package/lib/bounded-file.mjs +69 -0
- package/lib/bounded-json-response.mjs +157 -0
- package/lib/browser-library-profile.mjs +167 -38
- package/lib/dns-convergence-review.mjs +183 -0
- package/lib/dns-delegation-health.mjs +196 -3
- 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-portfolio-review.mjs +151 -0
- package/lib/evidence-export.mjs +1 -1
- package/lib/html-signals.mjs +5 -11
- package/lib/lookup-cache.mjs +5 -1
- package/lib/nameserver-preflight-review.mjs +159 -0
- package/lib/page-language-signals.mjs +124 -0
- package/lib/rdap-transport.mjs +6 -6
- package/lib/risk-scoring.mjs +42 -1
- package/lib/safe-fetch.mjs +37 -32
- package/lib/static-html-analysis.mjs +29 -1
- package/lib/threat-intelligence-runtime.mjs +1 -2
- package/lib/trust-store-comparison.mjs +146 -0
- package/lib/website-technology.mjs +22 -12
- package/lib/zone-intent-review.mjs +534 -0
- package/package.json +1 -1
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { domainToASCII } from 'node:url';
|
|
2
|
+
import { exactKeys, requireBoundedString, requireIsoTimestamp, requireRecord, } from "../lib/bounded-contract-normalizers.mjs";
|
|
3
|
+
import { EXTERNAL_FINDING_CATEGORIES, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, MAX_EXTERNAL_FINDING_DOMAINS, parseExternalFindingsDocument, } from "../frontend/src/lib/analysis/external-findings-import.js";
|
|
4
|
+
export const EXTERNAL_OBSERVATION_MAPPING_SCHEMA = 'whoisleuth.external-observation-mapping';
|
|
5
|
+
export const EXTERNAL_OBSERVATION_MAPPING_VERSION = 1;
|
|
6
|
+
export const MAX_EXTERNAL_OBSERVATION_MAPPING_BYTES = 4 * 1024 * 1024;
|
|
7
|
+
export const MAX_EXTERNAL_OBSERVATION_RECORDS = 500;
|
|
8
|
+
const ROOT_KEYS = new Set(['schema', 'version', 'source', 'profile', 'records']);
|
|
9
|
+
const SOURCE_KEYS = new Set(['name', 'reference', 'collectedAt']);
|
|
10
|
+
const PROFILE_KEYS = new Set([
|
|
11
|
+
'id', 'version', 'domainField', 'summaryField', 'observedAtField', 'referenceField',
|
|
12
|
+
'completenessField', 'category', 'evidenceClass', 'limitations',
|
|
13
|
+
]);
|
|
14
|
+
const PATH_RE = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*){0,7}$/u;
|
|
15
|
+
const FORBIDDEN_PATH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
16
|
+
const COMPLETENESS = new Set(['complete', 'inconclusive', 'partial', 'unknown']);
|
|
17
|
+
const CATEGORIES = new Set(EXTERNAL_FINDING_CATEGORIES);
|
|
18
|
+
function optionalText(value, label, maximum) {
|
|
19
|
+
if (value === undefined || value === null || value === '')
|
|
20
|
+
return null;
|
|
21
|
+
return requireBoundedString(value, label, maximum);
|
|
22
|
+
}
|
|
23
|
+
function path(value, label, optional = false) {
|
|
24
|
+
if (optional && (value === undefined || value === null || value === ''))
|
|
25
|
+
return null;
|
|
26
|
+
const candidate = requireBoundedString(value, label, 200);
|
|
27
|
+
if (!PATH_RE.test(candidate) || candidate.split('.').some((segment) => FORBIDDEN_PATH_SEGMENTS.has(segment))) {
|
|
28
|
+
throw new TypeError(`${label} must be a safe bounded dotted field path.`);
|
|
29
|
+
}
|
|
30
|
+
return candidate;
|
|
31
|
+
}
|
|
32
|
+
function readPath(value, fieldPath) {
|
|
33
|
+
let current = value;
|
|
34
|
+
for (const segment of fieldPath.split('.')) {
|
|
35
|
+
if (!current || typeof current !== 'object' || Array.isArray(current))
|
|
36
|
+
return undefined;
|
|
37
|
+
current = current[segment];
|
|
38
|
+
}
|
|
39
|
+
return current;
|
|
40
|
+
}
|
|
41
|
+
function domain(value, label) {
|
|
42
|
+
const supplied = requireBoundedString(value, label, 253).trim().toLowerCase().replace(/^\*\./u, '').replace(/\.$/u, '');
|
|
43
|
+
const ascii = domainToASCII(supplied).toLowerCase();
|
|
44
|
+
if (!ascii || ascii.length > 253 || !ascii.includes('.') || ascii.split('.').some((part) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(part))) {
|
|
45
|
+
throw new TypeError(`${label} must resolve to a valid DNS name.`);
|
|
46
|
+
}
|
|
47
|
+
return ascii;
|
|
48
|
+
}
|
|
49
|
+
function limitations(value) {
|
|
50
|
+
if (value === undefined)
|
|
51
|
+
return Object.freeze([]);
|
|
52
|
+
if (!Array.isArray(value) || value.length > 8)
|
|
53
|
+
throw new TypeError('profile.limitations must contain no more than 8 entries.');
|
|
54
|
+
return Object.freeze([...new Set(value.map((entry, index) => requireBoundedString(entry, `profile.limitations[${index}]`, 240)))]);
|
|
55
|
+
}
|
|
56
|
+
export function mapExternalObservations(inputRaw) {
|
|
57
|
+
const input = requireRecord(inputRaw, 'External observation mapping');
|
|
58
|
+
if (input.schema !== EXTERNAL_OBSERVATION_MAPPING_SCHEMA || input.version !== EXTERNAL_OBSERVATION_MAPPING_VERSION) {
|
|
59
|
+
throw new TypeError(`External observation mapping must use ${EXTERNAL_OBSERVATION_MAPPING_SCHEMA} version ${EXTERNAL_OBSERVATION_MAPPING_VERSION}.`);
|
|
60
|
+
}
|
|
61
|
+
exactKeys(input, ROOT_KEYS, 'External observation mapping');
|
|
62
|
+
const sourceInput = requireRecord(input.source, 'source');
|
|
63
|
+
exactKeys(sourceInput, SOURCE_KEYS, 'source');
|
|
64
|
+
const profileInput = requireRecord(input.profile, 'profile');
|
|
65
|
+
exactKeys(profileInput, PROFILE_KEYS, 'profile');
|
|
66
|
+
const profile = Object.freeze({
|
|
67
|
+
id: requireBoundedString(profileInput.id, 'profile.id', 80),
|
|
68
|
+
version: Number(profileInput.version),
|
|
69
|
+
domainField: path(profileInput.domainField, 'profile.domainField'),
|
|
70
|
+
summaryField: path(profileInput.summaryField, 'profile.summaryField'),
|
|
71
|
+
observedAtField: path(profileInput.observedAtField, 'profile.observedAtField'),
|
|
72
|
+
referenceField: path(profileInput.referenceField, 'profile.referenceField', true),
|
|
73
|
+
completenessField: path(profileInput.completenessField, 'profile.completenessField', true),
|
|
74
|
+
category: profileInput.category,
|
|
75
|
+
evidenceClass: profileInput.evidenceClass,
|
|
76
|
+
limitations: limitations(profileInput.limitations),
|
|
77
|
+
});
|
|
78
|
+
if (!Number.isSafeInteger(profile.version) || profile.version < 1 || profile.version > 1_000)
|
|
79
|
+
throw new TypeError('profile.version is invalid.');
|
|
80
|
+
if (typeof profile.category !== 'string' || !CATEGORIES.has(profile.category))
|
|
81
|
+
throw new TypeError('profile.category is unsupported.');
|
|
82
|
+
if (profile.evidenceClass !== 'deployment_observation' && profile.evidenceClass !== 'provider_report')
|
|
83
|
+
throw new TypeError('profile.evidenceClass is unsupported.');
|
|
84
|
+
if (!Array.isArray(input.records) || input.records.length < 1 || input.records.length > MAX_EXTERNAL_OBSERVATION_RECORDS) {
|
|
85
|
+
throw new TypeError(`records must contain between 1 and ${MAX_EXTERNAL_OBSERVATION_RECORDS} entries.`);
|
|
86
|
+
}
|
|
87
|
+
const source = Object.freeze({
|
|
88
|
+
name: requireBoundedString(sourceInput.name, 'source.name', 80),
|
|
89
|
+
reference: optionalText(sourceInput.reference, 'source.reference', 500),
|
|
90
|
+
collectedAt: sourceInput.collectedAt === null || sourceInput.collectedAt === undefined
|
|
91
|
+
? null
|
|
92
|
+
: requireIsoTimestamp(sourceInput.collectedAt, 'source.collectedAt'),
|
|
93
|
+
});
|
|
94
|
+
const candidates = input.records.map((record, index) => {
|
|
95
|
+
const domainValue = domain(readPath(record, profile.domainField), `records[${index}].${profile.domainField}`);
|
|
96
|
+
const summary = requireBoundedString(readPath(record, profile.summaryField), `records[${index}].${profile.summaryField}`, 900);
|
|
97
|
+
const observedAt = requireIsoTimestamp(readPath(record, profile.observedAtField), `records[${index}].${profile.observedAtField}`);
|
|
98
|
+
const completenessValue = profile.completenessField ? readPath(record, profile.completenessField) : 'unknown';
|
|
99
|
+
if (typeof completenessValue !== 'string' || !COMPLETENESS.has(completenessValue)) {
|
|
100
|
+
throw new TypeError(`records[${index}] completeness is unsupported.`);
|
|
101
|
+
}
|
|
102
|
+
return Object.freeze({
|
|
103
|
+
domain: domainValue,
|
|
104
|
+
category: profile.category,
|
|
105
|
+
evidenceClass: profile.evidenceClass,
|
|
106
|
+
summary,
|
|
107
|
+
observedAt,
|
|
108
|
+
completeness: completenessValue,
|
|
109
|
+
limitations: profile.limitations,
|
|
110
|
+
reference: profile.referenceField
|
|
111
|
+
? optionalText(readPath(record, profile.referenceField), `records[${index}].${profile.referenceField}`, 500)
|
|
112
|
+
: null,
|
|
113
|
+
structuredObservation: null,
|
|
114
|
+
});
|
|
115
|
+
}).sort((left, right) => left.domain.localeCompare(right.domain) || left.observedAt.localeCompare(right.observedAt) || left.summary.localeCompare(right.summary));
|
|
116
|
+
const selected = [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
const domainCounts = new Map();
|
|
119
|
+
const domains = new Set();
|
|
120
|
+
for (const finding of candidates) {
|
|
121
|
+
const key = `${finding.domain}\u0000${finding.category}\u0000${finding.summary}\u0000${finding.observedAt}`;
|
|
122
|
+
if (seen.has(key))
|
|
123
|
+
continue;
|
|
124
|
+
seen.add(key);
|
|
125
|
+
if (!domains.has(finding.domain) && domains.size >= MAX_EXTERNAL_FINDING_DOMAINS)
|
|
126
|
+
continue;
|
|
127
|
+
const count = domainCounts.get(finding.domain) ?? 0;
|
|
128
|
+
if (count >= MAX_EXTERNAL_FINDINGS_PER_DOMAIN)
|
|
129
|
+
continue;
|
|
130
|
+
if (selected.length >= MAX_EXTERNAL_FINDINGS)
|
|
131
|
+
continue;
|
|
132
|
+
domains.add(finding.domain);
|
|
133
|
+
domainCounts.set(finding.domain, count + 1);
|
|
134
|
+
selected.push(finding);
|
|
135
|
+
}
|
|
136
|
+
const truncated = selected.length < seen.size;
|
|
137
|
+
const document = {
|
|
138
|
+
schema: 'whoisleuth.external-findings',
|
|
139
|
+
schemaVersion: 3,
|
|
140
|
+
source,
|
|
141
|
+
findings: selected.map((finding) => ({
|
|
142
|
+
...finding,
|
|
143
|
+
limitations: [
|
|
144
|
+
...finding.limitations,
|
|
145
|
+
`Mapped locally with declarative profile ${profile.id} version ${profile.version}; the profile does not verify the upstream field semantics.`,
|
|
146
|
+
...(truncated ? ['The supplied records exceeded the browser import boundary; only the first deterministic bounded findings were retained.'] : []),
|
|
147
|
+
].slice(0, 8),
|
|
148
|
+
})),
|
|
149
|
+
};
|
|
150
|
+
return parseExternalFindingsDocument(document);
|
|
151
|
+
}
|
|
152
|
+
export function formatExternalObservationMapping(document) {
|
|
153
|
+
return [
|
|
154
|
+
'External observation mapping',
|
|
155
|
+
`Source ${document.source.name}`,
|
|
156
|
+
`Findings ${document.findings.length}`,
|
|
157
|
+
`Domains ${new Set(document.findings.map((finding) => finding.domain)).size}`,
|
|
158
|
+
'',
|
|
159
|
+
'The JSON form can be deliberately imported through the Console external-findings workflow.',
|
|
160
|
+
'',
|
|
161
|
+
].join('\n');
|
|
162
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { lookupStrictExitFindings } from "./strict-exit.mjs";
|
|
2
|
+
import { CliUsageError } from "./errors.mjs";
|
|
3
|
+
export const CLI_FAIL_POLICIES = ['source-failure', 'inconclusive', 'danger', 'material-drift'];
|
|
4
|
+
function record(value) {
|
|
5
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
6
|
+
}
|
|
7
|
+
export function parseCliFailPolicies(value) {
|
|
8
|
+
if (typeof value !== 'string' || !value || value.length > 100)
|
|
9
|
+
throw new CliUsageError('--fail-on requires a comma-separated policy list.');
|
|
10
|
+
const policies = [...new Set(value.split(',').map((item) => item.trim().toLowerCase()).filter(Boolean))];
|
|
11
|
+
if (!policies.length || policies.some((item) => !CLI_FAIL_POLICIES.includes(item))) {
|
|
12
|
+
throw new CliUsageError(`--fail-on supports: ${CLI_FAIL_POLICIES.join(', ')}.`);
|
|
13
|
+
}
|
|
14
|
+
return policies;
|
|
15
|
+
}
|
|
16
|
+
function availabilityStates(document) {
|
|
17
|
+
const direct = String(record(document.availability).state || '').toLowerCase();
|
|
18
|
+
const results = Array.isArray(document.results) ? document.results : [];
|
|
19
|
+
const resultStates = results.flatMap((value) => {
|
|
20
|
+
const item = record(value);
|
|
21
|
+
const state = item.availabilityState ?? record(item.availability).state;
|
|
22
|
+
return typeof state === 'string' ? [state.toLowerCase()] : [];
|
|
23
|
+
});
|
|
24
|
+
return [direct, ...resultStates].filter(Boolean);
|
|
25
|
+
}
|
|
26
|
+
export function evaluateCliFailPolicies(document, policies) {
|
|
27
|
+
const root = record(document);
|
|
28
|
+
const findings = [];
|
|
29
|
+
for (const policy of policies) {
|
|
30
|
+
if (policy === 'source-failure') {
|
|
31
|
+
const resultDocuments = Array.isArray(root.results) ? root.results.map(record).filter((item) => Object.keys(record(item.diagnostics)).length) : [];
|
|
32
|
+
const sourceFindings = Object.keys(record(root.diagnostics)).length
|
|
33
|
+
? lookupStrictExitFindings(root)
|
|
34
|
+
: resultDocuments.flatMap((item) => lookupStrictExitFindings(item));
|
|
35
|
+
const collection = record(root.collection);
|
|
36
|
+
const summary = record(root.summary);
|
|
37
|
+
const failed = Number(collection.failed ?? summary.failed ?? 0);
|
|
38
|
+
if (sourceFindings.length || failed > 0)
|
|
39
|
+
findings.push({ policy, reason: `${sourceFindings.length + failed} failed or incomplete collection state(s)` });
|
|
40
|
+
}
|
|
41
|
+
else if (policy === 'inconclusive') {
|
|
42
|
+
const count = availabilityStates(root).filter((state) => ['unknown', 'inconclusive', 'partial', 'unavailable'].includes(state)).length;
|
|
43
|
+
if (count)
|
|
44
|
+
findings.push({ policy, reason: `${count} authority or availability state(s) remained inconclusive` });
|
|
45
|
+
}
|
|
46
|
+
else if (policy === 'danger') {
|
|
47
|
+
const risk = Number(record(root.risk).score ?? root.riskScore);
|
|
48
|
+
const severity = String(root.severity || record(root.review).severity || '').toLowerCase();
|
|
49
|
+
if ((Number.isFinite(risk) && risk >= 70) || severity === 'danger' || severity === 'critical') {
|
|
50
|
+
findings.push({ policy, reason: Number.isFinite(risk) ? `risk score ${risk} met the 70 review threshold` : `explicit ${severity} severity was present` });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const summary = record(record(root.flightRecorder).summary);
|
|
55
|
+
const unexpected = Number(summary.unexpectedChanges ?? 0);
|
|
56
|
+
if (unexpected > 0)
|
|
57
|
+
findings.push({ policy, reason: `${unexpected} unexpected complete observation change(s)` });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return findings;
|
|
61
|
+
}
|
|
62
|
+
export function formatFailPolicyNotice(findings) {
|
|
63
|
+
return `Failure policy matched: ${findings.map((item) => `${item.policy} (${item.reason})`).join('; ')}.\n`;
|
|
64
|
+
}
|
package/cli/formatters/html.mjs
CHANGED
package/cli/formatters/json.mjs
CHANGED
|
@@ -10,11 +10,11 @@ const CLI_HTTP_SCHEMA = 'whoisleuth.cli.http';
|
|
|
10
10
|
const CLI_TLS_SCHEMA = 'whoisleuth.cli.tls';
|
|
11
11
|
const CLI_COMPARE_SCHEMA = 'whoisleuth.cli.compare';
|
|
12
12
|
const CLI_LOOKUP_SCHEMA_VERSION = 1;
|
|
13
|
-
const CLI_BULK_SCHEMA_VERSION =
|
|
13
|
+
const CLI_BULK_SCHEMA_VERSION = 3;
|
|
14
14
|
const CLI_CT_SEARCH_SCHEMA_VERSION = 1;
|
|
15
15
|
const CLI_DISCOVER_SCHEMA_VERSION = 2;
|
|
16
16
|
const CLI_POSTURE_SCHEMA_VERSION = 1;
|
|
17
|
-
const CLI_HTTP_SCHEMA_VERSION =
|
|
17
|
+
const CLI_HTTP_SCHEMA_VERSION = 2;
|
|
18
18
|
const CLI_TLS_SCHEMA_VERSION = 1;
|
|
19
19
|
const CLI_COMPARE_SCHEMA_VERSION = 3;
|
|
20
20
|
function buildCliLookupDocument(query, classified, result, generatedAt = new Date().toISOString(), mode = 'fast', collectionContext = {}) {
|
|
@@ -25,6 +25,7 @@ function buildCliLookupDocument(query, classified, result, generatedAt = new Dat
|
|
|
25
25
|
? collectionContext.vantageLabel
|
|
26
26
|
: null;
|
|
27
27
|
return {
|
|
28
|
+
...result,
|
|
28
29
|
schema: CLI_LOOKUP_SCHEMA,
|
|
29
30
|
version: CLI_LOOKUP_SCHEMA_VERSION,
|
|
30
31
|
generatedAt,
|
|
@@ -40,7 +41,6 @@ function buildCliLookupDocument(query, classified, result, generatedAt = new Dat
|
|
|
40
41
|
...(vantageLabel ? { vantageLabel } : {}),
|
|
41
42
|
},
|
|
42
43
|
} : {}),
|
|
43
|
-
...result,
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
function formatJsonDocument(document) {
|
|
@@ -105,15 +105,21 @@ function buildCliCompareDocument(result, generatedAt = new Date().toISOString())
|
|
|
105
105
|
return { ...result, schema: CLI_COMPARE_SCHEMA, version: CLI_COMPARE_SCHEMA_VERSION, generatedAt };
|
|
106
106
|
}
|
|
107
107
|
function bulkJsonItem(item, metadata) {
|
|
108
|
+
const provenance = {
|
|
109
|
+
observedAt: item.observedAt ?? null,
|
|
110
|
+
collectionOrigin: item.collectionOrigin ?? 'current_run',
|
|
111
|
+
};
|
|
108
112
|
if (!item.ok) {
|
|
109
113
|
return {
|
|
110
114
|
schema: CLI_BULK_ITEM_SCHEMA, version: CLI_BULK_SCHEMA_VERSION, generatedAt: metadata.generatedAt,
|
|
115
|
+
...provenance,
|
|
111
116
|
index: item.index, query: item.query, ok: false, error: item.error,
|
|
112
117
|
};
|
|
113
118
|
}
|
|
114
119
|
const result = item.result;
|
|
115
120
|
return {
|
|
116
121
|
schema: CLI_BULK_ITEM_SCHEMA, version: CLI_BULK_SCHEMA_VERSION, generatedAt: metadata.generatedAt,
|
|
122
|
+
...provenance,
|
|
117
123
|
index: item.index, query: item.query, ok: true,
|
|
118
124
|
type: item.classified.type,
|
|
119
125
|
inputHostname: item.classified.inputHostname,
|
|
@@ -142,6 +148,8 @@ function buildCliBulkDocument(items, metadata) {
|
|
|
142
148
|
};
|
|
143
149
|
}
|
|
144
150
|
function formatJsonLines(items, metadata) {
|
|
145
|
-
return
|
|
151
|
+
return items.length
|
|
152
|
+
? `${items.map((item) => JSON.stringify(bulkJsonItem(item, metadata))).join('\n')}\n`
|
|
153
|
+
: '';
|
|
146
154
|
}
|
|
147
155
|
export { CLI_BULK_ITEM_SCHEMA, CLI_BULK_SCHEMA, CLI_BULK_SCHEMA_VERSION, CLI_COMPARE_SCHEMA, CLI_COMPARE_SCHEMA_VERSION, CLI_CT_SEARCH_SCHEMA, CLI_CT_SEARCH_SCHEMA_VERSION, CLI_DISCOVER_ITEM_SCHEMA, CLI_DISCOVER_SCHEMA, CLI_DISCOVER_SCHEMA_VERSION, CLI_HTTP_SCHEMA, CLI_HTTP_SCHEMA_VERSION, CLI_POSTURE_SCHEMA, CLI_POSTURE_SCHEMA_VERSION, CLI_TLS_SCHEMA, CLI_TLS_SCHEMA_VERSION, CLI_LOOKUP_SCHEMA, CLI_LOOKUP_SCHEMA_VERSION, buildCliBulkDocument, buildCliCompareDocument, buildCliCtSearchDocument, buildCliDiscoverDocument, buildCliHttpDocument, buildCliLookupDocument, buildCliPostureDocument, buildCliTlsDocument, bulkJsonItem, discoverJsonItem, formatDiscoverDomainList, formatDiscoverJsonLines, formatJsonDocument, formatJsonLines, };
|
|
@@ -431,7 +431,7 @@ function formatTerminalHttp(document) {
|
|
|
431
431
|
const lines = [
|
|
432
432
|
`Domain ${safeTerminalValue(document.domain)}`,
|
|
433
433
|
`Probe ${titleCase(document.probeStatus)}`,
|
|
434
|
-
`
|
|
434
|
+
`Assessment ${titleCase(document.assessment ?? document.activityStatus)}`,
|
|
435
435
|
`Evidence ${titleCase(http.status)}`,
|
|
436
436
|
`Final URL ${safeTerminalValue(http.finalUrl)}`,
|
|
437
437
|
`HTTP status ${safeTerminalValue(response.status)}`,
|
package/cli/http.mjs
CHANGED
|
@@ -17,7 +17,8 @@ function buildHttpProbeResult(domain, probe) {
|
|
|
17
17
|
return {
|
|
18
18
|
domain,
|
|
19
19
|
probeStatus: status,
|
|
20
|
-
|
|
20
|
+
assessment: status === 'fetched' || status === 'responded' ? 'active' : 'inconclusive',
|
|
21
|
+
activityStatus: status === 'fetched' || status === 'responded' ? 'active' : null,
|
|
21
22
|
detail: boundedDetail(input.detail),
|
|
22
23
|
http: input.http && typeof input.http === 'object' && !Array.isArray(input.http)
|
|
23
24
|
? input.http
|
|
@@ -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
|
+
}
|
|
@@ -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,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
|
+
}
|