@slicedearth/whoisleuth-cli 1.32.2 → 1.36.1

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 (73) hide show
  1. package/README.md +43 -5
  2. package/SECURITY.md +35 -0
  3. package/cli/arguments.mjs +180 -8
  4. package/cli/artifact-verify.mjs +32 -0
  5. package/cli/bulk-command-runner.mjs +3 -1
  6. package/cli/bulk-output.mjs +6 -1
  7. package/cli/bulk.mjs +1 -2
  8. package/cli/command-catalogue.mjs +33 -0
  9. package/cli/completion.mjs +70 -7
  10. package/cli/doctor.mjs +78 -43
  11. package/cli/export-evidence.mjs +9 -2
  12. package/cli/formatters/html.mjs +1 -1
  13. package/cli/formatters/terminal.mjs +13 -7
  14. package/cli/investigation-plan.mjs +112 -0
  15. package/cli/lookup-command-runner.mjs +8 -0
  16. package/cli/lookup-diff.mjs +16 -5
  17. package/cli/lookup-plan.mjs +99 -0
  18. package/cli/lookup-timeline.mjs +123 -0
  19. package/cli/mail-review.mjs +47 -2
  20. package/cli/manual.mjs +2 -1
  21. package/cli/offline-evidence-review.mjs +141 -0
  22. package/cli/registry-support.mjs +1 -4
  23. package/cli/runner.mjs +214 -11
  24. package/docs/cli.md +200 -45
  25. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -6
  26. package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
  27. package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
  28. package/frontend/src/lib/analysis/http-summary.js +2 -1
  29. package/frontend/src/lib/analysis/utils.js +7 -40
  30. package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
  31. package/lib/availability.mjs +30 -10
  32. package/lib/bulk-limits.mjs +13 -0
  33. package/lib/csv.mjs +1 -1
  34. package/lib/ct-response-bounds.mjs +9 -0
  35. package/lib/ct-search.mjs +66 -12
  36. package/lib/dns-delegation-health.mjs +56 -5
  37. package/lib/dnssec-evidence-validation.mjs +235 -0
  38. package/lib/domain-control-manifest.mjs +339 -0
  39. package/lib/domain-posture.mjs +2 -1
  40. package/lib/encrypted-dns-contract.mjs +81 -0
  41. package/lib/evidence-export.mjs +17 -3
  42. package/lib/evidence-report-markdown.mjs +3 -3
  43. package/lib/evidence-report.mjs +3 -0
  44. package/lib/favicon.mjs +3 -8
  45. package/lib/html-signals.mjs +4 -0
  46. package/lib/http-evidence-bounds.mjs +4 -0
  47. package/lib/http-intelligence.mjs +2 -1
  48. package/lib/ip-prefix.mjs +54 -0
  49. package/lib/local-geoip-evidence.mjs +89 -0
  50. package/lib/outbound-identity.mjs +9 -0
  51. package/lib/outbound-request-bounds.mjs +9 -0
  52. package/lib/perceptual-hash-comparison.mjs +34 -0
  53. package/lib/perceptual-hash.mjs +3 -31
  54. package/lib/project-metadata.mjs +8 -0
  55. package/lib/provider-policy-admission.mjs +49 -0
  56. package/lib/rdap-search-workbench.mjs +157 -0
  57. package/lib/rdap.mjs +7 -2
  58. package/lib/registry-capabilities.mjs +31 -1
  59. package/lib/registry-capability-catalogue.mjs +11 -10
  60. package/lib/response-policy.mjs +81 -6
  61. package/lib/rpki-evidence.mjs +105 -0
  62. package/lib/safe-fetch.mjs +2 -1
  63. package/lib/semantic-version.mjs +54 -0
  64. package/lib/static-html-analysis.mjs +27 -0
  65. package/lib/threatfox-intelligence.mjs +10 -5
  66. package/lib/tlsa-evidence.mjs +194 -0
  67. package/lib/urlhaus-intelligence.mjs +10 -5
  68. package/lib/urlscan-intelligence.mjs +10 -5
  69. package/lib/website-security-posture.mjs +11 -4
  70. package/lib/whois-chain.mjs +14 -152
  71. package/lib/whois.mjs +2 -2
  72. package/package.json +7 -5
  73. package/third-party-notices.txt +2 -2
package/docs/cli.md CHANGED
@@ -8,7 +8,9 @@ hosted WHOISleuth deployment.
8
8
  source location. It groups commands by investigation, discovery, saved-evidence,
9
9
  integrity, and calibration workflows. `whoisleuth <command> --help` displays the
10
10
  command's purpose, focused invocation, example, and collection boundary without
11
- printing the full command list. Packaged copies include `LICENSE`, `NOTICE`, and
11
+ printing the full command list. `whoisleuth commands --json` exposes those same
12
+ contracts as a stable local catalogue for wrappers and terminal tooling.
13
+ Packaged copies include `LICENSE`, `NOTICE`, and
12
14
  `TRADEMARKS.md` alongside the CLI documentation.
13
15
 
14
16
  ## Installation
@@ -17,11 +19,18 @@ Public releases require Node.js 24 or later and install the `whoisleuth`
17
19
  command:
18
20
 
19
21
  ```bash
20
- npm exec --yes --package=@slicedearth/whoisleuth-cli -- whoisleuth --help
21
- npm install --global @slicedearth/whoisleuth-cli
22
+ npm exec --yes --ignore-scripts --package=@slicedearth/whoisleuth-cli -- whoisleuth --help
23
+ npm install --global --ignore-scripts @slicedearth/whoisleuth-cli
22
24
  whoisleuth doctor
23
25
  ```
24
26
 
27
+ Update and verify an existing global installation with:
28
+
29
+ ```bash
30
+ npm install --global --ignore-scripts @slicedearth/whoisleuth-cli@latest
31
+ whoisleuth --version
32
+ ```
33
+
25
34
  The scoped package and application share one semantic version. The package runs
26
35
  locally and does not call the hosted WHOISleuth deployment. Networked commands
27
36
  contact only the sources described by their collection profile; offline review,
@@ -34,6 +43,7 @@ node bin/whoisleuth.mts lookup example.com
34
43
  node bin/whoisleuth.mts lookup AS13335 --json
35
44
  printf 'example.com\n' | node bin/whoisleuth.mts lookup --json
36
45
  node bin/whoisleuth.mts lookup example.com --deep
46
+ node bin/whoisleuth.mts lookup example.com --deep --plan --json
37
47
  node bin/whoisleuth.mts lookup example.com --deep --summary
38
48
  node bin/whoisleuth.mts lookup example.com --deep --verbose
39
49
  node bin/whoisleuth.mts lookup example.com --deep --markdown --output lookup.md
@@ -41,6 +51,7 @@ node bin/whoisleuth.mts lookup example.com --deep --json --strict-exit --events
41
51
  cat domains.txt | node bin/whoisleuth.mts bulk --jsonl
42
52
  node bin/whoisleuth.mts bulk domains.txt --csv --registered-only
43
53
  node bin/whoisleuth.mts bulk domains.txt --domains --inconclusive-only
54
+ node bin/whoisleuth.mts bulk domains.txt --queries --errors-only
44
55
  node bin/whoisleuth.mts bulk domains.txt --concurrency 4
45
56
  node bin/whoisleuth.mts bulk domains.txt --deep --checkpoint bulk-checkpoint.json
46
57
  node bin/whoisleuth.mts bulk domains.txt --deep --checkpoint bulk-checkpoint.json --resume
@@ -55,6 +66,7 @@ node bin/whoisleuth.mts tls example.com --json
55
66
  node bin/whoisleuth.mts registry-support example.uk --json
56
67
  node bin/whoisleuth.mts risk-calibrate calibration.json --json
57
68
  node bin/whoisleuth.mts verify-artifact workspace.json --json
69
+ node bin/whoisleuth.mts verify-artifact lookup.json --json
58
70
  node bin/whoisleuth.mts verify-artifact workspace-encrypted.json --passphrase-file passphrase.txt --json
59
71
  node bin/whoisleuth.mts source-report lookup.json --json
60
72
  node bin/whoisleuth.mts inspect-archive workspace.json --json
@@ -65,13 +77,19 @@ node bin/whoisleuth.mts lookup example.com --deep --json > lookup.json
65
77
  node bin/whoisleuth.mts compare lookup.json --json
66
78
  node bin/whoisleuth.mts page-compare official.json candidate.json --json
67
79
  node bin/whoisleuth.mts mail-review bulk.json --json
80
+ node bin/whoisleuth.mts review-evidence dnssec-evidence.json --json
81
+ node bin/whoisleuth.mts domain-control domain-control-input.json --json
82
+ node bin/whoisleuth.mts workflow-plan domain-triage example.test --json
68
83
  node bin/whoisleuth.mts diff first-lookup.json second-lookup.json --json
84
+ node bin/whoisleuth.mts timeline first-observation.json second-observation.json latest-observation.json --json
69
85
  node bin/whoisleuth.mts export lookup.json > evidence.json
70
86
  node bin/whoisleuth.mts export lookup.json --markdown > evidence.md
71
87
  node bin/whoisleuth.mts export lookup.json --html > evidence.html
72
88
  node bin/whoisleuth.mts doctor
73
89
  node bin/whoisleuth.mts doctor --network --json
90
+ node bin/whoisleuth.mts commands --json
74
91
  node bin/whoisleuth.mts completion zsh
92
+ node bin/whoisleuth.mts completion powershell
75
93
  node bin/whoisleuth.mts manual | man -l -
76
94
  ```
77
95
 
@@ -87,10 +105,13 @@ npm run cli:package:check
87
105
 
88
106
  The check includes only the executable, reachable TypeScript modules, CLI
89
107
  guide, licence, notices, and trademark terms. It excludes application routes,
90
- deployment adapters, tests, and development tools. Its generated manifest stays
91
- private. Release-candidate assembly is a separate explicit operation and never
92
- publishes from a developer checkout. Use the scoped package name; an unqualified
93
- `npx whoisleuth` is not this project.
108
+ deployment adapters, tests, and development tools. Direct runtime dependencies
109
+ are pinned to the exact versions exercised by the reviewed repository lockfile.
110
+ The package and its dependencies require no lifecycle scripts, so the install
111
+ examples disable them explicitly.
112
+ Its generated manifest stays private. Release-candidate assembly is a separate
113
+ explicit operation and never publishes from a developer checkout. Use the
114
+ scoped package name; an unqualified `npx whoisleuth` is not this project.
94
115
 
95
116
  Lookup defaults to the conservative fast profile. `--deep` must be requested
96
117
  explicitly and can add bounded WHOIS, DNS, website, TLS, registrar RDAP, and
@@ -104,6 +125,14 @@ up to five normalized PTR names; JSON retains up to eight. PTR names are
104
125
  operator-published routing context, not proof of hosting control or ownership.
105
126
  Fast lookups do not run this query.
106
127
 
128
+ `lookup --plan` performs classification and prints a versioned preflight without
129
+ starting collection. It lists the normalized target, selected fast or deep
130
+ mode, planned source families, conditional operations, and the data each family
131
+ may receive. The plan cannot predict live feature configuration, cache state,
132
+ redirects, referrals, source availability, or an exact request count. It can be
133
+ combined with `--json`, but not with report, detail, strict-exit, progress-event,
134
+ or quiet options.
135
+
107
136
  Only one query is accepted by `lookup`. Multiple-input processing belongs to
108
137
  the explicit `bulk` command rather than being silently inferred by `lookup`.
109
138
  Standard input is capped at 4 KiB and must contain one non-empty line.
@@ -114,32 +143,49 @@ request. Its output is described below.
114
143
 
115
144
  `doctor` is offline by default. It checks the Node runtime, platform, terminal
116
145
  capabilities, and availability of offline command families. `doctor --network`
117
- adds bounded public-DNS and WHOIS port 43 checks against fixed diagnostic
118
- infrastructure. It retains only state and a bounded explanation, not resolved
119
- addresses or response content. A failed optional network check returns the
120
- partial-failure exit code rather than claiming that all CLI collection is
121
- unavailable.
122
-
123
- `completion bash`, `completion zsh`, and `completion fish` print static shell
124
- completion scripts to stdout. The command does not edit a shell profile or make
125
- a network request. Review the generated script before sourcing it or placing it
126
- in the relevant shell completion directory. `manual` prints a generated roff
127
- manual page and likewise changes no local configuration.
146
+ adds independent bounded public-DNS, HTTPS/RDAP bootstrap, and WHOIS port 43
147
+ checks against fixed IANA diagnostic infrastructure. The three probes run
148
+ within the same per-probe ceiling so one stalled transport does not block the
149
+ others. The report retains only state, HTTP status, and a bounded explanation,
150
+ not resolved addresses or response content. A failed optional network check
151
+ returns the partial-failure exit code rather than claiming that all CLI
152
+ collection is unavailable.
153
+
154
+ `completion bash`, `completion zsh`, `completion fish`, and `completion
155
+ powershell` print static shell completion scripts to stdout. The command does
156
+ not edit a shell profile or make a network request. Review the generated script
157
+ before sourcing it or placing it in the relevant shell completion directory.
158
+ `manual` prints a generated roff manual page and likewise changes no local
159
+ configuration.
160
+
161
+ `commands` prints the installed command catalogue without running collection or
162
+ reading evidence. `commands --json` emits version 1 of the
163
+ `whoisleuth.cli.command-catalogue` schema, including each command's usage,
164
+ example, declared collection mode, collection scope, and boundary. This is the
165
+ supported discovery contract for local wrappers; it is not a permission grant
166
+ or a substitute for focused help.
167
+
168
+ Focused command help and the generated manual classify every operation as
169
+ offline or networked and state the relevant target, input, and concurrency
170
+ ceiling. These labels describe observable behavior rather than granting
171
+ authorization. The packaged `DISCLOSURE` defines the intended defensive scope;
172
+ operators remain responsible for law, provider terms, and authorization.
128
173
 
129
174
  ## Deployment boundary
130
175
 
131
- The CLI is a local repository tool. The serverless deployment publishes only
132
- `frontend/build` and packages functions only from `netlify/functions`, so
133
- `bin/` and `cli/` are not part of the hosted static site or function bundle.
134
- Both the application root and the assembled CLI package remain private and are
135
- not published to the public npm registry.
176
+ The CLI runs locally. The serverless deployment publishes only `frontend/build`
177
+ and packages functions only from `netlify/functions`, so `bin/` and `cli/` are
178
+ not part of the hosted static site or function bundle. The application root and
179
+ ordinary package-check candidate remain private. Explicitly reviewed CLI
180
+ release candidates can be published under the scoped public package described
181
+ in the installation section.
136
182
 
137
183
  Commands that query RDAP, WHOIS, DNS, HTTP, TLS, or Certificate Transparency do
138
184
  so directly from the machine running the CLI. They do not use the hosted login,
139
185
  hosted session, or deployment usage controls; upstream providers can see and
140
186
  rate-limit the local machine's network address. Offline `discover`, `compare`,
141
- `page-compare`, `mail-review`, `diff`, `risk-calibrate`, `verify-artifact`, `source-report`, `export`,
142
- `completion`, and `manual` operations make no network requests. Commands write
187
+ `page-compare`, `mail-review`, `review-evidence`, `domain-control`, `workflow-plan`, `diff`, `timeline`, `risk-calibrate`, `verify-artifact`, `source-report`, `export`,
188
+ `commands`, `completion`, and `manual` operations make no network requests. Commands write
143
189
  to stdout unless the analyst deliberately selects a local output file.
144
190
 
145
191
  ## Output
@@ -266,6 +312,15 @@ registration, DNS, page identity, mail, certificate, and relationship fields,
266
312
  keeps missing and unavailable evidence distinct, and makes no ownership or
267
313
  maliciousness inference. The output records both original observation times.
268
314
 
315
+ `timeline <observation.json> <observation.json> [...]` accepts from 2 to 20
316
+ saved Lookup documents for one domain, orders them by their validated
317
+ observation times, and compares each adjacent pair offline. The 32 MiB aggregate
318
+ input ceiling is enforced while files are read and again by the timeline
319
+ builder. Output retains normalized field states and observation times but no
320
+ input filenames or raw registry payloads. A difference can reflect a domain
321
+ change or changed collection conditions, so the command makes no current-state,
322
+ ownership, intent, safety, or maliciousness conclusion.
323
+
269
324
  When diagnostics version 5, 6, 7, or 8 reports a documented registry collection
270
325
  constraint, terminal output also shows the suffix, WHOIS and RDAP access
271
326
  profiles, and the bounded limitation. This is static access-policy context: it
@@ -286,7 +341,7 @@ machine access is not evidence that a domain is unregistered or safe.
286
341
  This release supports `lookup`, `bulk`, `ct-search`, `discover`, `discover-scan`, `posture`,
287
342
  `http`, `tls`, `registry-support`, `risk-calibrate`, `verify-artifact`,
288
343
  `inspect-archive`, `sign-artifact`, `verify-signature`, `source-report`,
289
- `compare`, `page-compare`, `mail-review`, `diff`, `export`, `doctor`, `completion`, and `manual`. Additional command families
344
+ `compare`, `page-compare`, `mail-review`, `review-evidence`, `domain-control`, `workflow-plan`, `diff`, `timeline`, `export`, `doctor`, `commands`, `completion`, and `manual`. Additional command families
290
345
  are added as separate bounded increments rather than exposing incomplete
291
346
  aliases.
292
347
 
@@ -296,13 +351,13 @@ aliases.
296
351
  catalogue for one domain or suffix. It distinguishes an explicit
297
352
  fixture-backed or access-documented suffix profile from the generic IANA
298
353
  discovery profile and reports the RDAP and WHOIS discovery/access paths,
299
- WHOIS query and parser profiles, any bounded fallback, fixture scenarios,
354
+ WHOIS query and parser profiles, fixture scenarios,
300
355
  verification references, documentation references, and the catalogue's
301
356
  limitation. The same document includes a dated, official-source aggregate of
302
357
  generic, generic-restricted, sponsored, and infrastructure RDAP coverage. That
303
358
  snapshot is separate from the suffix profile and from live reachability.
304
359
 
305
- Terminal output is bounded and control-safe. `--json` emits version 2 of the
360
+ Terminal output is bounded and control-safe. `--json` emits version 3 of the
306
361
  `whoisleuth.cli.registry-support` schema. Unknown but syntactically valid
307
362
  suffixes retain the generic `discovery_only` profile; malformed input exits
308
363
  with code 2. The command never probes a registry or tests current reachability.
@@ -385,7 +440,11 @@ Risk model.
385
440
  evidence contents. Input is capped at 15 MiB. It currently recognises ordinary
386
441
  and encrypted workspace archives, case-response packets, acquisition-decision
387
442
  exports, domain-comparison exports, Bulk mail-exposure exports, and Bulk review
388
- manifests.
443
+ manifests. It also validates the bounded versioned structure of saved CLI
444
+ Lookup JSON. Because saved Lookup documents do not embed a checksum or
445
+ signature, that result is reported as `structure_valid` with content integrity
446
+ explicitly unchecked. Saved Lookup validation retains its stricter 8 MiB
447
+ document ceiling.
389
448
 
390
449
  For an ordinary workspace archive, the command validates the versioned
391
450
  structure, section byte counts, record counts, and per-section checksums. For a
@@ -469,19 +528,25 @@ deep mode.
469
528
 
470
529
  Bulk uses the shared compact lookup response, so it does not retain raw RDAP
471
530
  objects or WHOIS response bodies. `--json` returns one bounded collection;
472
- `--jsonl` emits one self-contained versioned item per line. Version 2 adds a
531
+ `--jsonl` emits one self-contained versioned item per line. `--domains` emits
532
+ only successfully normalized domain names, while `--queries` preserves the
533
+ selected bounded input queries and is suitable for an exact retry queue.
534
+ Version 2 adds a
473
535
  bounded `dnsSummary` projection for observed A, AAAA, NS, and MX records plus
474
536
  null MX, SPF, and DMARC state. `--csv` writes fixed columns for automation,
475
- including the DNS summaries and explicit outcome; `--domains` writes only the
476
- normalized retained domain names for a subsequent command.
477
-
478
- `--registered-only` and `--inconclusive-only` are mutually exclusive output
479
- filters. The first retains registered, for-sale, and expiring authority-aware
480
- states. The second retains unknown authority states and failed rows. Filtering
481
- does not change collection and never converts an unavailable or failed source
482
- into an unregistered result. Machine documents record collected and emitted
483
- counts. A mixture of successful and failed queries exits with code 4 while
484
- preserving every collected result before output filtering.
537
+ including the DNS summaries and explicit outcome.
538
+
539
+ `--registered-only`, `--inconclusive-only`, and `--errors-only` are mutually
540
+ exclusive output filters. The first retains registered, for-sale, and expiring
541
+ authority-aware states. The second retains unknown authority states and failed
542
+ rows. The third retains only rows whose lookup operation failed, which is useful
543
+ for producing an exact retry queue without treating an inconclusive successful
544
+ lookup as an operational error. Filtering does not change collection and never
545
+ converts an unavailable or failed source into an unregistered result. Machine
546
+ documents record collected and emitted counts. A mixture of successful and
547
+ failed queries exits with code 4 while preserving every collected result before
548
+ output filtering. For example, `bulk domains.txt --queries --errors-only`
549
+ produces only the exact failed inputs while retaining the partial-failure exit.
485
550
 
486
551
  ## Certificate Transparency search
487
552
 
@@ -492,11 +557,15 @@ does not call the hosted WHOISleuth deployment. Quote a multi-word keyword so
492
557
  the shell passes it as one argument.
493
558
 
494
559
  Terminal output summarizes certificate rows, observed hostnames, canonical
495
- registrable-domain matches, observation times, and completeness. It shows at
496
- most 100 matches and five hostnames per match, with explicit omission notes.
497
- `--json` returns the complete bounded structured result in the versioned
498
- `whoisleuth.cli.ct-search` schema. CT observations do not prove that a website
499
- is active or malicious.
560
+ registrable-domain matches, observation times, bounded certificate-issuance
561
+ groups, and completeness. An issuance group contains names observed together
562
+ in one public certificate record; a cross-domain group is a review lead, not
563
+ an attribution or ownership finding. Terminal output shows at most 100 matches
564
+ and five hostnames per match, with explicit omission notes. `--json` returns
565
+ the complete bounded structured result in the versioned
566
+ `whoisleuth.cli.ct-search` schema. The certificate-group cap is reported
567
+ separately from the registrable-domain result cap. CT observations do not prove
568
+ that a website is active or malicious.
500
569
 
501
570
  ## Lookalike discovery
502
571
 
@@ -729,6 +798,92 @@ The command makes no DNS or SMTP request and retains no source path. It does
729
798
  not test message acceptance, relay behavior, mailbox existence, catch-all
730
799
  behavior, SMTP banners, or whether a mail server is rogue, safe, or malicious.
731
800
 
801
+ An analyst may add a bounded `tlsaEvidence` object to a version-2 Bulk item for
802
+ offline DANE review. It must name the exact `_25._tcp.<mx-host>` service, and
803
+ that host must occur in the same item's retained MX evidence. A certificate
804
+ association match is complete only when `dnssecState` is `validated`. TLSA
805
+ usages 0 and 1 additionally require `pkixValidationState: "validated"`.
806
+ Usages 0 and 2 compare against the bounded `authorityMaterials` array; usages
807
+ 1 and 3 compare against the supplied leaf certificate or SPKI. Mismatched
808
+ service names, missing certificate roles, unvalidated prerequisites, malformed
809
+ material, and truncation remain invalid, partial, or untrusted rather than
810
+ becoming a DANE match.
811
+
812
+ ## Offline supplied-evidence review
813
+
814
+ `review-evidence [evidence.json]` accepts one version-1 JSON input and performs
815
+ only deterministic local parsing, comparison, or request planning. Input is
816
+ capped at 16 MiB. The command never retrieves DNS, opens SMTP, contacts an
817
+ RDAP server, submits a resolver query, or looks up an address in a hosted
818
+ GeoIP service.
819
+
820
+ Supported input schemas are:
821
+
822
+ - `whoisleuth.dnssec-evidence-input`: compares bounded DS and DNSKEY records,
823
+ recalculates DNSKEY key tags and supported DS digests, and reviews supplied
824
+ RRSIG validity windows at `observedAt`. `consistent` describes only the
825
+ supplied DS/DNSKEY relationship. It does not authenticate a chain to a root
826
+ trust anchor, validate RRSIG cryptography, or prove that a missing record is
827
+ absent.
828
+ - `whoisleuth.tlsa-evidence-input`: binds TLSA records to one explicit
829
+ `_port._transport.hostname` service and compares supplied leaf or authority
830
+ certificate/SPKI bytes. `matched` requires independently validated DNSSEC;
831
+ PKIX-TA and PKIX-EE usages also require an independently validated PKIX path.
832
+ The command does not retrieve the certificate or negotiate STARTTLS.
833
+ - `whoisleuth.rdap-search-input`: normalizes a supplied RDAP search-help
834
+ response and prepares an exact supported reverse-search request without
835
+ sending it.
836
+ - `whoisleuth.rpki-route-input`: compares a route prefix and origin ASN with a
837
+ bounded analyst-supplied VRP set.
838
+ - `whoisleuth.local-geoip-query`: queries an analyst-supplied bounded prefix
839
+ database whose source, version, and licence metadata remain in the result.
840
+ - `whoisleuth.encrypted-dns-plan-input`: validates an explicitly reviewed
841
+ encrypted-DNS provider contract and prepares a bounded query plan. It does
842
+ not execute the plan.
843
+
844
+ The common output is `whoisleuth.cli.offline-evidence-review` version 1. It
845
+ retains the nested result's explicit state and limitations. A locally
846
+ consistent relationship is not converted into a claim about current
847
+ publication, ownership, safety, or maliciousness.
848
+
849
+ ## Domain control manifests
850
+
851
+ `domain-control [input.json]` builds or reviews a bounded desired-state
852
+ manifest without making a request or changing configuration. A manifest input
853
+ uses `whoisleuth.domain-control-manifest-input` version 1, includes an expiry,
854
+ and may record expected nameservers, DS, MX, CAA, TLS issuer, TLS SPKI
855
+ fingerprint, registrar-lock preference, and a renewal-review date for up to 100
856
+ domains. Empty fields mean unconfigured; they do not require a record to be
857
+ absent.
858
+
859
+ The emitted `whoisleuth.domain-control-manifest` version 1 is normalized and
860
+ protected by a canonical SHA-256 digest. It can also be passed to
861
+ `sign-artifact` for an optional local Ed25519 signature. Integrity and
862
+ signature verification detect changes and authenticate possession of the
863
+ selected key; neither establishes that the desired state is correct.
864
+
865
+ A review input uses `whoisleuth.domain-control-review-input` version 1 and
866
+ contains one manifest plus separately attributed observations. Only a complete
867
+ `observed` field may produce `drift`. Partial, unavailable, unsupported, and
868
+ missing observations remain inconclusive, and unrelated observations are
869
+ counted but ignored. The review performs no DNS, RDAP, HTTP, TLS, SMTP, or
870
+ registrar request.
871
+
872
+ ## Fixed investigation plans
873
+
874
+ `workflow-plan <recipe> <domain|brand>` turns one of four fixed recipes into a
875
+ versioned `whoisleuth.cli.investigation-plan` document. Supported recipes are
876
+ `domain-triage`, `lookalike-review`, `owned-domain-review`, and
877
+ `historical-comparison`. Each step names an existing CLI command, carries its
878
+ arguments as a JSON array, declares whether it is offline or networked, and
879
+ marks network-disclosure or analyst-selection approval gates.
880
+
881
+ The command is plan-only. It does not execute a step, construct a shell script,
882
+ interpret a placeholder as a file path, read an artifact, make a request,
883
+ change a case, or submit evidence. Analysts deliberately run selected commands
884
+ after reviewing their collection boundaries. This provides repeatable
885
+ domain-specific workflows without an arbitrary automation or plugin surface.
886
+
732
887
  ## Optional local rendered capture
733
888
 
734
889
  `packages/web-capture` is a private repo-local Playwright package, not part of
@@ -151,7 +151,7 @@ function freshness(observedAt, now) {
151
151
  export function buildBulkDomainComparison(leftRaw, rightRaw, observedAtRaw = null, options = {}) {
152
152
  const left = normalizeBulkSessionResult(leftRaw);
153
153
  const right = normalizeBulkSessionResult(rightRaw);
154
- if (!left || !right || left.domain === right.domain)
154
+ if (!left || !right || (left.domain === right.domain && !options.allowSameDomain))
155
155
  return null;
156
156
  const observedAt = timestamp(observedAtRaw);
157
157
  const registry = context(left, right, 'Registry and registrar evidence', ['rdap', 'availability'], observedAt, options);
@@ -214,11 +214,17 @@ export function buildBulkDomainComparison(leftRaw, rightRaw, observedAtRaw = nul
214
214
  freshness: freshness(observedAt, options.now ?? Date.now()),
215
215
  rows,
216
216
  counts,
217
- limitations: [
218
- 'This compares compact settled evidence already present in Bulk and makes no new request.',
219
- 'Missing, unavailable, and differently collected evidence remain distinct from an observed difference.',
220
- 'Equality does not establish common ownership, infrastructure control, intent, safety, or maliciousness.',
221
- ],
217
+ limitations: options.allowSameDomain
218
+ ? [
219
+ 'This compares normalized evidence already present in two saved observations for the same domain and makes no new request.',
220
+ 'Missing, unavailable, and differently collected evidence remain distinct from an observed difference.',
221
+ 'An observed difference can reflect changed collection conditions and does not by itself establish current state, ownership, intent, safety, or maliciousness.',
222
+ ]
223
+ : [
224
+ 'This compares compact settled evidence already present in Bulk and makes no new request.',
225
+ 'Missing, unavailable, and differently collected evidence remain distinct from an observed difference.',
226
+ 'Equality does not establish common ownership, infrastructure control, intent, safety, or maliciousness.',
227
+ ],
222
228
  };
223
229
  }
224
230
  export async function buildBulkDomainComparisonExport(comparisonRaw, generatedAtRaw = new Date().toISOString()) {
@@ -1,13 +1,7 @@
1
1
  import { normalizeBulkSessionResult, } from "./bulk-session-model.js";
2
+ import { limitedBulkSources } from "./bulk-source-coverage.js";
2
3
  export const MAX_BULK_RETRY_ROWS = 200;
3
4
  export const BULK_REVIEW_STALE_AFTER_DAYS = 7;
4
- const LIMITED_STATES = new Set([
5
- 'error',
6
- 'partial',
7
- 'skipped',
8
- 'unavailable',
9
- 'unsupported',
10
- ]);
11
5
  function freshness(value, now = Date.now()) {
12
6
  if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) {
13
7
  return { observedAt: null, ageDays: null, state: 'unknown' };
@@ -21,9 +15,7 @@ function freshness(value, now = Date.now()) {
21
15
  };
22
16
  }
23
17
  function retryRow(value, isStale) {
24
- const limitedSources = value.sourceCoverage
25
- .filter((item) => LIMITED_STATES.has(item.state))
26
- .map((item) => item.source)
18
+ const limitedSources = limitedBulkSources(value.domain, value.sourceCoverage)
27
19
  .sort()
28
20
  .slice(0, 12);
29
21
  const reasons = [];
@@ -56,6 +48,7 @@ export function buildBulkRetryPlan(values, mode, observedAtRaw = null, now = Dat
56
48
  'This repeats the selected lookup profile; the current API does not request only one upstream source.',
57
49
  `Evidence is marked stale after ${BULK_REVIEW_STALE_AFTER_DAYS} days for review purposes; this is a local observation age, not an upstream record age.`,
58
50
  'Optional third-party intelligence providers are not contacted by Bulk.',
51
+ 'Sources deliberately skipped by the selected scan profile or deployment policy do not independently trigger a retry.',
59
52
  'A retry can remain partial or fail and does not convert unavailable evidence into absence.',
60
53
  ],
61
54
  };
@@ -0,0 +1,76 @@
1
+ // Authority-aware interpretation for compact Bulk source observations. Raw
2
+ // source states remain unchanged; this layer only decides whether an observed
3
+ // limitation was unexpected for the registry's published service paths.
4
+ import { registryCapabilityFor } from "../../../../lib/registry-capabilities.mjs";
5
+ const MAX_SOURCE_OBSERVATIONS = 32;
6
+ const MAX_SOURCE_TEXT_LENGTH = 40;
7
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
8
+ const LIMITED_SOURCE_STATES = new Set([
9
+ 'error',
10
+ 'failed',
11
+ 'inconclusive',
12
+ 'partial',
13
+ 'rate_limited',
14
+ 'unavailable',
15
+ 'unsupported',
16
+ ]);
17
+ function text(value) {
18
+ return typeof value === 'string'
19
+ && value.length <= MAX_SOURCE_TEXT_LENGTH
20
+ && !CONTROL_RE.test(value)
21
+ ? value.trim().toLowerCase()
22
+ : '';
23
+ }
24
+ function record(value) {
25
+ return value && typeof value === 'object' && !Array.isArray(value)
26
+ ? value
27
+ : null;
28
+ }
29
+ function observations(value) {
30
+ return Array.isArray(value)
31
+ ? value.slice(0, MAX_SOURCE_OBSERVATIONS).flatMap((entry) => {
32
+ const item = record(entry);
33
+ const source = text(item?.source);
34
+ const state = text(item?.state);
35
+ return source && state ? [{ source, state }] : [];
36
+ })
37
+ : [];
38
+ }
39
+ function expectedUnsupportedSources(domain) {
40
+ const capability = registryCapabilityFor(domain);
41
+ if (!capability)
42
+ return new Set();
43
+ const expected = new Set();
44
+ if (capability.rdapAccessProfile === 'no-iana-service')
45
+ expected.add('rdap');
46
+ if (capability.whoisAccessProfile === 'no-iana-service')
47
+ expected.add('whois');
48
+ return expected;
49
+ }
50
+ export function isExpectedUnsupportedBulkSource(domain, observation) {
51
+ const source = text(observation.source);
52
+ const state = text(observation.state);
53
+ return state === 'unsupported' && expectedUnsupportedSources(domain).has(source);
54
+ }
55
+ export function limitedBulkSources(domain, coverage) {
56
+ const expectedUnsupported = expectedUnsupportedSources(domain);
57
+ return observations(coverage)
58
+ .filter(({ source, state }) => (LIMITED_SOURCE_STATES.has(state)
59
+ && !(state === 'unsupported' && expectedUnsupported.has(source))))
60
+ .map(({ source }) => source);
61
+ }
62
+ export function classifyBulkSourceCoverage(domain, coverage) {
63
+ const retained = observations(coverage);
64
+ if (!retained.length)
65
+ return 'unrecorded';
66
+ return limitedBulkSources(domain, retained).length ? 'limited' : 'complete';
67
+ }
68
+ export function describeBulkSourceCoverage(domain, observation) {
69
+ const source = text(observation.source);
70
+ const state = text(observation.state);
71
+ if (!source || !state)
72
+ return 'unrecorded source';
73
+ return isExpectedUnsupportedBulkSource(domain, observation)
74
+ ? `${source}: unsupported (no IANA-published service)`
75
+ : `${source}: ${state}`;
76
+ }
@@ -3,10 +3,11 @@
3
3
  // connection attempts. Bulk/watchlist/case records deliberately keep only this
4
4
  // smaller derived shape: no paths, queries, header values, attempt errors, or
5
5
  // redirect inventories.
6
+ import { MAX_HTTP_EVIDENCE_REDIRECTS } from "../../../../lib/http-evidence-bounds.mjs";
6
7
  export const HTTP_SUMMARY_VERSION = 1;
7
8
  export const MAX_HTTP_SUMMARY_ORIGIN_LENGTH = 300;
8
9
  export const MAX_HTTP_SUMMARY_CONTENT_TYPE_LENGTH = 100;
9
- export const MAX_HTTP_SUMMARY_REDIRECTS = 5;
10
+ export const MAX_HTTP_SUMMARY_REDIRECTS = MAX_HTTP_EVIDENCE_REDIRECTS;
10
11
  export const MAX_HTTP_SECURITY_HEADER_INPUTS = 20;
11
12
  export const HTTP_SECURITY_HEADER_TOKENS = Object.freeze([
12
13
  'content-security-policy',
@@ -1,5 +1,6 @@
1
1
  // Browser-safe analysis helpers shared by the Svelte tools.
2
2
  import { serializeCsvCell, serializeCsvRows } from "../../../../lib/csv.mjs";
3
+ import { hammingDistanceHex, isInformativePerceptualHash, isPerceptualHash, } from "../../../../lib/perceptual-hash-comparison.mjs";
3
4
  // Deliberately conservative (no +tags, no comments, no quoted local parts) -
4
5
  // this only gates whether a WHOIS/RDAP-sourced string is safe to drop into a
5
6
  // mailto: URI as the recipient, not a general email validator. mailto:
@@ -38,47 +39,13 @@ export function entityDisplayName(value) {
38
39
  .slice(0, MAX_ENTITY_DISPLAY_LENGTH);
39
40
  return normalized || null;
40
41
  }
41
- // Hamming distance (0-64) between two 16-hex perceptual dHash strings (see
42
- // lib/perceptual-hash.mts), or null if either isn't a well-formed hash. Smaller
43
- // = more visually similar. Shared by the brand-profile near-match check and
44
- // bulk favicon clustering; mirrors the backend's hammingDistanceHex. The two
45
- // implementations target separate Node and browser execution boundaries.
46
- const HEX_HASH_RE = /^[0-9a-f]{16}$/;
47
- export function hammingDistanceHex(a, b) {
48
- if (typeof a !== 'string' || typeof b !== 'string')
49
- return null;
50
- if (!HEX_HASH_RE.test(a) || !HEX_HASH_RE.test(b))
51
- return null;
52
- let distance = 0;
53
- for (let i = 0; i < 16; i += 1) {
54
- let diff = parseInt(a.charAt(i), 16) ^ parseInt(b.charAt(i), 16);
55
- while (diff) {
56
- distance += diff & 1;
57
- diff >>= 1;
58
- }
59
- }
60
- return distance;
61
- }
42
+ export { hammingDistanceHex };
62
43
  // A dHash carries usable structure only when its set-bit count is away from
63
- // the degenerate extremes - a near-all-zero (or near-all-one) hash comes from
64
- // a solid/monotonic icon and collides with every other such icon (see
65
- // lib/perceptual-hash.mts's MIN_INFORMATIVE_BITS, mirrored here). Generation
66
- // already rejects these, but this is re-checked at every comparison so a
67
- // degenerate hash saved into a Brand Profile before that guard existed can't
68
- // keep producing false near-matches.
69
- const MIN_INFORMATIVE_HASH_BITS = 10;
44
+ // the degenerate extremes. The shared browser-safe contract applies the same
45
+ // threshold during generation and comparison, including for older retained
46
+ // profile values.
70
47
  export function isInformativeFaviconHash(hex) {
71
- if (typeof hex !== 'string' || !HEX_HASH_RE.test(hex))
72
- return false;
73
- let bits = 0;
74
- for (let i = 0; i < 16; i += 1) {
75
- let n = parseInt(hex.charAt(i), 16);
76
- while (n) {
77
- bits += n & 1;
78
- n >>= 1;
79
- }
80
- }
81
- return bits >= MIN_INFORMATIVE_HASH_BITS && bits <= 64 - MIN_INFORMATIVE_HASH_BITS;
48
+ return isInformativePerceptualHash(hex);
82
49
  }
83
50
  function faviconRecord(value) {
84
51
  const record = plainRecord(value);
@@ -87,7 +54,7 @@ function faviconRecord(value) {
87
54
  const faviconHash = typeof record.faviconHash === 'string' && record.faviconHash
88
55
  ? record.faviconHash
89
56
  : null;
90
- const faviconPHash = typeof record.faviconPHash === 'string' && HEX_HASH_RE.test(record.faviconPHash)
57
+ const faviconPHash = isPerceptualHash(record.faviconPHash)
91
58
  ? record.faviconPHash
92
59
  : null;
93
60
  return faviconHash || faviconPHash