@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.
Files changed (90) hide show
  1. package/README.md +23 -4
  2. package/bin/whoisleuth.mjs +31 -9
  3. package/cli/archive-inspect.mjs +37 -2
  4. package/cli/arguments.mjs +361 -256
  5. package/cli/artifact-verify.mjs +35 -1
  6. package/cli/bulk-checkpoint.mjs +72 -39
  7. package/cli/bulk-command-runner.mjs +34 -4
  8. package/cli/bulk.mjs +18 -7
  9. package/cli/case-pack.mjs +136 -0
  10. package/cli/ci-report.mjs +73 -0
  11. package/cli/collection-preflight.mjs +59 -0
  12. package/cli/command-reference.mjs +395 -0
  13. package/cli/completion.mjs +67 -13
  14. package/cli/config-profile.mjs +183 -0
  15. package/cli/ct-event-intake.mjs +159 -0
  16. package/cli/discovery-observation-snapshot.mjs +8 -14
  17. package/cli/discovery-scan-command-runner.mjs +29 -2
  18. package/cli/discovery-snapshot.mjs +8 -15
  19. package/cli/domain-control-monitor.mjs +104 -0
  20. package/cli/domain-control-observations.mjs +235 -0
  21. package/cli/errors.mjs +7 -1
  22. package/cli/evidence-command-arguments.mjs +11 -0
  23. package/cli/evidence-command-runner.mjs +1 -0
  24. package/cli/external-observation-mapping.mjs +162 -0
  25. package/cli/fail-policy.mjs +64 -0
  26. package/cli/formatters/html.mjs +1 -1
  27. package/cli/formatters/json.mjs +12 -4
  28. package/cli/formatters/terminal.mjs +1 -1
  29. package/cli/http.mjs +2 -1
  30. package/cli/investigation-manifest.mjs +113 -0
  31. package/cli/investigation-run.mjs +112 -0
  32. package/cli/lookup-brief.mjs +116 -0
  33. package/cli/lookup-command-runner.mjs +8 -1
  34. package/cli/lookup-diff.mjs +17 -19
  35. package/cli/lookup-reconcile.mjs +1 -1
  36. package/cli/mail-review.mjs +5 -5
  37. package/cli/manual.mjs +1 -1
  38. package/cli/network-command-runner.mjs +4 -1
  39. package/cli/offline-evidence-review.mjs +105 -8
  40. package/cli/open-asset-model-bridge.mjs +169 -0
  41. package/cli/registry-cohort.mjs +121 -0
  42. package/cli/registry-doctor.mjs +106 -11
  43. package/cli/registry-fixture-scaffold.mjs +74 -0
  44. package/cli/runner.mjs +388 -315
  45. package/docs/cli-reference.md +266 -21
  46. package/docs/cli.md +43 -7
  47. package/frontend/src/lib/analysis/acquisition-due-diligence.js +2 -2
  48. package/frontend/src/lib/analysis/brand-profile-model.js +50 -6
  49. package/frontend/src/lib/analysis/case-evidence-model.js +1 -1
  50. package/frontend/src/lib/analysis/case-record-core.js +1 -1
  51. package/frontend/src/lib/analysis/case-record-operations.js +1 -1
  52. package/frontend/src/lib/analysis/case-report.js +573 -0
  53. package/frontend/src/lib/analysis/case-response-model.js +30 -1
  54. package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
  55. package/frontend/src/lib/analysis/external-findings-import.js +320 -0
  56. package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
  57. package/frontend/src/lib/analysis/lookup-decision-support.js +9 -2
  58. package/frontend/src/lib/analysis/lookup-presentation.js +14 -30
  59. package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
  60. package/frontend/src/lib/analysis/scoring.js +1 -1
  61. package/frontend/src/lib/analysis/utils.js +1 -1
  62. package/frontend/src/lib/analysis/visualization-models.js +2 -3
  63. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
  64. package/frontend/src/lib/analysis/workspace-archive.js +5 -7
  65. package/lib/abusech-auth.mjs +1 -1
  66. package/lib/availability.mjs +1 -1
  67. package/lib/bounded-contract-normalizers.mjs +33 -0
  68. package/lib/bounded-file.mjs +69 -0
  69. package/lib/bounded-json-response.mjs +157 -0
  70. package/lib/browser-library-profile.mjs +167 -38
  71. package/lib/dns-convergence-review.mjs +183 -0
  72. package/lib/dns-delegation-health.mjs +196 -3
  73. package/lib/domain-change-packet.mjs +133 -0
  74. package/lib/domain-change-review.mjs +307 -0
  75. package/lib/domain-control-flight-recorder.mjs +268 -0
  76. package/lib/domain-portfolio-review.mjs +151 -0
  77. package/lib/evidence-export.mjs +1 -1
  78. package/lib/html-signals.mjs +5 -11
  79. package/lib/lookup-cache.mjs +5 -1
  80. package/lib/nameserver-preflight-review.mjs +159 -0
  81. package/lib/page-language-signals.mjs +124 -0
  82. package/lib/rdap-transport.mjs +6 -6
  83. package/lib/risk-scoring.mjs +42 -1
  84. package/lib/safe-fetch.mjs +37 -32
  85. package/lib/static-html-analysis.mjs +29 -1
  86. package/lib/threat-intelligence-runtime.mjs +1 -2
  87. package/lib/trust-store-comparison.mjs +146 -0
  88. package/lib/website-technology.mjs +22 -12
  89. package/lib/zone-intent-review.mjs +534 -0
  90. package/package.json +1 -1
package/README.md CHANGED
@@ -68,6 +68,11 @@ node bin/whoisleuth.mts reconcile office.json mobile.json external.json --json
68
68
  node bin/whoisleuth.mts timeline first-observation.json second-observation.json latest-observation.json --json
69
69
  node bin/whoisleuth.mts registry-support example.test --json
70
70
  node bin/whoisleuth.mts registry-doctor lookup.json --json
71
+ node bin/whoisleuth.mts registry-cohort saved-lookups.jsonl --json
72
+ node bin/whoisleuth.mts brief lookup.json
73
+ node bin/whoisleuth.mts case-pack cases.json --audience trusted --reviewed --json
74
+ node bin/whoisleuth.mts monitor-once control-manifest.json --json
75
+ node bin/whoisleuth.mts workflow-run domain-triage example.test --approve-network --json --output run.json
71
76
  node bin/whoisleuth.mts lookalike-calibrate reviewed-candidates.json --json
72
77
  node bin/whoisleuth.mts doctor
73
78
  node bin/whoisleuth.mts commands --json
@@ -85,8 +90,8 @@ and disclosures without making a network request. `commands --json` exposes the
85
90
  installed command contracts for local wrappers without running collection.
86
91
  Focused command help and the generated manual label every operation as offline
87
92
  or networked and state its target, input, and concurrency boundaries. The
88
- packaged [dual-use disclosure](DISCLOSURE) defines the supported defensive use
89
- of those capabilities.
93
+ packaged [dual-use disclosure](https://github.com/slicedearth/whoisleuth/blob/main/DISCLOSURE)
94
+ defines the supported defensive use of those capabilities.
90
95
 
91
96
  Interactive output uses restrained semantic colour, width-aware wrapping, and
92
97
  stderr-only progress for slower collection. Redirected and machine-readable
@@ -96,10 +101,18 @@ offline unless `--network` is explicitly supplied, and completion scripts are
96
101
  printed without modifying shell configuration. Commands can use atomic private
97
102
  `--output` files, Lookup can emit strict automation exits and target-free
98
103
  versioned progress events, and Bulk can resume an exact validated compact
99
- checkpoint. A checkpoint write failure preserves completed output and returns
104
+ checkpoint. Checkpoint schema 2 preserves each current row's observation time;
105
+ legacy schema-1 rows remain readable with unknown observation time rather than
106
+ being restamped on resume. Bulk machine output schema 3 distinguishes current
107
+ and resumed rows. A checkpoint write failure preserves completed output and returns
100
108
  the partial-result exit code. Ctrl-C suppresses partial final output and
101
- returns exit code 130; a second interrupt performs best-effort temporary-file
109
+ returns exit code 130. SIGTERM uses the same graceful cleanup path and returns
110
+ 143; a second interrupt performs best-effort temporary-file
102
111
  cleanup before exiting immediately.
112
+ Strict local profiles can supply only safe presentation, Fast-mode,
113
+ concurrency, and observer defaults; command-line options override them.
114
+ Supported automation commands can emit JUnit or SARIF and apply explicit
115
+ failure policies without changing their collection result.
103
116
 
104
117
  The offline `timeline` command orders 2 to 20 saved Lookup documents for one
105
118
  domain and compares each adjacent observation. It retains observation times and
@@ -114,6 +127,12 @@ non-comparable evidence separate; the labels are context, not proof of
114
127
  independence. `registry-doctor`, `assurance`, `sharing-review`, and
115
128
  `lookalike-calibrate` add offline compatibility, change/recovery, sharing, and
116
129
  review-yield workflows without contacting the hosted application.
130
+ `registry-cohort` aggregates target-free publication-quality counts from saved
131
+ Lookups, while `registry-scaffold` produces a sanitised synthetic contributor
132
+ fixture. `brief` and `case-pack` create bounded local handoffs, `monitor-once`
133
+ runs one explicit control review rather than a daemon, and `workflow-run`
134
+ executes only installed fixed-recipe steps with per-run network approval and
135
+ analyst-selection pauses.
117
136
 
118
137
  Bulk also supports fixed-column CSV, domain-only or exact-query output,
119
138
  registered, inconclusive, and hard-failure output filters, and bounded A, AAAA,
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'node:module';
3
+ import EXIT_CODES from "../cli/exit-codes.mjs";
3
4
  for (const stream of [process.stdout, process.stderr]) {
4
5
  stream.on('error', (error) => {
5
6
  if (error.code === 'EPIPE')
@@ -20,33 +21,54 @@ if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-V')) {
20
21
  }
21
22
  }
22
23
  else {
23
- const [errorsModule, outputModule, runnerModule] = await Promise.all([
24
+ const [configModule, errorsModule, outputModule, runnerModule] = await Promise.all([
25
+ import("../cli/config-profile.mjs"),
24
26
  import("../cli/errors.mjs"),
25
27
  import("../cli/output-file.mjs"),
26
28
  import("../cli/runner.mjs"),
27
29
  ]);
30
+ let resolvedArgv;
31
+ try {
32
+ resolvedArgv = await configModule.resolveCliProfileArguments(argv);
33
+ }
34
+ catch (error) {
35
+ process.stderr.write(`Usage error: ${errorsModule.boundedCliErrorMessage(error, 'Invalid CLI profile')}\n`);
36
+ process.exitCode = EXIT_CODES.USAGE;
37
+ resolvedArgv = [];
38
+ }
28
39
  const cancellation = new AbortController();
29
40
  let interruptionCount = 0;
30
- const interrupt = () => {
41
+ let requestedExitCode = 130;
42
+ const interrupt = (signal) => {
31
43
  interruptionCount += 1;
32
- if (interruptionCount === 1)
44
+ if (interruptionCount === 1) {
45
+ requestedExitCode = signal === 'SIGTERM' ? 143 : 130;
33
46
  cancellation.abort(new DOMException('Aborted', 'AbortError'));
47
+ }
34
48
  else {
35
49
  outputModule.cleanupPendingOutputFilesSync();
36
- process.exit(130);
50
+ process.exit(requestedExitCode);
37
51
  }
38
52
  };
39
- process.on('SIGINT', interrupt);
40
- runnerModule.runCli(argv, { signal: cancellation.signal }).then((code) => {
53
+ const onSigint = () => interrupt('SIGINT');
54
+ const onSigterm = () => interrupt('SIGTERM');
55
+ process.on('SIGINT', onSigint);
56
+ process.on('SIGTERM', onSigterm);
57
+ const execution = resolvedArgv.length
58
+ ? runnerModule.runCli(resolvedArgv, { signal: cancellation.signal })
59
+ : Promise.resolve(process.exitCode || EXIT_CODES.USAGE);
60
+ execution.then((code) => {
41
61
  if (code === 130) {
42
- process.removeListener('SIGINT', interrupt);
43
- process.exit(130);
62
+ process.removeListener('SIGINT', onSigint);
63
+ process.removeListener('SIGTERM', onSigterm);
64
+ process.exit(requestedExitCode);
44
65
  }
45
66
  process.exitCode = code;
46
67
  }).catch((error) => {
47
68
  process.stderr.write(`Internal CLI error: ${errorsModule.boundedCliErrorMessage(error)}\n`);
48
69
  process.exitCode = 70;
49
70
  }).finally(() => {
50
- process.removeListener('SIGINT', interrupt);
71
+ process.removeListener('SIGINT', onSigint);
72
+ process.removeListener('SIGTERM', onSigterm);
51
73
  });
52
74
  }
@@ -3,9 +3,10 @@ import { createHash } from 'node:crypto';
3
3
  import { domainToASCII } from 'node:url';
4
4
  import { decryptWorkspaceArchive, isEncryptedWorkspaceArchive, } from "../frontend/src/lib/analysis/workspace-archive-crypto.js";
5
5
  import { WORKSPACE_ARCHIVE_SCHEMA, readWorkspaceArchive, } from "../frontend/src/lib/analysis/workspace-archive.js";
6
+ import { canonicalArtifactJson } from "../frontend/src/lib/analysis/artifact-integrity.js";
6
7
  import { MAX_OFFLINE_ARTIFACT_BYTES, verifyOfflineArtifact, } from "./artifact-verify.mjs";
7
8
  export const ARCHIVE_INSPECTION_SCHEMA = 'whoisleuth.workspace-archive-inspection';
8
- export const ARCHIVE_INSPECTION_VERSION = 1;
9
+ export const ARCHIVE_INSPECTION_VERSION = 2;
9
10
  export const MAX_ARCHIVE_SEARCH_LENGTH = 253;
10
11
  export const MAX_ARCHIVE_SEARCH_MATCHES = 100;
11
12
  export const MAX_ARCHIVE_SEARCH_NODES = 100_000;
@@ -19,6 +20,7 @@ const SEARCHABLE_FIELDS = new Set([
19
20
  'target',
20
21
  ]);
21
22
  const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
23
+ const SHA256_DIGEST_RE = /^sha256:[a-f0-9]{64}$/u;
22
24
  function record(value) {
23
25
  return value && typeof value === 'object' && !Array.isArray(value)
24
26
  ? value
@@ -66,6 +68,30 @@ function canonicalSearchValue(value) {
66
68
  function valueDigest(value) {
67
69
  return createHash('sha256').update(value, 'utf8').digest('hex');
68
70
  }
71
+ function archiveContentDigest(sections) {
72
+ const identity = sections.map((section) => ({
73
+ id: section.id,
74
+ schema: section.schema,
75
+ version: section.version,
76
+ recordCount: section.recordCount,
77
+ content: (() => {
78
+ if (!section.data || typeof section.data !== 'object' || Array.isArray(section.data)) {
79
+ return section.data;
80
+ }
81
+ const { exportedAt: _exportedAt, generatedAt: _generatedAt, ...content } = section.data;
82
+ return content;
83
+ })(),
84
+ }));
85
+ return `sha256:${createHash('sha256').update(canonicalArtifactJson(identity), 'utf8').digest('hex')}`;
86
+ }
87
+ function expectedContentDigest(value) {
88
+ if (value === null || value === undefined || value === '')
89
+ return null;
90
+ if (typeof value !== 'string' || !SHA256_DIGEST_RE.test(value)) {
91
+ throw new TypeError('Expected archive content digest must use the form sha256 followed by 64 lowercase hexadecimal characters.');
92
+ }
93
+ return value;
94
+ }
69
95
  function searchSection(section, value, search, reveal, results, state) {
70
96
  const stack = [{ value, depth: 0 }];
71
97
  while (stack.length && results.length < MAX_ARCHIVE_SEARCH_MATCHES) {
@@ -120,6 +146,11 @@ export async function inspectWorkspaceArchive(raw, options = {}) {
120
146
  ? await decryptWorkspaceArchive(parsed, options.passphrase)
121
147
  : parsed;
122
148
  const archive = await readWorkspaceArchive(archiveValue);
149
+ const contentDigestSha256 = archiveContentDigest(archive.sections);
150
+ const expectedDigest = expectedContentDigest(options.expectedContentDigest);
151
+ if (expectedDigest && expectedDigest !== contentDigestSha256) {
152
+ throw new TypeError('Workspace archive content digest did not match the expected value.');
153
+ }
123
154
  const search = normalizeSearch(options.search);
124
155
  const reveal = options.reveal === true;
125
156
  if (reveal && !search)
@@ -143,12 +174,14 @@ export async function inspectWorkspaceArchive(raw, options = {}) {
143
174
  archive: Object.freeze({
144
175
  encrypted,
145
176
  schema: WORKSPACE_ARCHIVE_SCHEMA,
146
- version: archive.version,
177
+ version: archive.sourceVersion,
178
+ readerVersion: archive.version,
147
179
  }),
148
180
  summary: Object.freeze({
149
181
  inputBytes: Buffer.byteLength(raw, 'utf8'),
150
182
  sectionCount: archive.sections.length,
151
183
  recordCount: archive.sections.reduce((sum, section) => sum + section.recordCount, 0),
184
+ contentDigestSha256,
152
185
  }),
153
186
  sections: Object.freeze(archive.sections.map((section) => Object.freeze({
154
187
  id: section.id,
@@ -169,6 +202,7 @@ export async function inspectWorkspaceArchive(raw, options = {}) {
169
202
  }),
170
203
  limitations: Object.freeze([
171
204
  'Inspection validates the archive and reports bounded section metadata without printing retained evidence by default.',
205
+ 'The content digest covers ordered section identities and normalised content after excluding each section export timestamp. Equality detects matching retained archive content but does not authenticate its source or accuracy.',
172
206
  'Search checks exact values in a small allowlist of target fields and never searches analyst notes, contacts, or raw evidence.',
173
207
  ...(reveal
174
208
  ? ['Search values were revealed only because the operator supplied --reveal; handle the output as sensitive evidence.']
@@ -182,6 +216,7 @@ export function formatArchiveInspection(report) {
182
216
  `Archive: ${report.archive.encrypted ? 'encrypted' : 'plain'} · v${report.archive.version}`,
183
217
  `Sections: ${report.summary.sectionCount}`,
184
218
  `Records: ${report.summary.recordCount}`,
219
+ `Content digest: ${report.summary.contentDigestSha256}`,
185
220
  ];
186
221
  for (const section of report.sections) {
187
222
  lines.push(`${section.label}: ${section.recordCount} records · ${section.status} · ${section.bytes} bytes`);