@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.
- package/DISCLOSURE +1 -1
- package/cli/artifact-validation/case-response.mjs +51 -8
- package/cli/artifact-validation/lookup-evidence.mjs +15 -1
- package/cli/artifact-validation/signed-review.mjs +2 -51
- package/cli/artifact-verify.mjs +9 -2
- package/cli/bulk-command-runner.mjs +4 -6
- package/cli/case-command.mjs +79 -3
- package/cli/case-pack.mjs +7 -2
- package/cli/command-definition.mjs +2 -1
- package/cli/discovery-scan-command-runner.mjs +4 -6
- package/cli/domain-feed.mjs +61 -0
- package/cli/formatters/json.mjs +1 -13
- package/cli/indicator-set-command.mjs +1 -2
- package/cli/intake-command.mjs +43 -3
- package/cli/investigation-artifacts.mjs +2 -3
- package/cli/investigation-package-review.mjs +2 -0
- package/cli/local-mmdb-review.mjs +27 -4
- package/cli/lookup-command-runner.mjs +1 -0
- package/cli/offline-evidence-review.mjs +51 -5
- package/cli/review-arguments.mjs +3 -2
- package/cli/review-command-definitions.mjs +14 -8
- package/cli/review-command-runner.mjs +6 -3
- package/cli/risk-calibration.mjs +6 -4
- package/cli/selected-input-worker.mjs +12 -2
- package/cli/support-command-runner.mjs +3 -3
- package/cli/watchlist-review.mjs +43 -0
- package/cli/workflow-arguments.mjs +14 -0
- package/cli/workflow-command-definitions.mjs +26 -1
- package/cli/workflow-command-runner.mjs +32 -0
- package/docs/cli.md +99 -2
- package/docs/contextual-reviews.md +28 -5
- package/docs/infrastructure-observations.md +126 -0
- package/docs/offline-intake-context.md +213 -0
- package/lib/credential-form-attribution.mjs +136 -0
- package/lib/credential-surface-profile.mjs +6 -2
- package/lib/dns-delegation-health.mjs +23 -7
- package/lib/dns-intelligence.mjs +18 -4
- package/lib/domain-posture-analysis.mjs +53 -2
- package/lib/domain-posture-parsers.mjs +9 -0
- package/lib/domain-posture.mjs +29 -10
- package/lib/evidence-export-privacy.mjs +5 -2
- package/lib/evidence-report.mjs +1 -0
- package/lib/favicon.mjs +13 -4
- package/lib/html-signals.mjs +6 -0
- package/lib/lookup-child-profile-contract.mjs +28 -11
- package/lib/lookup-response-contract.mjs +1 -0
- package/lib/operation-budget.mjs +4 -0
- package/lib/service-binding-dns.mjs +19 -2
- package/lib/sslbl-certificates.generated.mjs +138 -137
- package/lib/static-html-analysis.mjs +32 -4
- package/lib/technology-evidence-role.mjs +1 -51
- package/lib/tls-intelligence.mjs +30 -5
- package/lib/website-enrichment.mjs +39 -23
- package/lib/whois-chain.mjs +12 -3
- package/lib/whois-common-formats.mjs +3 -1
- package/lib/whois-contacts.mjs +13 -2
- package/package.json +7 -5
- package/packages/analysis/risk-scoring.mjs +12 -9
- package/packages/cases/case-containment-handoff.mjs +195 -0
- package/packages/cases/case-evidence-links.mjs +16 -0
- package/packages/cases/case-evidence-model.mjs +14 -3
- package/packages/cases/case-object-coverage.mjs +33 -0
- package/packages/cases/case-packet-correction.mjs +106 -0
- package/packages/cases/case-recheck-model.mjs +35 -5
- package/packages/cases/case-record-operations.mjs +74 -9
- package/packages/cases/case-record-projection.mjs +15 -3
- package/packages/cases/case-record-version-input.mjs +23 -1
- package/packages/cases/case-report.mjs +30 -0
- package/packages/cases/case-requested-evidence.mjs +1 -1
- package/packages/cases/case-response-actions.mjs +57 -1
- package/packages/cases/case-response-model.mjs +7 -0
- package/packages/cases/case-response-object.mjs +119 -0
- package/packages/cases/case-response-outcomes.mjs +81 -14
- package/packages/cases/case-response-packet-render.mjs +22 -3
- package/packages/cases/case-response-packet.mjs +30 -2
- package/packages/cases/case-response-review-inputs.mjs +48 -7
- package/packages/cases/case-workflow-metadata.mjs +1 -9
- package/packages/contracts/analyst-interchange.mjs +7 -5
- package/packages/contracts/candidate-watch-review.mjs +54 -0
- package/packages/contracts/case-portability.mjs +260 -42
- package/packages/contracts/cli-command-semantics.mjs +3 -0
- package/packages/contracts/context-review.mjs +2 -1
- package/packages/contracts/external-observation-interchange.mjs +96 -28
- package/packages/contracts/lookup-evidence.mjs +3 -2
- package/packages/contracts/message-intake.mjs +7 -1
- package/packages/contracts/risk-calibration.mjs +44 -29
- package/packages/contracts/schema-lifecycle.mjs +3 -1
- package/packages/contracts/web-capture.mjs +1 -1
- package/packages/contracts/workspace-portability.mjs +86 -16
- package/packages/evidence/collection-quality.mjs +1 -1
- package/packages/evidence/credential-form-attribution.mjs +60 -0
- package/packages/evidence/image-regions.mjs +15 -0
- package/packages/evidence/record-identity.mjs +60 -16
- package/packages/evidence/technology-evidence-role.mjs +51 -0
- package/packages/interchange/external-findings-import.mjs +32 -17
- package/packages/interchange/json-output.mjs +14 -0
- package/packages/interchange/managed-indicator-export.mjs +3 -2
- package/packages/interchange/managed-indicator-set.mjs +13 -0
- package/packages/investigation/acquisition-decision-packet.mjs +78 -0
- package/packages/investigation/context-review.mjs +3 -3
- package/packages/investigation/document-intake.mjs +9 -3
- package/packages/investigation/domain-history-review.mjs +43 -11
- package/packages/investigation/domain-transition-review.mjs +2 -0
- package/packages/investigation/infrastructure-observation.mjs +234 -0
- package/packages/investigation/intake-context.mjs +284 -0
- package/packages/investigation/intake-indicators.mjs +116 -0
- package/packages/investigation/intake-phones.mjs +155 -0
- package/packages/investigation/intake-report.mjs +2 -1
- package/packages/investigation/investigation-manifest.mjs +33 -7
- package/packages/investigation/link-intake.mjs +38 -8
- package/packages/investigation/message-intake.mjs +55 -15
- package/packages/monitoring/domain-feed.mjs +321 -0
- package/packages/workspace/brand-candidate-workflow.mjs +351 -0
- package/packages/workspace/brand-keyword-campaign.mjs +231 -0
- package/packages/workspace/brand-profile-model.mjs +46 -2
- package/packages/workspace/bulk-session-model.mjs +4 -0
- package/packages/workspace/candidate-watch-handoff.mjs +195 -0
- package/packages/workspace/detection-rule-model.mjs +10 -2
- package/packages/workspace/shortlist-model.mjs +10 -2
- package/packages/workspace/watchlist-history.mjs +31 -4
- package/packages/workspace/watchlist-store.mjs +136 -7
- 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
|
-
'
|
|
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
|
-
|
|
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
|
|
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');
|
package/lib/dns-intelligence.mjs
CHANGED
|
@@ -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) => () =>
|
|
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
|
|
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 } : {}),
|