@slicedearth/whoisleuth-cli 2.5.1 → 2.6.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 (122) hide show
  1. package/DISCLOSURE +1 -1
  2. package/cli/artifact-validation/case-response.mjs +51 -8
  3. package/cli/artifact-validation/lookup-evidence.mjs +15 -1
  4. package/cli/artifact-validation/signed-review.mjs +2 -51
  5. package/cli/artifact-verify.mjs +9 -2
  6. package/cli/bulk-command-runner.mjs +4 -6
  7. package/cli/case-command.mjs +79 -3
  8. package/cli/case-pack.mjs +7 -2
  9. package/cli/command-definition.mjs +2 -1
  10. package/cli/discovery-scan-command-runner.mjs +4 -6
  11. package/cli/domain-feed.mjs +61 -0
  12. package/cli/formatters/json.mjs +1 -13
  13. package/cli/indicator-set-command.mjs +1 -2
  14. package/cli/intake-command.mjs +43 -3
  15. package/cli/investigation-artifacts.mjs +2 -3
  16. package/cli/investigation-package-review.mjs +2 -0
  17. package/cli/local-mmdb-review.mjs +27 -4
  18. package/cli/lookup-command-runner.mjs +1 -0
  19. package/cli/offline-evidence-review.mjs +51 -5
  20. package/cli/review-arguments.mjs +3 -2
  21. package/cli/review-command-definitions.mjs +14 -8
  22. package/cli/review-command-runner.mjs +6 -3
  23. package/cli/risk-calibration.mjs +6 -4
  24. package/cli/selected-input-worker.mjs +12 -2
  25. package/cli/support-command-runner.mjs +3 -3
  26. package/cli/watchlist-review.mjs +43 -0
  27. package/cli/workflow-arguments.mjs +14 -0
  28. package/cli/workflow-command-definitions.mjs +26 -1
  29. package/cli/workflow-command-runner.mjs +32 -0
  30. package/docs/cli.md +99 -2
  31. package/docs/contextual-reviews.md +28 -5
  32. package/docs/infrastructure-observations.md +126 -0
  33. package/docs/offline-intake-context.md +213 -0
  34. package/lib/credential-form-attribution.mjs +136 -0
  35. package/lib/credential-surface-profile.mjs +6 -2
  36. package/lib/dns-delegation-health.mjs +23 -7
  37. package/lib/dns-intelligence.mjs +18 -4
  38. package/lib/domain-posture-analysis.mjs +53 -2
  39. package/lib/domain-posture-parsers.mjs +9 -0
  40. package/lib/domain-posture.mjs +29 -10
  41. package/lib/evidence-export-privacy.mjs +5 -2
  42. package/lib/evidence-report.mjs +1 -0
  43. package/lib/favicon.mjs +13 -4
  44. package/lib/html-signals.mjs +6 -0
  45. package/lib/lookup-child-profile-contract.mjs +28 -11
  46. package/lib/lookup-response-contract.mjs +1 -0
  47. package/lib/operation-budget.mjs +4 -0
  48. package/lib/service-binding-dns.mjs +19 -2
  49. package/lib/sslbl-certificates.generated.mjs +138 -137
  50. package/lib/static-html-analysis.mjs +32 -4
  51. package/lib/technology-evidence-role.mjs +1 -51
  52. package/lib/tls-intelligence.mjs +30 -5
  53. package/lib/website-enrichment.mjs +39 -23
  54. package/lib/whois-chain.mjs +12 -3
  55. package/lib/whois-common-formats.mjs +3 -1
  56. package/lib/whois-contacts.mjs +13 -2
  57. package/package.json +7 -5
  58. package/packages/analysis/risk-scoring.mjs +12 -9
  59. package/packages/cases/case-containment-handoff.mjs +195 -0
  60. package/packages/cases/case-evidence-links.mjs +16 -0
  61. package/packages/cases/case-evidence-model.mjs +14 -3
  62. package/packages/cases/case-object-coverage.mjs +33 -0
  63. package/packages/cases/case-packet-correction.mjs +106 -0
  64. package/packages/cases/case-recheck-model.mjs +35 -5
  65. package/packages/cases/case-record-operations.mjs +74 -9
  66. package/packages/cases/case-record-projection.mjs +15 -3
  67. package/packages/cases/case-record-version-input.mjs +23 -1
  68. package/packages/cases/case-report.mjs +30 -0
  69. package/packages/cases/case-requested-evidence.mjs +1 -1
  70. package/packages/cases/case-response-actions.mjs +57 -1
  71. package/packages/cases/case-response-model.mjs +7 -0
  72. package/packages/cases/case-response-object.mjs +119 -0
  73. package/packages/cases/case-response-outcomes.mjs +81 -14
  74. package/packages/cases/case-response-packet-render.mjs +22 -3
  75. package/packages/cases/case-response-packet.mjs +30 -2
  76. package/packages/cases/case-response-review-inputs.mjs +48 -7
  77. package/packages/cases/case-workflow-metadata.mjs +1 -9
  78. package/packages/contracts/analyst-interchange.mjs +7 -5
  79. package/packages/contracts/candidate-watch-review.mjs +54 -0
  80. package/packages/contracts/case-portability.mjs +260 -42
  81. package/packages/contracts/cli-command-semantics.mjs +3 -0
  82. package/packages/contracts/context-review.mjs +2 -1
  83. package/packages/contracts/external-observation-interchange.mjs +96 -28
  84. package/packages/contracts/lookup-evidence.mjs +3 -2
  85. package/packages/contracts/message-intake.mjs +7 -1
  86. package/packages/contracts/risk-calibration.mjs +44 -29
  87. package/packages/contracts/schema-lifecycle.mjs +3 -1
  88. package/packages/contracts/web-capture.mjs +1 -1
  89. package/packages/contracts/workspace-portability.mjs +86 -16
  90. package/packages/evidence/collection-quality.mjs +1 -1
  91. package/packages/evidence/credential-form-attribution.mjs +60 -0
  92. package/packages/evidence/image-regions.mjs +15 -0
  93. package/packages/evidence/record-identity.mjs +60 -16
  94. package/packages/evidence/technology-evidence-role.mjs +51 -0
  95. package/packages/interchange/external-findings-import.mjs +32 -17
  96. package/packages/interchange/json-output.mjs +14 -0
  97. package/packages/interchange/managed-indicator-export.mjs +3 -2
  98. package/packages/interchange/managed-indicator-set.mjs +13 -0
  99. package/packages/investigation/acquisition-decision-packet.mjs +78 -0
  100. package/packages/investigation/context-review.mjs +3 -3
  101. package/packages/investigation/document-intake.mjs +9 -3
  102. package/packages/investigation/domain-history-review.mjs +43 -11
  103. package/packages/investigation/domain-transition-review.mjs +2 -0
  104. package/packages/investigation/infrastructure-observation.mjs +234 -0
  105. package/packages/investigation/intake-context.mjs +284 -0
  106. package/packages/investigation/intake-indicators.mjs +116 -0
  107. package/packages/investigation/intake-phones.mjs +155 -0
  108. package/packages/investigation/intake-report.mjs +2 -1
  109. package/packages/investigation/investigation-manifest.mjs +33 -7
  110. package/packages/investigation/link-intake.mjs +38 -8
  111. package/packages/investigation/message-intake.mjs +55 -15
  112. package/packages/monitoring/domain-feed.mjs +321 -0
  113. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  114. package/packages/workspace/brand-keyword-campaign.mjs +231 -0
  115. package/packages/workspace/brand-profile-model.mjs +46 -2
  116. package/packages/workspace/bulk-session-model.mjs +4 -0
  117. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  118. package/packages/workspace/detection-rule-model.mjs +10 -2
  119. package/packages/workspace/shortlist-model.mjs +10 -2
  120. package/packages/workspace/watchlist-history.mjs +31 -4
  121. package/packages/workspace/watchlist-store.mjs +136 -7
  122. package/third-party-notices.txt +29 -29
@@ -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,
@@ -99,15 +99,19 @@ function withTimeout(promise, timeoutMs) {
99
99
  });
100
100
  }
101
101
  async function defaultAuthorityQuery(input) {
102
+ input.signal?.throwIfAborted();
102
103
  const resolver = new dns.Resolver({
103
104
  timeout: Math.max(250, Math.min(input.timeoutMs, DNS_DELEGATION_TIMEOUT_MS)),
104
105
  tries: 1,
105
106
  });
106
107
  resolver.setServers([net.isIP(input.address) === 6 ? `[${input.address}]:53` : input.address]);
108
+ const cancelled = () => resolver.cancel();
109
+ input.signal?.addEventListener('abort', cancelled, { once: true });
107
110
  const [nameservers, soa] = await Promise.allSettled([
108
111
  resolver.resolveNs(input.domain),
109
112
  resolver.resolveSoa(input.domain),
110
- ]);
113
+ ]).finally(() => { input.signal?.removeEventListener('abort', cancelled); resolver.cancel(); });
114
+ input.signal?.throwIfAborted();
111
115
  const reason = nameservers.status === 'rejected'
112
116
  ? nameservers.reason
113
117
  : soa.status === 'rejected'
@@ -188,18 +192,21 @@ function normaliseAuthorityValues(type, value) {
188
192
  return normaliseAuthorityValueSet(type, value).values;
189
193
  }
190
194
  async function defaultAuthorityRecordQuery(input) {
195
+ input.signal?.throwIfAborted();
191
196
  const resolver = new dns.Resolver({
192
197
  timeout: Math.max(250, Math.min(input.timeoutMs, DNS_DELEGATION_TIMEOUT_MS)),
193
198
  tries: 1,
194
199
  });
195
200
  resolver.setServers([net.isIP(input.address) === 6 ? `[${input.address}]:53` : input.address]);
201
+ const cancelled = () => resolver.cancel();
202
+ input.signal?.addEventListener('abort', cancelled, { once: true });
196
203
  const queries = [
197
204
  ['A', resolver.resolve4(input.domain)],
198
205
  ['AAAA', resolver.resolve6(input.domain)],
199
206
  ['CAA', resolver.resolveCaa(input.domain)],
200
207
  ['MX', resolver.resolveMx(input.domain)],
201
208
  ];
202
- return Promise.all(queries.map(async ([type, promise]) => {
209
+ const result = await Promise.all(queries.map(async ([type, promise]) => {
203
210
  try {
204
211
  const response = await withTimeout(promise, input.timeoutMs);
205
212
  const normalised = normaliseAuthorityValueSet(type, response);
@@ -224,7 +231,9 @@ async function defaultAuthorityRecordQuery(input) {
224
231
  discarded: 0,
225
232
  };
226
233
  }
227
- }));
234
+ })).finally(() => { input.signal?.removeEventListener('abort', cancelled); resolver.cancel(); });
235
+ input.signal?.throwIfAborted();
236
+ return result;
228
237
  }
229
238
  function authorityRecordMatrix(authorities) {
230
239
  const types = ['A', 'AAAA', 'CAA', 'MX'];
@@ -318,6 +327,7 @@ function skippedDnsDelegationHealth(detail = 'Authoritative delegation checks we
318
327
  };
319
328
  }
320
329
  async function collectDnsDelegationHealth(domainValue, parentQueryValue, options = {}) {
330
+ options.signal?.throwIfAborted();
321
331
  const domain = hostname(domainValue);
322
332
  if (!domain)
323
333
  return skippedDnsDelegationHealth('The domain was not eligible for authoritative delegation checks.');
@@ -338,7 +348,8 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
338
348
  const timeoutMs = Math.max(250, Math.min(5000, Number(options.timeoutMs) || DNS_DELEGATION_TIMEOUT_MS));
339
349
  const now = options.now || Date.now;
340
350
  const started = now();
341
- const authorities = await Promise.all(candidates.map(async (nameserver) => {
351
+ const pending = candidates.map(async (nameserver) => {
352
+ options.signal?.throwIfAborted();
342
353
  const glue = registry.nameserverDetails.find((item) => item.name === nameserver)?.addresses ?? [];
343
354
  const resolved = glue.length
344
355
  ? glue
@@ -350,10 +361,11 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
350
361
  : [])
351
362
  .map(publicAddress)
352
363
  .filter((address) => address !== null);
364
+ options.signal?.throwIfAborted();
353
365
  const addresses = [...new Set(resolved)].slice(0, MAX_AUTHORITY_ADDRESSES);
354
366
  const attempts = await Promise.all(addresses.map(async (address) => {
355
367
  try {
356
- const raw = await withTimeout(queryAuthority({ domain, nameserver, address, timeoutMs }), timeoutMs);
368
+ const raw = await withTimeout(queryAuthority({ domain, nameserver, address, timeoutMs, ...(options.signal ? { signal: options.signal } : {}) }), timeoutMs);
357
369
  const query = record(raw);
358
370
  const code = boundedText(query.errorCode, 40);
359
371
  const error = boundedText(query.error, MAX_ERROR_LENGTH);
@@ -384,6 +396,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
384
396
  };
385
397
  }
386
398
  }));
399
+ options.signal?.throwIfAborted();
387
400
  const successful = attempts.find((attempt) => attempt.state === 'success');
388
401
  const partial = attempts.find((attempt) => attempt.state === 'partial');
389
402
  const retained = successful ?? partial;
@@ -391,7 +404,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
391
404
  const recordAddress = retained?.address ?? addresses[0];
392
405
  if (queryAuthorityRecords && recordAddress) {
393
406
  try {
394
- recordSets = (await withTimeout(queryAuthorityRecords({ domain, nameserver, address: recordAddress, timeoutMs }), timeoutMs))
407
+ recordSets = (await withTimeout(queryAuthorityRecords({ domain, nameserver, address: recordAddress, timeoutMs, ...(options.signal ? { signal: options.signal } : {}) }), timeoutMs))
395
408
  .filter((item) => ['A', 'AAAA', 'CAA', 'MX'].includes(item.type))
396
409
  .slice(0, 4)
397
410
  .map((item) => {
@@ -428,6 +441,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
428
441
  }));
429
442
  }
430
443
  }
444
+ options.signal?.throwIfAborted();
431
445
  return {
432
446
  nameserver,
433
447
  addressSource: glue.length ? 'registry_glue' : 'recursive_address',
@@ -445,7 +459,9 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
445
459
  recordSets,
446
460
  attempts,
447
461
  };
448
- }));
462
+ });
463
+ const authorities = await Promise.all(pending).finally(() => Promise.allSettled(pending));
464
+ options.signal?.throwIfAborted();
449
465
  const successfulAuthorities = authorities.filter((authority) => authority.state === 'success');
450
466
  const partialAuthorities = authorities.filter((authority) => authority.state === 'partial');
451
467
  const lameAuthorities = authorities.filter((authority) => authority.state === 'lame');
@@ -416,6 +416,7 @@ function observedPresence(result) {
416
416
  return result.records.length > 0 ? true : completeQuery(result) ? false : null;
417
417
  }
418
418
  async function collectEffectiveCaaPolicy(domain, options = {}) {
419
+ options.signal?.throwIfAborted();
419
420
  const normalizedDomain = normalizeHostname(domain);
420
421
  const queryLimit = Math.max(1, Math.min(MAX_CAA_TREE_QUERIES, Math.floor(options.queryLimit || MAX_CAA_TREE_QUERIES)));
421
422
  const now = options.now || Date.now;
@@ -455,9 +456,11 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
455
456
  const timeoutMs = options.timeoutMs || DNS_TIMEOUT_MS;
456
457
  const queries = [];
457
458
  for (const [index, owner] of owners.entries()) {
459
+ options.signal?.throwIfAborted();
458
460
  const result = index === 0 && options.directResult
459
461
  ? options.directResult
460
462
  : await query(() => resolver(owner), normalizeCaa, timeoutMs);
463
+ options.signal?.throwIfAborted();
461
464
  queries.push({
462
465
  owner,
463
466
  status: result.status,
@@ -582,6 +585,7 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
582
585
  };
583
586
  }
584
587
  async function collectDnsIntelligence(domain, options = {}) {
588
+ options.signal?.throwIfAborted();
585
589
  const registrationDomain = options.registrationDomain ?? domain;
586
590
  if (options.registrationDomain !== undefined && canonicalRegistrableDomain(domain) !== registrationDomain) {
587
591
  throw new TypeError('DNS registration context must match the hostname being observed.');
@@ -592,7 +596,10 @@ async function collectDnsIntelligence(domain, options = {}) {
592
596
  const timeoutMs = options.timeoutMs || DNS_TIMEOUT_MS;
593
597
  const now = options.now || Date.now;
594
598
  const started = now();
595
- const invoke = (name, fallback, value = domain) => () => (resolvers[name] || fallback)(value);
599
+ const invoke = (name, fallback, value = domain) => () => {
600
+ options.signal?.throwIfAborted();
601
+ return (resolvers[name] || fallback)(value);
602
+ };
596
603
  const aPromise = query(invoke('resolve4', dns.resolve4), (records) => normalizeAddresses(records, 4), timeoutMs);
597
604
  const aaaaPromise = query(invoke('resolve6', dns.resolve6), (records) => normalizeAddresses(records, 6), timeoutMs);
598
605
  const cnamePromise = query(invoke('resolveCname', dns.resolveCname), normalizeHostnames, timeoutMs);
@@ -607,6 +614,7 @@ async function collectDnsIntelligence(domain, options = {}) {
607
614
  directResult,
608
615
  timeoutMs,
609
616
  now,
617
+ ...(options.signal ? { signal: options.signal } : {}),
610
618
  ...(options.observedAt ? { observedAt: options.observedAt } : {}),
611
619
  }))
612
620
  : Promise.resolve(null);
@@ -614,7 +622,7 @@ async function collectDnsIntelligence(domain, options = {}) {
614
622
  ? query(invoke('resolveSoa', dns.resolveSoa), normalizeSoa, timeoutMs)
615
623
  : Promise.resolve(null);
616
624
  const httpsPromise = includeExtendedContext
617
- ? query(invoke('resolveHttps', (value) => resolveServiceBindingRecords(value, 'HTTPS', { timeoutMs })), normalizeServiceBindings, timeoutMs)
625
+ ? query(invoke('resolveHttps', (value) => resolveServiceBindingRecords(value, 'HTTPS', { timeoutMs, ...(options.signal ? { signal: options.signal } : {}) })), normalizeServiceBindings, timeoutMs)
618
626
  : Promise.resolve(null);
619
627
  const registrationNsPromise = includeExtendedContext && registrationDomain !== domain
620
628
  ? query(invoke('resolveNs', dns.resolveNs, registrationDomain), normalizeHostnames, timeoutMs)
@@ -627,10 +635,11 @@ async function collectDnsIntelligence(domain, options = {}) {
627
635
  ...(options.queryAuthority ? { queryAuthority: options.queryAuthority } : {}),
628
636
  timeoutMs,
629
637
  now,
638
+ ...(options.signal ? { signal: options.signal } : {}),
630
639
  ...(options.observedAt ? { observedAt: options.observedAt } : {}),
631
640
  }))
632
641
  : Promise.resolve(null);
633
- const [a, aaaa, cname, ns, mx, spf, dmarc, caa, caaPolicy, soa, https, delegation] = await Promise.all([
642
+ const pending = [
634
643
  aPromise,
635
644
  aaaaPromise,
636
645
  cnamePromise,
@@ -643,7 +652,12 @@ async function collectDnsIntelligence(domain, options = {}) {
643
652
  soaPromise,
644
653
  httpsPromise,
645
654
  delegationPromise,
646
- ]);
655
+ ];
656
+ // A rejected continuation must not release the lookup while sibling queries
657
+ // are still running. Their existing bounded resolver deadlines remain active.
658
+ const [a, aaaa, cname, ns, mx, spf, dmarc, caa, caaPolicy, soa, https, delegation] = await Promise.all(pending)
659
+ .finally(() => Promise.allSettled(pending));
660
+ options.signal?.throwIfAborted();
647
661
  const queries = {
648
662
  a, aaaa, cname, ns, mx, spf, dmarc, caa,
649
663
  ...(soa ? { soa } : {}),