@slicedearth/whoisleuth-cli 1.43.0 → 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 +8 -4
- package/bin/whoisleuth.mjs +20 -9
- package/cli/bulk-checkpoint.mjs +65 -28
- package/cli/bulk-command-runner.mjs +15 -2
- package/cli/bulk.mjs +18 -7
- package/cli/completion.mjs +34 -3
- package/cli/config-profile.mjs +15 -12
- package/cli/discovery-observation-snapshot.mjs +8 -14
- package/cli/discovery-scan-command-runner.mjs +14 -1
- package/cli/discovery-snapshot.mjs +8 -15
- package/cli/errors.mjs +7 -1
- package/cli/formatters/json.mjs +12 -4
- package/cli/formatters/terminal.mjs +1 -1
- package/cli/http.mjs +2 -1
- package/cli/lookup-reconcile.mjs +1 -1
- package/cli/mail-review.mjs +5 -5
- package/cli/manual.mjs +1 -1
- package/cli/registry-fixture-scaffold.mjs +4 -3
- package/cli/runner.mjs +20 -9
- package/docs/cli-reference.md +26 -7
- package/docs/cli.md +1 -0
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
- package/frontend/src/lib/analysis/brand-profile-model.js +4 -2
- 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/lookup-asset-graph.js +8 -8
- package/frontend/src/lib/analysis/lookup-decision-support.js +2 -2
- package/frontend/src/lib/analysis/lookup-presentation.js +9 -15
- package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
- package/frontend/src/lib/analysis/utils.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
- package/frontend/src/lib/analysis/workspace-archive.js +3 -7
- 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/lookup-cache.mjs +5 -1
- package/lib/rdap-transport.mjs +6 -6
- package/lib/safe-fetch.mjs +37 -32
- package/lib/threat-intelligence-runtime.mjs +1 -2
- package/lib/website-technology.mjs +2 -6
- package/package.json +1 -1
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
import { CliUsageError } from "./errors.mjs";
|
|
2
|
+
import { boundedCliInputError, CliUsageError } from "./errors.mjs";
|
|
4
3
|
import { writePrivateFile } from "./output-file.mjs";
|
|
5
4
|
import { isValidAsciiDomainName } from "../lib/hostname.mjs";
|
|
5
|
+
import { readBoundedRegularTextFile } from "../lib/bounded-file.mjs";
|
|
6
6
|
export const CLI_DISCOVERY_SNAPSHOT_SCHEMA = 'whoisleuth.cli.discovery-snapshot';
|
|
7
7
|
export const CLI_DISCOVERY_SNAPSHOT_VERSION = 1;
|
|
8
8
|
export const MAX_DISCOVERY_SNAPSHOT_BYTES = 4 * 1024 * 1024;
|
|
9
9
|
export const MAX_DISCOVERY_SNAPSHOT_DOMAINS = 10_000;
|
|
10
|
-
const SHA256_RE = /^[a-f0-9]{64}$/u;
|
|
11
10
|
function configurationDigest(configuration) {
|
|
12
11
|
return createHash('sha256').update(JSON.stringify({
|
|
13
12
|
seed: configuration.seed,
|
|
@@ -69,22 +68,16 @@ function parseDiscoverySnapshot(text, expectedConfigurationDigest) {
|
|
|
69
68
|
}
|
|
70
69
|
async function readExistingSnapshot(path) {
|
|
71
70
|
try {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
return await handle.readFile({ encoding: 'utf8' });
|
|
79
|
-
}
|
|
80
|
-
finally {
|
|
81
|
-
await handle.close();
|
|
82
|
-
}
|
|
71
|
+
return await readBoundedRegularTextFile(path, {
|
|
72
|
+
maximumBytes: MAX_DISCOVERY_SNAPSHOT_BYTES,
|
|
73
|
+
label: 'Discovery snapshot input',
|
|
74
|
+
allowSymbolicLink: true,
|
|
75
|
+
});
|
|
83
76
|
}
|
|
84
77
|
catch (error) {
|
|
85
78
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
|
|
86
79
|
return null;
|
|
87
|
-
throw error;
|
|
80
|
+
throw boundedCliInputError(error, 'Discovery snapshot input');
|
|
88
81
|
}
|
|
89
82
|
}
|
|
90
83
|
async function updateDiscoverySnapshot(path, candidatesValue, configuration, generatedAt) {
|
package/cli/errors.mjs
CHANGED
|
@@ -18,4 +18,10 @@ function boundedCliErrorMessage(error, fallback = 'Unexpected command failure')
|
|
|
18
18
|
.trim()
|
|
19
19
|
.slice(0, MAX_CLI_ERROR_MESSAGE_LENGTH) || fallback;
|
|
20
20
|
}
|
|
21
|
-
|
|
21
|
+
function boundedCliInputError(error, label) {
|
|
22
|
+
if (error instanceof CliUsageError)
|
|
23
|
+
return error;
|
|
24
|
+
const safeLabel = label.replace(/[^A-Za-z0-9 ()_-]+/gu, '').trim().slice(0, 80) || 'Input';
|
|
25
|
+
return new CliUsageError(`${safeLabel} could not be read as a bounded regular file.`);
|
|
26
|
+
}
|
|
27
|
+
export { CliUsageError, MAX_CLI_ERROR_MESSAGE_LENGTH, boundedCliErrorMessage, boundedCliInputError, };
|
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
|
package/cli/lookup-reconcile.mjs
CHANGED
|
@@ -37,7 +37,7 @@ function buildCliLookupReconciliation(inputs, generatedAt = new Date().toISOStri
|
|
|
37
37
|
label: `Lookup reconciliation observation ${index + 1}`,
|
|
38
38
|
}));
|
|
39
39
|
const domain = documents[0]?.registrableDomain;
|
|
40
|
-
if (!domain || documents.some((
|
|
40
|
+
if (!domain || documents.some((candidate) => candidate.registrableDomain !== domain)) {
|
|
41
41
|
throw new CliUsageError('Lookup reconciliation requires observations for one domain.');
|
|
42
42
|
}
|
|
43
43
|
const comparisons = documents.slice(1).map((document, index) => buildCliLookupDiff(inputs[0], inputs[index + 1], generatedAt, { domainMode: 'same' }));
|
package/cli/mail-review.mjs
CHANGED
|
@@ -128,14 +128,14 @@ function parseInput(textValue) {
|
|
|
128
128
|
try {
|
|
129
129
|
const parsed = JSON.parse(textInput);
|
|
130
130
|
const document = record(parsed);
|
|
131
|
-
if (document.schema === 'whoisleuth.cli.bulk' && document.version
|
|
131
|
+
if (document.schema === 'whoisleuth.cli.bulk' && [2, 3].includes(Number(document.version)) && Array.isArray(document.results)) {
|
|
132
132
|
values = document.results;
|
|
133
133
|
}
|
|
134
|
-
else if (document.schema === 'whoisleuth.cli.bulk.item' && document.version
|
|
134
|
+
else if (document.schema === 'whoisleuth.cli.bulk.item' && [2, 3].includes(Number(document.version))) {
|
|
135
135
|
values = [document];
|
|
136
136
|
}
|
|
137
137
|
else {
|
|
138
|
-
throw new CliUsageError('Mail review requires WHOISleuth Bulk JSON schema version 2.');
|
|
138
|
+
throw new CliUsageError('Mail review requires WHOISleuth Bulk JSON schema version 2 or 3.');
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
catch (error) {
|
|
@@ -152,8 +152,8 @@ function parseInput(textValue) {
|
|
|
152
152
|
throw new CliUsageError(`Mail review supports between 1 and ${MAX_MAIL_REVIEW_ROWS} Bulk rows.`);
|
|
153
153
|
}
|
|
154
154
|
const rows = values.map((value) => record(value));
|
|
155
|
-
if (rows.some((value) => value.schema !== 'whoisleuth.cli.bulk.item' || value.version
|
|
156
|
-
throw new CliUsageError('Every Mail review row must use WHOISleuth Bulk item schema version 2.');
|
|
155
|
+
if (rows.some((value) => value.schema !== 'whoisleuth.cli.bulk.item' || ![2, 3].includes(Number(value.version)))) {
|
|
156
|
+
throw new CliUsageError('Every Mail review row must use WHOISleuth Bulk item schema version 2 or 3.');
|
|
157
157
|
}
|
|
158
158
|
return rows;
|
|
159
159
|
}
|
package/cli/manual.mjs
CHANGED
|
@@ -23,7 +23,7 @@ ${commands}
|
|
|
23
23
|
.SH OUTPUT
|
|
24
24
|
Human-readable output is the default. Versioned JSON and JSONL are available where documented. Diagnostics and optional progress events are written to standard error. Use --output with an optional --force flag for atomic private file output.
|
|
25
25
|
.SH EXIT STATUS
|
|
26
|
-
0 indicates command completion, 2 invalid usage, 3 a collection or comparison failure, 4 an explicitly detected partial result, 70 an internal bootstrap failure,
|
|
26
|
+
0 indicates command completion, 2 invalid usage, 3 a collection or comparison failure, 4 an explicitly detected partial result, 70 an internal bootstrap failure, 130 analyst cancellation, and 143 service termination.
|
|
27
27
|
.SH PRIVACY
|
|
28
28
|
Network commands disclose the target to the directly queried upstream services. Offline commands do not make network requests. Output files are created with private permissions and are never uploaded by the CLI.
|
|
29
29
|
.SH LICENSE
|
|
@@ -12,10 +12,11 @@ export function buildRegistryFixtureScaffold(profileIdInput, suffixInput, scenar
|
|
|
12
12
|
throw new TypeError('The suffix is invalid.');
|
|
13
13
|
if (!SCENARIOS.has(scenario))
|
|
14
14
|
throw new TypeError('Scenario must be registered, not_found, or inconclusive.');
|
|
15
|
-
const
|
|
16
|
-
if (!
|
|
15
|
+
const matchingProfiles = registryCompatibilityMatrix().filter((item) => item.id === profileId);
|
|
16
|
+
if (!matchingProfiles.length)
|
|
17
17
|
throw new TypeError('The capability profile is not present in the registry catalogue.');
|
|
18
|
-
|
|
18
|
+
const profile = matchingProfiles.find((item) => item.suffixes.includes(suffix));
|
|
19
|
+
if (!profile)
|
|
19
20
|
throw new TypeError(`The capability profile does not cover .${suffix}.`);
|
|
20
21
|
const domain = `EXAMPLE.${suffix.toUpperCase()}`;
|
|
21
22
|
const response = scenario === 'registered'
|
package/cli/runner.mjs
CHANGED
|
@@ -57,17 +57,28 @@ import { presentTerminalOutput, terminalPresentation, } from "./terminal-present
|
|
|
57
57
|
const require = createRequire(import.meta.url);
|
|
58
58
|
const { version: VERSION } = require('../package.json');
|
|
59
59
|
const MAX_STDIN_BYTES = 4096;
|
|
60
|
-
async function readStdinBounded(stream, limit = MAX_STDIN_BYTES) {
|
|
60
|
+
async function readStdinBounded(stream, limit = MAX_STDIN_BYTES, signal) {
|
|
61
61
|
if (!stream || stream.isTTY)
|
|
62
62
|
return '';
|
|
63
|
+
const abort = () => stream.destroy?.(new DOMException('Aborted', 'AbortError'));
|
|
64
|
+
if (signal?.aborted)
|
|
65
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
66
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
63
67
|
const chunks = [];
|
|
64
68
|
let total = 0;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
try {
|
|
70
|
+
for await (const chunk of stream) {
|
|
71
|
+
if (signal?.aborted)
|
|
72
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
73
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
74
|
+
total += buffer.length;
|
|
75
|
+
if (total > limit)
|
|
76
|
+
throw new CliUsageError(`Standard input is limited to ${limit} bytes.`);
|
|
77
|
+
chunks.push(buffer);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
signal?.removeEventListener('abort', abort);
|
|
71
82
|
}
|
|
72
83
|
const text = Buffer.concat(chunks).toString('utf8').trim();
|
|
73
84
|
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
@@ -136,7 +147,7 @@ async function runParsedCli(args, dependencies = {}) {
|
|
|
136
147
|
};
|
|
137
148
|
const readSingleInput = async () => (dependencies.readStdin
|
|
138
149
|
? await dependencies.readStdin()
|
|
139
|
-
: await readStdinBounded(dependencies.stdin || process.stdin));
|
|
150
|
+
: await readStdinBounded(dependencies.stdin || process.stdin, MAX_STDIN_BYTES, dependencies.signal));
|
|
140
151
|
const commandContext = Object.freeze({
|
|
141
152
|
stdout,
|
|
142
153
|
stderr,
|
|
@@ -289,7 +300,7 @@ async function runParsedCli(args, dependencies = {}) {
|
|
|
289
300
|
}
|
|
290
301
|
if (args.action === 'registry-support') {
|
|
291
302
|
failureLabel = 'Registry support';
|
|
292
|
-
const readInput = dependencies.readStdin || (() => readStdinBounded(dependencies.stdin || process.stdin));
|
|
303
|
+
const readInput = dependencies.readStdin || (() => readStdinBounded(dependencies.stdin || process.stdin, MAX_STDIN_BYTES, dependencies.signal));
|
|
293
304
|
const requestedInput = args.target || await readInput();
|
|
294
305
|
if (!requestedInput)
|
|
295
306
|
throw new CliUsageError('registry-support requires one domain or suffix as an argument or on stdin.');
|
package/docs/cli-reference.md
CHANGED
|
@@ -330,7 +330,8 @@ delivery observed by the caller.
|
|
|
330
330
|
|
|
331
331
|
Pressing Ctrl-C asks an in-flight Lookup or Bulk run to stop, suppresses any
|
|
332
332
|
partial final result, flushes an enabled Bulk checkpoint, and exits with code
|
|
333
|
-
130.
|
|
333
|
+
130. SIGTERM follows the same graceful cleanup path and exits with code 143.
|
|
334
|
+
Already started network operations remain subject to their existing hard
|
|
334
335
|
timeouts while the shared in-process API unwinds; the executable terminates
|
|
335
336
|
after CLI cleanup. A second Ctrl-C removes any known unpublished temporary
|
|
336
337
|
output file on a best-effort basis before exiting immediately.
|
|
@@ -338,11 +339,23 @@ output file on a best-effort basis before exiting immediately.
|
|
|
338
339
|
Bulk can persist compact progress with `--checkpoint <file>`. A new checkpoint
|
|
339
340
|
is private, bounded to 16 MiB, versioned, and tied to the exact ordered input
|
|
340
341
|
and scan mode by SHA-256. It stores only compact per-item results, never full
|
|
341
|
-
Lookup responses.
|
|
342
|
+
Lookup responses. Checkpoint schema 2 records a nullable per-row observation
|
|
343
|
+
time. Schema 1 remains readable, but its resumed rows keep observation time
|
|
344
|
+
unknown rather than inheriting the resume time. Reusing the same path is
|
|
345
|
+
refused unless `--resume` is also
|
|
342
346
|
specified. Resume revalidates the complete untrusted document and skips only
|
|
343
347
|
the exact completed rows; changed input, mode, schema, digest, or malformed
|
|
344
348
|
results fail closed.
|
|
345
349
|
|
|
350
|
+
Bulk JSON, JSONL, and item documents use schema version 3. Each row includes
|
|
351
|
+
`observedAt` and a `collectionOrigin` of `current_run` or
|
|
352
|
+
`resumed_checkpoint`; the top-level `generatedAt` remains the report creation
|
|
353
|
+
time. HTTP JSON uses schema version 2 and adds the explicit `assessment` field.
|
|
354
|
+
The legacy `activityStatus` field remains for compatibility, but is `null`
|
|
355
|
+
when collection is inconclusive rather than labelling the target unreachable.
|
|
356
|
+
Consumers should use `assessment`; older version-1 `unreachable` values remain
|
|
357
|
+
historical inconclusive labels, not proof that a target cannot be reached.
|
|
358
|
+
|
|
346
359
|
If checkpoint persistence fails after collection, WHOISleuth still emits the
|
|
347
360
|
completed final result, reports the checkpoint limitation, and exits with code
|
|
348
361
|
4. The last successfully published checkpoint remains available, but it may
|
|
@@ -393,6 +406,7 @@ machine access is not evidence that a domain is unregistered or safe.
|
|
|
393
406
|
| 4 | A bounded operation completed partially, such as Bulk item failures or failed explicit Doctor network checks. |
|
|
394
407
|
| 70 | Unexpected CLI bootstrap failure. |
|
|
395
408
|
| 130 | The analyst cancelled the command. No partial final result was emitted. |
|
|
409
|
+
| 143 | The process received SIGTERM. No partial final result was emitted. |
|
|
396
410
|
|
|
397
411
|
This release supports `lookup`, `bulk`, `ct-search`, `ct-intake`, `map-observations`, `oam-export`, `discover`, `discover-scan`, `posture`,
|
|
398
412
|
`http`, `tls`, `registry-support`, `registry-doctor`, `registry-cohort`, `registry-scaffold`, `risk-calibrate`,
|
|
@@ -1255,12 +1269,17 @@ text, and a version-2 `whoisleuth.web-capture-manifest` compatible with the
|
|
|
1255
1269
|
Cases importer. Version-1 manifests remain importable.
|
|
1256
1270
|
|
|
1257
1271
|
Rendered capture executes page JavaScript. It caps HTTP(S) requests and request
|
|
1258
|
-
hostnames, blocks downloads, service workers, WebSockets,
|
|
1259
|
-
credentials, non-default ports, and private or reserved
|
|
1272
|
+
hostnames, blocks downloads, service workers, WebSockets, WebRTC, WebTransport,
|
|
1273
|
+
non-read methods, credentials, non-default ports, and private or reserved
|
|
1274
|
+
addresses, and retains
|
|
1260
1275
|
no request path, query, headers, bodies, cookies, credentials, DOM markup, or
|
|
1261
|
-
page text.
|
|
1262
|
-
|
|
1263
|
-
|
|
1276
|
+
page text. Every browser request is intercepted, its hostname is resolved again,
|
|
1277
|
+
and the bounded response is fetched through WHOISleuth's public-address-pinned
|
|
1278
|
+
transport. Individual responses are capped at 4 MiB and all fetched response
|
|
1279
|
+
bodies together are capped at 24 MiB. Redirect destinations pass through the
|
|
1280
|
+
same interception and validation before collection continues. Use a disposable
|
|
1281
|
+
network-restricted environment for untrusted targets because rendered page code
|
|
1282
|
+
still executes inside the isolated browser process.
|
|
1264
1283
|
|
|
1265
1284
|
The separate offline comparison command accepts two selected version-2 local
|
|
1266
1285
|
capture manifests. It first verifies each referenced screenshot and DOM digest
|
package/docs/cli.md
CHANGED
|
@@ -168,6 +168,7 @@ commands do not publish partial files. Output is capped at 32 MiB.
|
|
|
168
168
|
| 4 | A bounded operation completed partially. |
|
|
169
169
|
| 70 | Unexpected CLI bootstrap failure. |
|
|
170
170
|
| 130 | The analyst cancelled the command; no partial final result was emitted. |
|
|
171
|
+
| 143 | The process received SIGTERM; no partial final result was emitted. |
|
|
171
172
|
|
|
172
173
|
## Detailed command reference
|
|
173
174
|
|
|
@@ -205,7 +205,7 @@ function contactsItem(insights) {
|
|
|
205
205
|
function sourceComplete(evidence) {
|
|
206
206
|
return evidence.source === 'dns' && evidence.complete === true;
|
|
207
207
|
}
|
|
208
|
-
function transitionDependencies(
|
|
208
|
+
function transitionDependencies(_availability, activation, dnsEvidence, dnsRecords, tlsEvidence) {
|
|
209
209
|
const dnsComplete = sourceComplete(dnsEvidence);
|
|
210
210
|
const nameservers = strings(dnsRecords.ns);
|
|
211
211
|
const addresses = [...strings(dnsRecords.a), ...strings(dnsRecords.aaaa)];
|
|
@@ -67,7 +67,7 @@ function boundedText(value, maximum = MAX_PROFILE_TEXT_LENGTH) {
|
|
|
67
67
|
return '';
|
|
68
68
|
return value.slice(0, maximum * 4).replace(/\s+/g, ' ').trim().slice(0, maximum).trim();
|
|
69
69
|
}
|
|
70
|
-
function
|
|
70
|
+
export function normalizeBrandProfileId(value) {
|
|
71
71
|
return typeof value === 'string' && SAFE_ID_RE.test(value) ? value : null;
|
|
72
72
|
}
|
|
73
73
|
function timestamp(value, fallback) {
|
|
@@ -337,7 +337,9 @@ export function normalizeBrandProfile(raw, options = {}) {
|
|
|
337
337
|
const pageBaseline = candidateBaseline && officialDomains.includes(candidateBaseline.domain)
|
|
338
338
|
? candidateBaseline
|
|
339
339
|
: null;
|
|
340
|
-
const profileId =
|
|
340
|
+
const profileId = normalizeBrandProfileId(existing?.id)
|
|
341
|
+
|| normalizeBrandProfileId(value.id)
|
|
342
|
+
|| (typeof options.makeId === 'function' ? normalizeBrandProfileId(options.makeId()) : null);
|
|
341
343
|
const name = boundedText(value.name, MAX_PROFILE_NAME_LENGTH);
|
|
342
344
|
if (!profileId || !name)
|
|
343
345
|
return null;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// record normalization, and analyst updates.
|
|
3
3
|
import { normalizeHttpSummary } from "./http-summary.js";
|
|
4
4
|
import { normalizeOpportunityModelVersion, normalizeRiskModelVersion } from "./scoring.js";
|
|
5
|
-
import {
|
|
5
|
+
import { MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, } from "./case-record-contracts.js";
|
|
6
6
|
import { CONCLUSIVE_AVAILABILITY, DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_RANK, EVIDENCE_SOURCE_SET, REGISTERED_LIKE, hashString, isoOrNull, objectRecord, safeId, } from "./case-record-core.js";
|
|
7
7
|
function evidenceString(value, max = MAX_EVIDENCE_STRING_LENGTH) {
|
|
8
8
|
if (value == null)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Pure, framework-neutral analyst-case records, evidence histories, bounded
|
|
2
2
|
// record normalization, and analyst updates.
|
|
3
|
-
import { CASE_DISPOSITIONS, CASE_REVIEW_REASONS,
|
|
3
|
+
import { CASE_DISPOSITIONS, CASE_REVIEW_REASONS, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_DOMAIN_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.js";
|
|
4
4
|
// Forward-version policy (two distinct guarantees):
|
|
5
5
|
// - A locally-stored envelope that declares a version greater than this is
|
|
6
6
|
// still read best-effort on load (known fields kept, unknown dropped), but
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Pure, framework-neutral analyst-case records, evidence histories, bounded
|
|
2
2
|
// record normalization, and analyst updates.
|
|
3
3
|
import { appendCaseAction, appendCaseAssertion, appendCaseDecision, appendCaseEvidencePin, appendCaseEvidencePins, appendCaseManualTrailEvent, appendCaseSighting, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseSightings, updateCaseAction, updateCaseAssertion, } from "./case-response-model.js";
|
|
4
|
-
import {
|
|
4
|
+
import { MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.js";
|
|
5
5
|
import { DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_SET, deterministicId, isoOrNow, makeId, normalizeDisposition, normalizeDomain, normalizeNoteBody, normalizeReviewReasonCode, normalizeNotes, normalizeSource, normalizeStatus, normalizeTags, objectRecord, safeId, } from "./case-record-core.js";
|
|
6
6
|
import { normalizeEvidenceHistory, } from "./case-evidence-model.js";
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
@@ -372,7 +372,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
372
372
|
completeness: sourceCompleteness(networkContext),
|
|
373
373
|
limitations: limitations(networkContext.limitations),
|
|
374
374
|
lenses: ['all'],
|
|
375
|
-
href: '#evidence-network
|
|
375
|
+
href: '#evidence-network',
|
|
376
376
|
});
|
|
377
377
|
}
|
|
378
378
|
const networkLabel = text(network.name || network.holder || network.handle, 180);
|
|
@@ -390,7 +390,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
390
390
|
completeness: sourceCompleteness(networkContext),
|
|
391
391
|
limitations: limitations(networkContext.limitations),
|
|
392
392
|
lenses: ['all'],
|
|
393
|
-
href: '#evidence-network
|
|
393
|
+
href: '#evidence-network',
|
|
394
394
|
});
|
|
395
395
|
}
|
|
396
396
|
for (const cidr of textList(network.cidrs, 8)) {
|
|
@@ -407,7 +407,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
407
407
|
completeness: sourceCompleteness(networkContext),
|
|
408
408
|
limitations: limitations(networkContext.limitations),
|
|
409
409
|
lenses: ['all'],
|
|
410
|
-
href: '#evidence-network
|
|
410
|
+
href: '#evidence-network',
|
|
411
411
|
});
|
|
412
412
|
}
|
|
413
413
|
}
|
|
@@ -437,7 +437,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
437
437
|
completeness: sourceCompleteness(input.pageIdentity),
|
|
438
438
|
limitations: limitations(record(input.pageIdentity).limitations),
|
|
439
439
|
lenses: ['identity'],
|
|
440
|
-
href: '#evidence-page
|
|
440
|
+
href: '#evidence-page',
|
|
441
441
|
boundary: trustBoundary(canonicalHost, finalHost || target),
|
|
442
442
|
}, 'Publisher-declared canonical origin', identitySource);
|
|
443
443
|
}
|
|
@@ -451,7 +451,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
451
451
|
completeness: sourceCompleteness(input.pageIdentity),
|
|
452
452
|
limitations: limitations(record(input.pageIdentity).limitations),
|
|
453
453
|
lenses: ['identity'],
|
|
454
|
-
href: '#evidence-page
|
|
454
|
+
href: '#evidence-page',
|
|
455
455
|
boundary: trustBoundary(openGraphHost, finalHost || target),
|
|
456
456
|
}, 'Publisher-declared Open Graph origin', identitySource);
|
|
457
457
|
}
|
|
@@ -466,7 +466,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
466
466
|
completeness: sourceCompleteness(input.pageIdentity),
|
|
467
467
|
limitations: ['A declared form action does not prove that a user submitted data or that the endpoint received it.'],
|
|
468
468
|
lenses: ['identity'],
|
|
469
|
-
href: '#evidence-page
|
|
469
|
+
href: '#evidence-page',
|
|
470
470
|
boundary: trustBoundary(host, finalHost || target),
|
|
471
471
|
}, 'External form-action origin', identitySource);
|
|
472
472
|
}
|
|
@@ -482,7 +482,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
482
482
|
completeness: sourceCompleteness(input.pageIdentity),
|
|
483
483
|
limitations: ['A static resource reference does not prove that a browser loaded the resource or disclosed data to it.'],
|
|
484
484
|
lenses: ['identity'],
|
|
485
|
-
href: '#evidence-page
|
|
485
|
+
href: '#evidence-page',
|
|
486
486
|
boundary: trustBoundary(host, finalHost || target),
|
|
487
487
|
}, 'External resource origin', identitySource);
|
|
488
488
|
}
|
|
@@ -498,7 +498,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
498
498
|
completeness: sourceCompleteness(input.pageIdentity),
|
|
499
499
|
limitations: ['A shared identifier is a relationship lead and does not establish common ownership or control.'],
|
|
500
500
|
lenses: ['identity'],
|
|
501
|
-
href: '#evidence-page
|
|
501
|
+
href: '#evidence-page',
|
|
502
502
|
}, text(identifier.type, 80), identitySource);
|
|
503
503
|
}
|
|
504
504
|
}
|
|
@@ -275,7 +275,7 @@ function identityEntries(input) {
|
|
|
275
275
|
title: 'The declared canonical origin differs from the observed final origin',
|
|
276
276
|
detail: `${finalHost} served the page while the document declared ${canonicalHost} as canonical.`,
|
|
277
277
|
sources: ['HTTP', 'HTML'],
|
|
278
|
-
href: '#evidence-page
|
|
278
|
+
href: '#evidence-page',
|
|
279
279
|
});
|
|
280
280
|
}
|
|
281
281
|
if (finalHost && openGraphHost && !sameRegistrableScope(finalHost, openGraphHost, registrableDomain)) {
|
|
@@ -286,7 +286,7 @@ function identityEntries(input) {
|
|
|
286
286
|
title: 'The Open Graph URL points outside the observed final origin',
|
|
287
287
|
detail: `${finalHost} served the page while Open Graph metadata declared ${openGraphHost}. Publisher metadata is a claim, not identity proof.`,
|
|
288
288
|
sources: ['HTTP', 'HTML'],
|
|
289
|
-
href: '#evidence-page
|
|
289
|
+
href: '#evidence-page',
|
|
290
290
|
});
|
|
291
291
|
}
|
|
292
292
|
return output;
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
export const LOOKUP_PRESENTATION_STORAGE_KEY = 'whoisleuth:lookup-presentation:v1';
|
|
2
|
-
export const
|
|
3
|
-
Object.freeze({ id: 'summary', label: 'Essentials', detail: 'A compact assessment, key observations, unresolved evidence, and one summary for each evidence family.' }),
|
|
4
|
-
Object.freeze({ id: 'standard', label: 'Evidence', detail: 'Settled evidence is grouped by task while source detail and raw evidence stay collapsed until you inspect them.' }),
|
|
5
|
-
Object.freeze({ id: 'full', label: 'All evidence', detail: 'Every settled evidence family remains available, including advanced and bounded raw evidence.' }),
|
|
6
|
-
]);
|
|
2
|
+
export const MAX_LOOKUP_PRESENTATION_SERIALIZED_BYTES = 1_024;
|
|
7
3
|
export const LOOKUP_TASK_VIEWS = Object.freeze([
|
|
8
4
|
Object.freeze({ id: 'general', label: 'General investigation' }),
|
|
9
5
|
Object.freeze({ id: 'acquisition', label: 'Acquisition review' }),
|
|
@@ -11,13 +7,7 @@ export const LOOKUP_TASK_VIEWS = Object.freeze([
|
|
|
11
7
|
Object.freeze({ id: 'incident', label: 'Incident response' }),
|
|
12
8
|
Object.freeze({ id: 'owned', label: 'Owned-domain posture' }),
|
|
13
9
|
]);
|
|
14
|
-
const DENSITIES = new Set(LOOKUP_EVIDENCE_DENSITIES.map((option) => option.id));
|
|
15
10
|
const TASKS = new Set(LOOKUP_TASK_VIEWS.map((option) => option.id));
|
|
16
|
-
export function normalizeLookupEvidenceDensity(value) {
|
|
17
|
-
return typeof value === 'string' && DENSITIES.has(value)
|
|
18
|
-
? value
|
|
19
|
-
: 'summary';
|
|
20
|
-
}
|
|
21
11
|
export function normalizeLookupTaskView(value) {
|
|
22
12
|
return typeof value === 'string' && TASKS.has(value)
|
|
23
13
|
? value
|
|
@@ -25,10 +15,15 @@ export function normalizeLookupTaskView(value) {
|
|
|
25
15
|
}
|
|
26
16
|
export function readLookupPresentation(storage) {
|
|
27
17
|
try {
|
|
28
|
-
const
|
|
18
|
+
const serialized = storage.getItem(LOOKUP_PRESENTATION_STORAGE_KEY);
|
|
19
|
+
if (serialized === null
|
|
20
|
+
|| serialized.length > MAX_LOOKUP_PRESENTATION_SERIALIZED_BYTES
|
|
21
|
+
|| new TextEncoder().encode(serialized).byteLength > MAX_LOOKUP_PRESENTATION_SERIALIZED_BYTES) {
|
|
22
|
+
return { task: 'general' };
|
|
23
|
+
}
|
|
24
|
+
const stored = JSON.parse(serialized);
|
|
29
25
|
if (stored?.version === 1) {
|
|
30
26
|
return {
|
|
31
|
-
density: normalizeLookupEvidenceDensity(stored.density),
|
|
32
27
|
task: normalizeLookupTaskView(stored.task),
|
|
33
28
|
};
|
|
34
29
|
}
|
|
@@ -36,13 +31,12 @@ export function readLookupPresentation(storage) {
|
|
|
36
31
|
catch {
|
|
37
32
|
// Invalid or unavailable browser storage falls back to the stable defaults.
|
|
38
33
|
}
|
|
39
|
-
return {
|
|
34
|
+
return { task: 'general' };
|
|
40
35
|
}
|
|
41
36
|
export function writeLookupPresentation(storage, state) {
|
|
42
37
|
try {
|
|
43
38
|
storage.setItem(LOOKUP_PRESENTATION_STORAGE_KEY, JSON.stringify({
|
|
44
39
|
version: 1,
|
|
45
|
-
density: normalizeLookupEvidenceDensity(state.density),
|
|
46
40
|
task: normalizeLookupTaskView(state.task),
|
|
47
41
|
}));
|
|
48
42
|
}
|