@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.
Files changed (42) hide show
  1. package/README.md +8 -4
  2. package/bin/whoisleuth.mjs +20 -9
  3. package/cli/bulk-checkpoint.mjs +65 -28
  4. package/cli/bulk-command-runner.mjs +15 -2
  5. package/cli/bulk.mjs +18 -7
  6. package/cli/completion.mjs +34 -3
  7. package/cli/config-profile.mjs +15 -12
  8. package/cli/discovery-observation-snapshot.mjs +8 -14
  9. package/cli/discovery-scan-command-runner.mjs +14 -1
  10. package/cli/discovery-snapshot.mjs +8 -15
  11. package/cli/errors.mjs +7 -1
  12. package/cli/formatters/json.mjs +12 -4
  13. package/cli/formatters/terminal.mjs +1 -1
  14. package/cli/http.mjs +2 -1
  15. package/cli/lookup-reconcile.mjs +1 -1
  16. package/cli/mail-review.mjs +5 -5
  17. package/cli/manual.mjs +1 -1
  18. package/cli/registry-fixture-scaffold.mjs +4 -3
  19. package/cli/runner.mjs +20 -9
  20. package/docs/cli-reference.md +26 -7
  21. package/docs/cli.md +1 -0
  22. package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
  23. package/frontend/src/lib/analysis/brand-profile-model.js +4 -2
  24. package/frontend/src/lib/analysis/case-evidence-model.js +1 -1
  25. package/frontend/src/lib/analysis/case-record-core.js +1 -1
  26. package/frontend/src/lib/analysis/case-record-operations.js +1 -1
  27. package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
  28. package/frontend/src/lib/analysis/lookup-decision-support.js +2 -2
  29. package/frontend/src/lib/analysis/lookup-presentation.js +9 -15
  30. package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
  31. package/frontend/src/lib/analysis/utils.js +1 -1
  32. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
  33. package/frontend/src/lib/analysis/workspace-archive.js +3 -7
  34. package/lib/bounded-file.mjs +69 -0
  35. package/lib/bounded-json-response.mjs +157 -0
  36. package/lib/browser-library-profile.mjs +167 -38
  37. package/lib/lookup-cache.mjs +5 -1
  38. package/lib/rdap-transport.mjs +6 -6
  39. package/lib/safe-fetch.mjs +37 -32
  40. package/lib/threat-intelligence-runtime.mjs +1 -2
  41. package/lib/website-technology.mjs +2 -6
  42. package/package.json +1 -1
@@ -1,13 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { open } from 'node:fs/promises';
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
- const handle = await open(path, 'r');
73
- try {
74
- const metadata = await handle.stat();
75
- if (!metadata.isFile() || metadata.size > MAX_DISCOVERY_SNAPSHOT_BYTES) {
76
- throw new CliUsageError(`Discovery snapshot input is limited to ${MAX_DISCOVERY_SNAPSHOT_BYTES} bytes.`);
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
- export { CliUsageError, MAX_CLI_ERROR_MESSAGE_LENGTH, boundedCliErrorMessage };
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, };
@@ -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 = 2;
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 = 1;
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 `${items.map((item) => JSON.stringify(bulkJsonItem(item, metadata))).join('\n')}\n`;
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
- `Activity ${titleCase(document.activityStatus)}`,
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
- activityStatus: status === 'fetched' || status === 'responded' ? 'active' : 'unreachable',
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
@@ -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((document) => document.registrableDomain !== domain)) {
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' }));
@@ -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 === 2 && Array.isArray(document.results)) {
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 === 2) {
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 !== 2)) {
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, and 130 analyst cancellation.
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 profile = registryCompatibilityMatrix().find((item) => item.id === profileId);
16
- if (!profile)
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
- if (!profile.suffixes.includes(suffix))
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
- for await (const chunk of stream) {
66
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
67
- total += buffer.length;
68
- if (total > limit)
69
- throw new CliUsageError(`Standard input is limited to ${limit} bytes.`);
70
- chunks.push(buffer);
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.');
@@ -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. Already started network operations remain subject to their existing hard
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. Reusing the same path is refused unless `--resume` is also
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, non-read methods,
1259
- credentials, non-default ports, and private or reserved addresses, and retains
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. The browser connection is not pinned to the address checked before
1262
- each hostname's first request, so DNS rebinding remains a residual risk. Use a
1263
- disposable network-restricted environment for untrusted targets.
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(availability, activation, dnsEvidence, dnsRecords, tlsEvidence) {
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 safeId(value) {
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 = safeId(existing?.id) || safeId(value.id) || (typeof options.makeId === 'function' ? safeId(options.makeId()) : null);
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 { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_CASES, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_DOMAIN_LENGTH, 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, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.js";
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, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_CASES, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_DOMAIN_LENGTH, 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, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.js";
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 { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_CASES, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_DOMAIN_LENGTH, 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, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.js";
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-context',
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-context',
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-context',
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-identity',
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-identity',
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-identity',
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-identity',
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-identity',
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-identity',
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-identity',
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 LOOKUP_EVIDENCE_DENSITIES = Object.freeze([
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 stored = JSON.parse(storage.getItem(LOOKUP_PRESENTATION_STORAGE_KEY) || 'null');
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 { density: 'summary', task: 'general' };
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
  }