@slicedearth/whoisleuth-cli 2.5.1 → 2.7.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 (170) hide show
  1. package/DISCLOSURE +9 -6
  2. package/NOTICE +3 -2
  3. package/README.md +60 -67
  4. package/cli/artifact-validation/case-response.mjs +6 -814
  5. package/cli/artifact-validation/lookup-evidence.mjs +30 -3
  6. package/cli/artifact-validation/signed-review.mjs +2 -51
  7. package/cli/artifact-verify.mjs +41 -3
  8. package/cli/bulk-command-runner.mjs +4 -6
  9. package/cli/case-command.mjs +92 -16
  10. package/cli/case-pack.mjs +14 -3
  11. package/cli/ci-report.mjs +17 -5
  12. package/cli/command-definition.mjs +7 -6
  13. package/cli/discovery-scan-command-runner.mjs +4 -6
  14. package/cli/doctor.mjs +37 -3
  15. package/cli/domain-feed.mjs +61 -0
  16. package/cli/formatters/html.mjs +3 -0
  17. package/cli/formatters/json.mjs +9 -14
  18. package/cli/formatters/terminal-lookup.mjs +24 -1
  19. package/cli/history-command-definitions.mjs +1 -1
  20. package/cli/indicator-set-command.mjs +1 -2
  21. package/cli/intake-command.mjs +43 -3
  22. package/cli/investigation-artifacts.mjs +2 -3
  23. package/cli/investigation-package-review.mjs +2 -0
  24. package/cli/investigation-plan.mjs +15 -0
  25. package/cli/local-mmdb-review.mjs +27 -4
  26. package/cli/lookup-brief.mjs +11 -5
  27. package/cli/lookup-command-runner.mjs +21 -13
  28. package/cli/offline-evidence-review.mjs +51 -5
  29. package/cli/review-arguments.mjs +3 -2
  30. package/cli/review-command-definitions.mjs +23 -17
  31. package/cli/review-command-runner.mjs +6 -3
  32. package/cli/risk-calibration.mjs +6 -4
  33. package/cli/saved-lookup.mjs +22 -7
  34. package/cli/selected-input-worker.mjs +12 -2
  35. package/cli/source-reliability.mjs +12 -10
  36. package/cli/support-arguments.mjs +1 -1
  37. package/cli/support-command-definitions.mjs +5 -4
  38. package/cli/support-command-runner.mjs +9 -7
  39. package/cli/watchlist-review.mjs +43 -0
  40. package/cli/workflow-arguments.mjs +14 -0
  41. package/cli/workflow-command-definitions.mjs +27 -1
  42. package/cli/workflow-command-runner.mjs +32 -0
  43. package/docs/cli-reference.md +49 -11
  44. package/docs/cli.md +200 -26
  45. package/docs/contextual-reviews.md +38 -5
  46. package/docs/infrastructure-observations.md +135 -0
  47. package/docs/offline-intake-context.md +213 -0
  48. package/lib/credential-form-attribution.mjs +136 -0
  49. package/lib/credential-surface-profile.mjs +6 -2
  50. package/lib/dmarc-discovery.mjs +4 -2
  51. package/lib/dns-delegation-health.mjs +23 -7
  52. package/lib/dns-inheritance-review.mjs +14 -1
  53. package/lib/dns-intelligence.mjs +18 -4
  54. package/lib/domain-posture-analysis.mjs +53 -2
  55. package/lib/domain-posture-parsers.mjs +9 -0
  56. package/lib/domain-posture.mjs +29 -10
  57. package/lib/evidence-export-privacy.mjs +5 -2
  58. package/lib/evidence-export.mjs +10 -4
  59. package/lib/evidence-report.mjs +3 -1
  60. package/lib/favicon.mjs +13 -4
  61. package/lib/generated/cisa-kev-catalog.mjs +17 -4
  62. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  63. package/lib/generated/retire-browser-catalog.mjs +178 -5
  64. package/lib/html-signals.mjs +6 -0
  65. package/lib/idn-confusables.mjs +5 -1
  66. package/lib/lookup-child-profile-contract.mjs +28 -11
  67. package/lib/lookup-collection-receipt.mjs +52 -0
  68. package/lib/lookup-diagnostics.mjs +2 -12
  69. package/lib/lookup-progress.mjs +29 -31
  70. package/lib/lookup-response-contract.mjs +10 -0
  71. package/lib/lookup-response.mjs +15 -14
  72. package/lib/lookup-source-progress.mjs +4 -2
  73. package/lib/lookup.mjs +33 -2
  74. package/lib/operation-budget.mjs +4 -0
  75. package/lib/rdap-capabilities.mjs +43 -9
  76. package/lib/registry-insights.mjs +17 -6
  77. package/lib/service-binding-dns.mjs +19 -2
  78. package/lib/sslbl-certificates.generated.mjs +142 -140
  79. package/lib/static-html-analysis.mjs +32 -4
  80. package/lib/technology-evidence-role.mjs +1 -51
  81. package/lib/tls-intelligence.mjs +30 -5
  82. package/lib/website-enrichment.mjs +39 -23
  83. package/lib/whois-authority.mjs +56 -1
  84. package/lib/whois-chain.mjs +14 -4
  85. package/lib/whois-common-formats.mjs +3 -1
  86. package/lib/whois-contacts.mjs +13 -2
  87. package/lib/whois-contracts.mjs +2 -0
  88. package/lib/whois-normalization.mjs +4 -4
  89. package/package.json +9 -7
  90. package/packages/analysis/bounded-json.mjs +12 -2
  91. package/packages/analysis/risk-scoring.mjs +12 -9
  92. package/packages/cases/case-attachment-model.mjs +36 -1
  93. package/packages/cases/case-checkpoint-receipts.mjs +68 -0
  94. package/packages/cases/case-containment-handoff.mjs +195 -0
  95. package/packages/cases/case-evidence-links.mjs +44 -0
  96. package/packages/cases/case-evidence-model.mjs +51 -7
  97. package/packages/cases/case-migration-model.mjs +9 -1
  98. package/packages/cases/case-object-coverage.mjs +119 -0
  99. package/packages/cases/case-packet-correction.mjs +106 -0
  100. package/packages/cases/case-recheck-model.mjs +35 -5
  101. package/packages/cases/case-record-operations.mjs +87 -9
  102. package/packages/cases/case-record-projection.mjs +43 -5
  103. package/packages/cases/case-record-version-input.mjs +23 -1
  104. package/packages/cases/case-report.mjs +35 -4
  105. package/packages/cases/case-requested-evidence.mjs +1 -1
  106. package/packages/cases/case-response-actions.mjs +57 -1
  107. package/packages/cases/case-response-model.mjs +7 -0
  108. package/packages/cases/case-response-object.mjs +119 -0
  109. package/packages/cases/case-response-outcomes.mjs +81 -14
  110. package/packages/cases/case-response-packet-render.mjs +22 -3
  111. package/packages/cases/case-response-packet-validation.mjs +880 -0
  112. package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
  113. package/packages/cases/case-response-packet.mjs +32 -17
  114. package/packages/cases/case-response-review-inputs.mjs +66 -8
  115. package/packages/cases/case-type-presentation.mjs +10 -0
  116. package/packages/cases/case-workflow-metadata.mjs +1 -17
  117. package/packages/comparison/capture-context.mjs +3 -2
  118. package/packages/contracts/analyst-interchange.mjs +7 -5
  119. package/packages/contracts/analyst-review-state-contract.mjs +1 -1
  120. package/packages/contracts/candidate-watch-review.mjs +54 -0
  121. package/packages/contracts/case-portability.mjs +292 -43
  122. package/packages/contracts/cli-command-semantics.mjs +3 -0
  123. package/packages/contracts/cli-doctor.mjs +4 -0
  124. package/packages/contracts/cli-lookup.mjs +57 -46
  125. package/packages/contracts/context-review.mjs +2 -1
  126. package/packages/contracts/external-observation-interchange.mjs +96 -28
  127. package/packages/contracts/lookup-evidence.mjs +13 -2
  128. package/packages/contracts/message-intake.mjs +7 -1
  129. package/packages/contracts/registry-insights.mjs +6 -0
  130. package/packages/contracts/risk-calibration.mjs +44 -29
  131. package/packages/contracts/schema-lifecycle.mjs +3 -1
  132. package/packages/contracts/web-capture.mjs +1 -1
  133. package/packages/contracts/workspace-portability.mjs +86 -16
  134. package/packages/evidence/collection-quality.mjs +1 -1
  135. package/packages/evidence/credential-form-attribution.mjs +60 -0
  136. package/packages/evidence/dmarc-policy-context.mjs +80 -0
  137. package/packages/evidence/image-regions.mjs +15 -0
  138. package/packages/evidence/lookup-collection-receipt.mjs +140 -0
  139. package/packages/evidence/lookup-collection-review.mjs +74 -0
  140. package/packages/evidence/record-identity.mjs +60 -16
  141. package/packages/evidence/technology-evidence-role.mjs +51 -0
  142. package/packages/interchange/external-findings-import.mjs +40 -21
  143. package/packages/interchange/json-output.mjs +14 -0
  144. package/packages/interchange/managed-indicator-export.mjs +3 -2
  145. package/packages/interchange/managed-indicator-set.mjs +13 -0
  146. package/packages/investigation/acquisition-decision-packet.mjs +78 -0
  147. package/packages/investigation/bulk-mail-exposure.mjs +2 -2
  148. package/packages/investigation/context-review.mjs +3 -3
  149. package/packages/investigation/document-intake.mjs +9 -3
  150. package/packages/investigation/domain-history-review.mjs +50 -12
  151. package/packages/investigation/domain-transition-review.mjs +2 -0
  152. package/packages/investigation/infrastructure-observation.mjs +234 -0
  153. package/packages/investigation/intake-context.mjs +284 -0
  154. package/packages/investigation/intake-indicators.mjs +116 -0
  155. package/packages/investigation/intake-phones.mjs +155 -0
  156. package/packages/investigation/intake-report.mjs +2 -1
  157. package/packages/investigation/investigation-manifest.mjs +33 -7
  158. package/packages/investigation/link-intake.mjs +38 -8
  159. package/packages/investigation/message-intake.mjs +55 -15
  160. package/packages/monitoring/domain-feed.mjs +321 -0
  161. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  162. package/packages/workspace/brand-keyword-campaign.mjs +241 -0
  163. package/packages/workspace/brand-profile-model.mjs +46 -2
  164. package/packages/workspace/bulk-session-model.mjs +12 -0
  165. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  166. package/packages/workspace/detection-rule-model.mjs +10 -2
  167. package/packages/workspace/shortlist-model.mjs +10 -2
  168. package/packages/workspace/watchlist-history.mjs +31 -4
  169. package/packages/workspace/watchlist-store.mjs +136 -7
  170. package/third-party-notices.txt +30 -30
@@ -17,8 +17,9 @@ The CLI uses the same models:
17
17
  whoisleuth review-evidence selected-input.json --json --strict-exit
18
18
  ```
19
19
 
20
- Each input has `schema`, `version: 1` and `evidence`. Unknown versions and fields
21
- are rejected. Inputs are bounded to 16 MiB; each supplied context list permits
20
+ Each input has `schema`, a supported `version` and `evidence`. Most inputs use
21
+ version 1; domain history also supports version 2 as described below. Unknown
22
+ versions and fields are rejected. Inputs are bounded to 16 MiB; each supplied context list permits
22
23
  200 records. `--strict-exit` returns 4 for a partial contextual review. Neither
23
24
  adapter contacts a destination or executes selected software.
24
25
 
@@ -57,8 +58,12 @@ later. These stages never execute a command, submit a form or open a destination
57
58
 
58
59
  The Case view compares retained snapshots using the existing source, hostname,
59
60
  scan-depth and model-version rules. Registration, DNS, mail and web changes are
60
- shown alongside source-qualified certificate pins. Equal-time observations are
61
- not ordered into a change sequence. Retired dependencies must involve the Case
61
+ shown alongside source-qualified certificate pins.
62
+ Registration and nameserver changes target the Case registration domain; mail
63
+ and web changes target the explicit observation hostname. Certificate pins keep
64
+ their own hostname, or an unknown target when none was retained. These targets
65
+ are preserved in review downloads, retained reports and print previews.
66
+ Equal-time observations are not ordered into a change sequence. Retired dependencies must involve the Case
62
67
  domain or one of its hostnames. An expected window qualifies the review; it does
63
68
  not approve a change automatically or establish transfer or takeover.
64
69
  Review prompts connect comparable page, mail, delegation and registration changes
@@ -66,12 +71,23 @@ to the retained concern. Incomplete comparisons remain visible. A changed creati
66
71
  date asks for reassessment of earlier decisions without asserting a new owner,
67
72
  resetting a baseline or discarding history; expiry alone never establishes deletion.
68
73
 
74
+ Open **Retirement review checklist** for DNS-alias, recovery-email and retained
75
+ integration-reference prompts. Review the configuration you control, then use
76
+ the existing declaration form to record the affected hostnames, evidence family,
77
+ retirement time and source. The draft shows these details before you prepare a
78
+ review. Prefer a configuration or change-reference label; do not enter recovery
79
+ addresses, credentials or complete URLs. Reports with declared retired dependencies
80
+ include the same suggested next reviews in the browser and CLI. Suggestions do not
81
+ record checks as completed or establish takeover availability. Saving preserves
82
+ the source-qualified declaration, not confirmation that configuration was changed.
83
+
69
84
  Schema: `whoisleuth.domain-history.input`. Its `evidence` contains:
70
85
 
71
86
  - `caseExport`: an ordinary supported editable Case export, with `version` and
72
87
  `cases`; the exact existing Case reader rejects data loss or silent repair.
73
88
  - `caseId`: the Case selected from that export.
74
- - `declarations`: `expectedChanges` and `retiredDependencies` arrays.
89
+ - `declarations`: `expectedChanges` and `retiredDependencies` arrays; version 2
90
+ also requires `registrationBoundaries`. Version 1 remains readable unchanged.
75
91
 
76
92
  An expected change is `{family, start, end, reason}`. A retired dependency is
77
93
  `{asset, dependency, family, retiredAt, source}`. Families are `registration`,
@@ -79,6 +95,23 @@ An expected change is `{family, start, end, reason}`. A retired dependency is
79
95
  reusable input download includes the selected Case and is not duplicated in
80
96
  workspace storage when saving the report.
81
97
 
98
+ Version-2 registration boundaries are analyst declarations, not confirmed
99
+ registration epochs. Each has `{kind, occurredAt, source, rationale,
100
+ snapshotIds, evidencePinIds}`. Kinds are `registration_reported`,
101
+ `deletion_reported`, `reregistration_reported`, `transfer_reported` and
102
+ `review_boundary`. Supply an explicit ISO time and at least one exact reference
103
+ to a snapshot or evidence pin retained in the selected Case. References must be
104
+ unique; unavailable references fail rather than selecting a replacement.
105
+ Up to 40 boundaries and 20 references of each type per boundary are accepted.
106
+
107
+ The Case history form exposes the same declarations. Reports count retained
108
+ observations before and at/after a declared time without assigning ownership or
109
+ asserting that deletion, re-registration or transfer occurred. Earlier evidence,
110
+ decisions, exceptions and open follow-ups are not rewritten. Reports retain the
111
+ declared source and exact evidence references, and can be deliberately saved as
112
+ ordinary Case files. The reusable input remains a separate download containing
113
+ the selected Case, not an additional workspace copy.
114
+
82
115
  ## Platform objects
83
116
 
84
117
  Schema: `whoisleuth.platform-continuity.input`. `evidence` is an array:
@@ -0,0 +1,135 @@
1
+ # Source-qualified infrastructure snapshots
2
+
3
+ The retained infrastructure inventory combines admitted browser-local evidence,
4
+ not all infrastructure belonging to an organisation. It makes no requests on
5
+ import, opening a view, filtering, selecting a host or comparing history.
6
+
7
+ Import a `whoisleuth.infrastructure-observation` version 1 document through the
8
+ existing external-findings import. The exact bounded snapshot is retained as one
9
+ Case evidence pin. Review the preview before confirming the mutation. The source
10
+ descriptions are supplied provenance, not authenticated source identities.
11
+ Legacy DNS and certificate imports remain readable: an old Case-domain DNS pin
12
+ does not acquire a precise queried hostname or complete collection scope.
13
+
14
+ The document records an explicit target, selected full hostnames and DNS types,
15
+ collection mode, observation time and coverage. Each DNS response retains its
16
+ queried name, response owner, type, outcome and source. An alias link uses its
17
+ record owner; an address returned after an alias belongs to the returned address
18
+ owner, not automatically to the initial query. Only retained alias links are
19
+ drawn. Links from different dates do not prove one contemporaneous alias chain.
20
+ No-data, NXDOMAIN, failed and not-checked outcomes remain different observations
21
+ and do not decide registration availability.
22
+
23
+ Certificate-log and TLS observations keep distinct sources and exact SHA-256
24
+ identities. Full names remain visible; `*.example.test` is a wildcard pattern,
25
+ not an enumerated hostname, and cannot be selected for Lookup or Bulk.
26
+ Certificate names alone do not establish current resolution or activity.
27
+
28
+ DNS operator, observed edge/CDN/proxy, application platform, runtime, embedded
29
+ dependency, address registration, routing origin and independently observed
30
+ origin are distinct source-qualified roles. Routing requires a routing source;
31
+ IP-registration data cannot substitute for it. Edge indicators cannot identify
32
+ a hidden origin. Multiple providers and conflicting observations remain separate.
33
+ The snapshot reader does not derive physical location, responsibility, ownership,
34
+ safety or maliciousness from these roles.
35
+
36
+ ## Review and deliberately act
37
+
38
+ Open the inventory from saved-work search. Type, source and date filters apply to
39
+ the admitted set before pagination. Inspect one snapshot to review every retained
40
+ DNS outcome, certificate name and provider role, or select two snapshots in
41
+ chronological order to compare. Export preserves the exact snapshot format.
42
+ Topology search applies to the complete admitted one-hop relationship set, not
43
+ the current source page. Its diagram has a 50-relationship budget; the paginated
44
+ source list and exact snapshot review retain access to evidence beyond the diagram.
45
+
46
+ Follow a retained domain to an address or certificate, then open its supporting
47
+ Case. **Return to retained infrastructure** restores the exact query, filters,
48
+ page positions and selected identity. **Tab references → Navigation trail** also
49
+ lets you return to earlier successful pivots. This trail lives only in the
50
+ current tab and workspace; it creates no observed relationships. Sources are
51
+ read again rather than copied into navigation state. A removed identity stays
52
+ unavailable, and clearing references or locking the workspace expires the links.
53
+ Certificate identities here are leaf-certificate SHA-256, not SPKI digests.
54
+
55
+ Lookup links fill the selected target for review without submitting it. Explicitly
56
+ selected hostnames can be prepared in existing Bulk. That handoff does not scan,
57
+ recursively expand targets, change a watchlist or enrol a host in monitoring.
58
+ Use the existing reviewed Lookup/Bulk/Monitor collection plans and explicit
59
+ save actions to collect or retain another observation.
60
+
61
+ ## Historical comparison and CLI
62
+
63
+ Source, mode and exact selection scope must match, and the later snapshot must
64
+ have a strictly later observation time. Newly observed means newly present in
65
+ retained evidence, not newly created. A value not returned by a complete,
66
+ comparable selected collection is labelled **not returned**, never confirmed
67
+ absence or disappearance. Failed, partial, limited, changed-source and
68
+ incomparable snapshots cannot generate removal findings. Equal-time observations
69
+ have no temporal order. Provider-reported history stays labelled separately from
70
+ supplied local observations.
71
+
72
+ Comparison rows retain the exact DNS query, response owner and record type, or
73
+ the certificate SHA-256, alongside their source and observation clocks. Labels
74
+ are presentation only: relationship groups and graph exports use canonical
75
+ entity identities, including when long nameserver-set labels look identical.
76
+
77
+ Derived relationship-group, graph-node and cluster IDs can change when their
78
+ identity calculation changes, including for short labels. They are not durable
79
+ cross-export keys; use the canonical `entityId` where provided. Historical groups
80
+ without it use their complete retained value. Graph fields named `canonical`
81
+ and `value` are bounded display text, not a replacement for that identity. Inspect
82
+ the contributing Case evidence for full nameserver members. Component selections
83
+ and cluster adjustments are transient and are not persisted review history.
84
+
85
+ In GraphML and GEXF, the display label for `node_canonical` is now
86
+ `Bounded display value` (previously `Canonical value`). The field key, JSON
87
+ property, entity identities and values are unchanged; XML consumers should use
88
+ the field key rather than its display label.
89
+
90
+ The existing offline command shares the website reader and comparison owner:
91
+
92
+ ```sh
93
+ whoisleuth review-evidence infrastructure-observation.json --json
94
+ whoisleuth review-evidence infrastructure-comparison.json --json
95
+ ```
96
+
97
+ The comparison input is exactly:
98
+
99
+ ```json
100
+ {
101
+ "schema": "whoisleuth.infrastructure-comparison.input",
102
+ "version": 1,
103
+ "earlier": { "...": "complete version-1 snapshot" },
104
+ "later": { "...": "complete version-1 snapshot" }
105
+ }
106
+ ```
107
+
108
+ The placeholders above are illustrative, not valid evidence. See the reserved
109
+ fixture in `test/fixtures/infrastructure-observations/` for an exact snapshot.
110
+ The CLI performs no DNS, registry, BGP, TLS, HTTP or provider request.
111
+
112
+ Changed or incomplete latest retained pairs surface as existing local Review
113
+ Items with supporting Case navigation. Complete earlier snapshots remain
114
+ available as stronger baselines; later failures do not rewrite them. There is no
115
+ new history store or scheduling engine.
116
+
117
+ ## Resource and compatibility boundary
118
+
119
+ One snapshot is limited to 128 KiB, 128 selected hostnames, six DNS types, 16
120
+ source identities, 512 DNS response rows with at most 32 values each, 32
121
+ certificate observations with at most 128 exact names each, 128 role observations
122
+ and 12 bounded limitations. Bounds reject oversized documents rather than
123
+ silently prune accepted snapshot evidence. Existing Case/workspace quotas still
124
+ apply. Projection/diagram budgets may admit a smaller disposable view, with
125
+ incomplete coverage disclosed; exact snapshot review/export remains available.
126
+
127
+ Version 1 has exact keys and refuses future versions. Existing external-findings
128
+ version 4 and typed version-1 row imports preserve their historical meaning;
129
+ version 5 adds the optional exact infrastructure snapshot. No external discovery
130
+ API, new provider, dependency, collector or automatic request is required.
131
+ Current snapshot and version-5 findings exports use compact UTF-8 JSON with a
132
+ terminal line feed so the emitted representation stays within its parser ceiling.
133
+ An offline comparison input is bounded to two snapshot budgets plus 1 KiB of
134
+ framing. Historical version-4 fixture bytes and reader meaning remain unchanged;
135
+ new typed row conversions emit version 5.
@@ -0,0 +1,213 @@
1
+ # Selected-input indicators and internal containment
2
+
3
+ Message intake remains local and inert. It does not resolve indicators, open
4
+ links, consult reputation services, execute attachments or apply controls.
5
+
6
+ ## Source-linked indicator review
7
+
8
+ Version 2 of `whoisleuth.message-intake` adds literal IPv4/IPv6 observations and
9
+ explicitly labelled MD5, SHA-1 and SHA-256 strings from selected plain text,
10
+ message body text, supported calendar fields and extracted document text.
11
+ Private and reserved addresses are valid textual observations, not permission
12
+ to contact them. A labelled hash string is a supplied claim, not proof that a
13
+ file has that digest. Bare opaque hexadecimal values are not interpreted.
14
+
15
+ Each indicator identifies its input/message/document part and page when known.
16
+ The review retains the source-byte and part digests separately. Identical values
17
+ from different parts remain separately attributed; repeated identical values
18
+ within one part are deduplicated. The UI can copy a source citation. Indicators
19
+ are not added to Lookup targets and do not become a risk score or finding.
20
+
21
+ Complete URL spans, address local parts, recognised credential-bearing lines,
22
+ message headers and QR payloads are not scanned for indicators. MIME HTML
23
+ extraction also excludes script and style nodes. Explicit plaintext is literal
24
+ supplied text: an IP inside a pasted code snippet can be an inert observation;
25
+ the code is never executed. Phone candidates have their own stricter markup
26
+ exclusions. HAR and identity-event imports keep their existing restricted
27
+ projections: no broad scan of their original fields occurs. Extraction is
28
+ limited to 512 observations, 4,096 candidate checks and 2,097,152 text code
29
+ units across an input. Reached bounds are partial coverage, never proof that
30
+ no further indicators exist.
31
+
32
+ Version-1 reports remain readable without invented indicator coverage. Their
33
+ original source digests do not acquire new observations retrospectively.
34
+
35
+ ### Supplied phone candidates
36
+
37
+ Plain-text intake can also identify possible telephone numbers, including a
38
+ support number in a supplied snippet with no link. Candidates are review leads:
39
+ they do not authenticate a caller, identify an operator or establish wrongdoing.
40
+ No number is dialled, resolved or checked against a reputation service.
41
+
42
+ Review and select each observation deliberately before including it in a saved
43
+ review. Unselected candidates remain transient. The selected observation keeps
44
+ its original spelling, source digest and exact text range, not the surrounding
45
+ snippet. Text ranges use zero-based, end-exclusive UTF-16 code-unit offsets;
46
+ supplementary characters occupy two units. A declared source time is separate
47
+ from the local review time. Identify manual transcription or OCR-derived text
48
+ when that is how the supplied text was obtained.
49
+
50
+ The browser provides a shared declaration for selected occurrences. Open
51
+ **Declaration for candidate** to override a source label, role, observation time
52
+ or text basis for one occurrence. Apply or clear edits before saving or
53
+ downloading; only equivalent numbers with equivalent declarations share a group.
54
+
55
+ An advertised support contact and a sender or caller-ID claim describe different
56
+ roles; neither verifies ownership. International formatting can be compared only
57
+ where its prefix and supported separators permit a defensible normalisation.
58
+ National-format numbers, conflicting country context and unsupported characters
59
+ remain uncertain. Extensions are separate, not appended to the destination.
60
+ Dates, identifiers and unsupported forms must not be treated as verified numbers.
61
+
62
+ Selected numbers can still be private contacts. Review the selected content
63
+ before saving or sharing it. Ordinary public/trusted Case projections do not
64
+ include the private intake attachment; domain-focused interchange does not invent
65
+ a telephone mapping. A missing exported phone observation is not evidence that
66
+ none was supplied.
67
+
68
+ ### Compare a displayed claim with a supplied destination
69
+
70
+ Use the intake's manual destination comparison when supplied ad, search or
71
+ screenshot material shows one destination and a separate source supplies another.
72
+ Identify each source, any known observation time, and whether its text is a claim,
73
+ transcription or supplied observation. The comparison uses the same safe hostname
74
+ parsing as an HTML link's displayed-host comparison.
75
+
76
+ Review the exact hostname and registrable boundary. A hostname such as
77
+ `store.example.test.attacker.invalid` is not `store.example.test`. Legitimate
78
+ tracking destinations can also differ. Missing, malformed or credential-bearing
79
+ input leaves insufficient evidence rather than inventing a redirect chain.
80
+
81
+ Exact entered URLs stay in the private transient view. Retained output uses the
82
+ minimised hostname/origin projection and supplied qualifications, not private
83
+ paths, query values or fragments. Comparing the pair never follows either URL
84
+ and does not establish that a redirect occurred.
85
+
86
+ ## Optional distribution declarations
87
+
88
+ After reviewing input in Lookup or a Case, open **Declare distribution
89
+ context**. Supply a channel, non-sensitive source label and optional declared
90
+ time, reference, observer and vantage labels. Apply or clear changed declarations
91
+ before saving or downloading. These are analyst claims, not verified delivery,
92
+ capture conditions, network location or independent collection. The local review
93
+ time remains separate from a declared observation time.
94
+
95
+ The CLI accepts the same explicit declaration:
96
+
97
+ ```sh
98
+ whoisleuth intake text selected.txt --intake-context context.json --json
99
+ ```
100
+
101
+ ```json
102
+ {
103
+ "schema": "whoisleuth.intake-context",
104
+ "version": 1,
105
+ "context": {
106
+ "channel": "sms",
107
+ "observedAt": "2026-01-02T03:04:05.000Z",
108
+ "sourceLabel": "Reported message",
109
+ "reference": "CASE-17",
110
+ "observerLabel": "Reviewer A",
111
+ "vantageLabel": "Selected device"
112
+ }
113
+ }
114
+ ```
115
+
116
+ The separate context file is limited to 8 KiB; stdin is not accepted for it.
117
+ Use `null` for unknown optional values. Channels are `email`, `sms`, `messaging`,
118
+ `social`, `advertisement`, `website`, `document`, `other` and `unknown`.
119
+ Do not supply credentials, recipients, personal account identifiers or exact
120
+ URLs in labels. All declarations are included in the downloaded or deliberately
121
+ retained review. The Case summary pin contains counts and the report identity,
122
+ not the indicator values or declaration text. Retaining original input remains
123
+ a separate choice.
124
+
125
+ ### Select phone observations in the CLI
126
+
127
+ First run `whoisleuth intake text selected.txt --json`. Standard error shows a
128
+ candidate count, the full source digest, UTF-16 ranges and uncertainty, but not
129
+ the numbers themselves. JSON output excludes unselected candidates. `--quiet`
130
+ suppresses this discovery guidance. Inspect the corresponding text in your
131
+ selected file before choosing a range.
132
+
133
+ A version-2 context file binds selections to those exact input bytes. For
134
+ example, a file containing `Support: +1 202 555 0107` followed by one LF newline
135
+ has this selection:
136
+
137
+ ```json
138
+ {
139
+ "schema": "whoisleuth.intake-context",
140
+ "version": 2,
141
+ "context": null,
142
+ "review": {
143
+ "sourceDigestSha256": "sha256:4ff4224ff4ed496fb67fa2ccb25f87b284d1d291f8e79b57646d6c32700eeb52",
144
+ "phones": [{
145
+ "start": 9,
146
+ "end": 24,
147
+ "declaration": {
148
+ "sourceLabel": "Selected support snippet",
149
+ "observedAt": null,
150
+ "basis": "supplied_text",
151
+ "role": "advertised_support_contact",
152
+ "countryCallingCode": null
153
+ }
154
+ }],
155
+ "destinationPair": null
156
+ }
157
+ }
158
+ ```
159
+
160
+ Run the earlier `--intake-context context.json` command to include that selected
161
+ observation. A changed file or incorrect range is rejected; obtain its current
162
+ digest and review it again. `context` can also carry the existing distribution
163
+ declaration. Version-1 context files remain supported.
164
+
165
+ ## Selected internal containment handoff
166
+
167
+ In **Case → Response → Account and device recovery**, open **Prepare an internal
168
+ containment handoff**. Select retained next-step assertions and, independently,
169
+ their linked evidence pins. Choose security operations, identity response,
170
+ endpoint response or network response for tailored review guidance.
171
+
172
+ The preview displays the selected statements, rationale, recorded state,
173
+ times, pin values and source metadata. **Exact JSON disclosure** exposes the
174
+ complete download, including selected pin scope, provenance and recheck fields.
175
+ Supports, contradicts, unresolved and
176
+ unrecorded relationships remain distinct. A linked pin that is not selected is
177
+ not silently included; an unavailable referenced pin remains unavailable.
178
+ The handoff is limited to 20 requests, 40 pins and 128 KiB, with no silent
179
+ truncation. No original file bytes or unrelated Case notes, title, contacts,
180
+ recipient values, assertions or pins are included.
181
+
182
+ Review the audience disclosure before copy, download or explicit retention.
183
+ Internal and trusted audiences use the existing Case disclosure policy. Public
184
+ preview excludes internal requests and supporting pins and cannot produce a
185
+ containment export. Selected analyst text may still be sensitive: minimisation
186
+ does not provide anonymisation. Saving uses an ordinary retained Case file and
187
+ a source-hashed summary pin, not another task or state store.
188
+
189
+ The existing `review-evidence` CLI command accepts an equivalent selected input:
190
+
191
+ ```json
192
+ {
193
+ "schema": "whoisleuth.internal-containment.input",
194
+ "version": 1,
195
+ "caseExport": { "version": 18, "cases": [] },
196
+ "caseId": "selected-case-id",
197
+ "selection": {
198
+ "audience": "internal",
199
+ "recipientRole": "identity_response",
200
+ "assertionIds": ["selected-next-step-id"],
201
+ "evidencePinIds": ["selected-linked-pin-id"]
202
+ },
203
+ "disclosureReviewed": true
204
+ }
205
+ ```
206
+
207
+ Replace the empty example with a supported ordinary Case export and exact
208
+ retained IDs after reviewing its selected content. The CLI does not write the
209
+ Case; `--strict-exit` returns 4 when selected supporting context is incomplete.
210
+ Output uses `whoisleuth.internal-containment` version 1 inside the existing
211
+ offline-review envelope. Neither external resolution nor a resolved Case closes
212
+ an open internal request. The handoff performs no assignment, notification,
213
+ blocking, credential change, isolation or other containment action.
@@ -0,0 +1,136 @@
1
+ import { classifyStaticCredentialInput, staticControlDisabled, MAX_STATIC_FORMS, MAX_STATIC_INPUTS, } from "./static-html-analysis.mjs";
2
+ import { CREDENTIAL_CATEGORY_NAMES, MAX_FORM_DESTINATIONS, } from "../packages/evidence/credential-form-attribution.mjs";
3
+ const attribute = (element, name) => element.attributes.find(item => item.name === name)?.value;
4
+ /** HTML Auto buttons submit only without command attributes or a select parent. */
5
+ export function staticControlIsSubmitter(element, parent) {
6
+ const type = (attribute(element, 'type') ?? '').toLowerCase();
7
+ if (element.name === 'input')
8
+ return type === 'submit' || type === 'image';
9
+ if (element.name !== 'button')
10
+ return false;
11
+ if (type === 'submit')
12
+ return true;
13
+ if (type === 'button' || type === 'reset')
14
+ return false;
15
+ return attribute(element, 'command') === undefined && attribute(element, 'commandfor') === undefined
16
+ && !(parent?.html && parent.name === 'select');
17
+ }
18
+ // Uses the existing bounded, inert-template-free tree. IDs and control values
19
+ // are transient association inputs; only ordinal forms and fixed categories
20
+ // leave this projection.
21
+ export function attributeCredentialForms(analysis, documentUrl, sourceTruncated = false) {
22
+ const elements = analysis.elements;
23
+ const firstId = new Map();
24
+ const firstLegends = new Map();
25
+ let firstUncertainId = Number.POSITIVE_INFINITY;
26
+ const formIndices = [];
27
+ let complete = !sourceTruncated && !analysis.inputLimitReached && !analysis.tagLimitReached && !analysis.forms.truncated;
28
+ for (const [index, element] of elements.entries()) {
29
+ if (element.attributesTruncated && attribute(element, 'id') === undefined)
30
+ firstUncertainId = Math.min(firstUncertainId, index);
31
+ const id = attribute(element, 'id');
32
+ if (id !== undefined && id.length <= 4096 && !firstId.has(id))
33
+ firstId.set(id, index);
34
+ if (element.html && element.name === 'form') {
35
+ if (formIndices.length < MAX_STATIC_FORMS)
36
+ formIndices.push(index);
37
+ else
38
+ complete = false;
39
+ }
40
+ if (element.html && element.name === 'legend' && element.parent !== null && !firstLegends.has(element.parent))
41
+ firstLegends.set(element.parent, index);
42
+ }
43
+ const ancestor = (index, name) => {
44
+ for (let parent = elements[index]?.parent ?? null; parent !== null; parent = elements[parent]?.parent ?? null) {
45
+ const element = elements[parent];
46
+ if (element.html && element.name === name)
47
+ return parent;
48
+ }
49
+ return null;
50
+ };
51
+ const owner = (index) => {
52
+ const element = elements[index];
53
+ if (element.attributesTruncated)
54
+ return null;
55
+ for (let parent = element.parent; parent !== null; parent = elements[parent]?.parent ?? null) {
56
+ if (elements[parent]?.name === 'fieldset' && elements[parent]?.attributesTruncated)
57
+ return null;
58
+ }
59
+ const explicit = attribute(element, 'form');
60
+ if (explicit !== undefined) {
61
+ const selected = firstId.get(explicit);
62
+ // A clipped earlier ID could shadow this form in the browser's first-ID
63
+ // lookup. Do not attribute its controls to the later retained match.
64
+ return selected !== undefined && selected < firstUncertainId && elements[selected]?.html && elements[selected]?.name === 'form' ? selected : null;
65
+ }
66
+ return ancestor(index, 'form');
67
+ };
68
+ const destination = (form, submitter) => {
69
+ const method = (submitter && attribute(submitter, 'formmethod')) ?? attribute(form, 'method') ?? 'get';
70
+ if (method.toLowerCase() === 'dialog')
71
+ return { relationship: 'no_submission', origin: null };
72
+ if (form.attributesTruncated || submitter?.attributesTruncated)
73
+ return { relationship: 'unknown', origin: null };
74
+ const action = (submitter && attribute(submitter, 'formaction')) ?? attribute(form, 'action');
75
+ if (action !== undefined && (action.length > 4096 || /[\u0000-\u001f\u007f]/u.test(action)))
76
+ return { relationship: 'unknown', origin: null };
77
+ try {
78
+ const source = new URL(documentUrl);
79
+ // Missing/empty actions use the document URL, not the <base> URL.
80
+ if (action?.trim() && analysis.baseHrefState === 'invalid')
81
+ return { relationship: 'unknown', origin: null };
82
+ const target = new URL(action?.trim() || documentUrl, analysis.effectiveBaseUrl ?? documentUrl);
83
+ if (!['http:', 'https:'].includes(target.protocol) || target.username || target.password || target.origin.length > 320)
84
+ return { relationship: 'unknown', origin: null };
85
+ return { relationship: target.origin === source.origin ? 'same_origin' : 'external', origin: target.origin };
86
+ }
87
+ catch {
88
+ return { relationship: 'unknown', origin: null };
89
+ }
90
+ };
91
+ const forms = formIndices.map((index, ordinal) => ({
92
+ index: ordinal + 1,
93
+ categories: Object.fromEntries(CREDENTIAL_CATEGORY_NAMES.map(name => [name, 0])),
94
+ destinations: [destination(elements[index])],
95
+ }));
96
+ const formRows = new Map(formIndices.map((index, ordinal) => [index, forms[ordinal]]));
97
+ let unassociatedInputs = 0;
98
+ let controls = 0;
99
+ for (const [index, element] of elements.entries()) {
100
+ if (!element.html || !['input', 'button'].includes(element.name))
101
+ continue;
102
+ if (++controls > MAX_STATIC_INPUTS) {
103
+ complete = false;
104
+ break;
105
+ }
106
+ if (staticControlDisabled(elements, index, firstLegends))
107
+ continue;
108
+ const categories = element.name === 'input' ? classifyStaticCredentialInput(element.attributes) : { values: [], truncated: false };
109
+ if (categories.truncated || element.attributesTruncated)
110
+ complete = false;
111
+ const ownerIndex = owner(index);
112
+ const row = ownerIndex === null ? undefined : formRows.get(ownerIndex);
113
+ if (categories.values.length) {
114
+ if (!row) {
115
+ unassociatedInputs++;
116
+ complete = false;
117
+ }
118
+ else
119
+ for (const category of categories.values)
120
+ row.categories[category]++;
121
+ }
122
+ const submitter = staticControlIsSubmitter(element, element.parent === null ? undefined : elements[element.parent]);
123
+ if (row && ownerIndex !== null && submitter && (attribute(element, 'formaction') !== undefined || attribute(element, 'formmethod') !== undefined)) {
124
+ const next = destination(elements[ownerIndex], element);
125
+ if (!row.destinations.some(value => value.relationship === next.relationship && value.origin === next.origin)) {
126
+ if (row.destinations.length >= MAX_FORM_DESTINATIONS)
127
+ complete = false;
128
+ else
129
+ row.destinations.push(next);
130
+ }
131
+ }
132
+ }
133
+ if (forms.some(form => form.destinations.some(value => value.relationship === 'unknown')))
134
+ complete = false;
135
+ return { forms, unassociatedInputs, complete };
136
+ }
@@ -5,22 +5,25 @@
5
5
  // never enter this profile.
6
6
  import { createObservation } from "../packages/evidence/observation.mjs";
7
7
  import { CREDENTIAL_SURFACE_PROFILE_VERSION } from "./lookup-child-profile-contract.mjs";
8
+ import { attributeCredentialForms } from "./credential-form-attribution.mjs";
8
9
  import { MAX_STATIC_FORMS, MAX_STATIC_INPUTS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
9
10
  function analyzeCredentialSurfaceProfile(input = {}) {
10
11
  const htmlAnalysis = input.htmlAnalysis ?? analyzeStaticHtml(input.html, { baseUrl: input.baseUrl });
11
12
  const forms = htmlAnalysis.forms;
13
+ const formAttribution = input.formAttribution ?? attributeCredentialForms(htmlAnalysis, typeof input.baseUrl === 'string' ? input.baseUrl : '', input.sourceTruncated === true);
12
14
  const sourceTruncated = input.sourceTruncated === true;
13
15
  const truncated = sourceTruncated
14
16
  || htmlAnalysis.inputLimitReached
15
17
  || htmlAnalysis.tagLimitReached
16
18
  || forms.truncated;
17
19
  const unclassifiedActions = forms.actions.unclassified;
18
- const partial = truncated || unclassifiedActions > 0;
20
+ const partial = truncated || unclassifiedActions > 0 || !formAttribution.complete;
19
21
  const limitations = [
20
22
  'Static input categories use only bounded type and autocomplete declarations; JavaScript-rendered, non-semantic, disabled, and hidden controls are not classified.',
21
23
  'Category counts can overlap when one input declares more than one recognised purpose.',
22
24
  'External submission can be legitimate and does not establish phishing, unsafe handling, ownership, intent, or maliciousness.',
23
- 'No field names, values, labels, placeholders, complete action URLs, paths, queries, fragments, or arbitrary attributes are retained.',
25
+ 'Only form ordinals, input-purpose counts and declared destination origins are retained; no field names, values, labels, placeholders, URL paths, queries or fragments.',
26
+ 'Declared destinations, including submitter overrides, are not observed submissions. Script-created or changed forms are not evaluated.',
24
27
  ];
25
28
  if (sourceTruncated || htmlAnalysis.inputLimitReached) {
26
29
  limitations.push('The captured homepage body was truncated, so credential-surface evidence may be incomplete.');
@@ -36,6 +39,7 @@ function analyzeCredentialSurfaceProfile(input = {}) {
36
39
  }
37
40
  return {
38
41
  credentialSurfaceVersion: CREDENTIAL_SURFACE_PROFILE_VERSION,
42
+ formAttribution,
39
43
  ...createObservation({
40
44
  status: partial ? 'partial' : 'success',
41
45
  observedAt: input.observedAt,
@@ -1,6 +1,7 @@
1
1
  import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
2
2
  import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
3
3
  import { parseDmarcRecords } from "./domain-posture-parsers.mjs";
4
+ import { MAX_DMARC_DISCOVERY_OWNERS } from "../packages/evidence/dmarc-policy-context.mjs";
4
5
  export function requireDmarcDomain(domain) {
5
6
  if (typeof domain !== 'string' || domain.length > 253 || !isValidAsciiHostname(domain) || domain !== domain.toLowerCase()) {
6
7
  throw new TypeError('The additional DNS review requires a normalised domain.');
@@ -29,7 +30,8 @@ export function dmarcPolicyObservation(owner, observation) {
29
30
  : state;
30
31
  const time = typeof observation.observedAt === 'string' && observation.observedAt.length <= 64
31
32
  ? normalizeExplicitIsoTimestamp(observation.observedAt) : null;
32
- return { owner, parsed, psd, valid, unavailable, incomplete,
33
+ return { owner, parsed, psd, valid, unavailable, incomplete, observedAt: time,
34
+ state: unavailable ? 'unavailable' : valid ? 'observed' : parsed.records.length ? 'invalid' : 'not_found',
33
35
  record: `Recursive DNS TXT _dmarc.${owner} · ${time ?? 'time unavailable'} · ${description}` };
34
36
  }
35
37
  /** RFC 9989 sections 4.10–4.10.2: at most eight owners, never PSL inference. */
@@ -38,7 +40,7 @@ export async function walkDmarcTree(domain, exact, resolve, purpose) {
38
40
  const rows = [dmarcPolicyObservation(domain, exact)];
39
41
  const first = rows[0];
40
42
  const labels = domain.split('.');
41
- let next = labels.length >= 8 ? labels.slice(-7) : labels.slice(1);
43
+ let next = labels.length >= MAX_DMARC_DISCOVERY_OWNERS ? labels.slice(-(MAX_DMARC_DISCOVERY_OWNERS - 1)) : labels.slice(1);
42
44
  const policyOnly = purpose === 'policy' && first.valid;
43
45
  if (!policyOnly && !first.unavailable && !(first.valid && ['n', 'y'].includes(first.psd))) {
44
46
  while (next.length) {