@slicedearth/whoisleuth-cli 2.4.0 → 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/NOTICE +2 -2
- package/cli/argument-values.mjs +55 -0
- package/cli/arguments.mjs +24 -470
- package/cli/artifact-validation/case-response.mjs +67 -9
- package/cli/artifact-validation/lookup-evidence.mjs +18 -4
- package/cli/artifact-validation/signed-review.mjs +61 -64
- package/cli/artifact-verify.mjs +11 -2
- package/cli/assurance-arguments.mjs +56 -0
- package/cli/assurance-command-definitions.mjs +179 -0
- package/cli/assurance-command-runner.mjs +1 -1
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +1 -1
- package/cli/bulk-command-runner.mjs +6 -8
- package/cli/bulk.mjs +2 -3
- package/cli/case-command.mjs +99 -6
- package/cli/case-pack.mjs +19 -4
- package/cli/ci-report.mjs +6 -3
- package/cli/collection-arguments.mjs +123 -0
- package/cli/collection-command-definitions.mjs +330 -0
- package/cli/collection-preflight.mjs +8 -1
- package/cli/command-argument-grammar.mjs +8 -210
- package/cli/command-definition.mjs +330 -0
- package/cli/command-reference.mjs +55 -1388
- package/cli/command-seeds.mjs +18 -0
- package/cli/compare.mjs +1 -1
- package/cli/config-profile.mjs +1 -1
- package/cli/ct-event-intake.mjs +4 -4
- package/cli/discover.mjs +2 -2
- package/cli/discovery-observation-snapshot.mjs +2 -2
- package/cli/discovery-scan-command-runner.mjs +7 -9
- package/cli/discovery-scan.mjs +1 -1
- package/cli/discovery-snapshot.mjs +2 -2
- package/cli/discovery-workflow.mjs +4 -4
- package/cli/domain-control-monitor.mjs +6 -6
- package/cli/domain-control-observations.mjs +2 -1
- package/cli/domain-feed.mjs +61 -0
- package/cli/errors.mjs +5 -6
- package/cli/evidence-arguments.mjs +46 -0
- package/cli/evidence-command-definitions.mjs +69 -0
- package/cli/evidence-command-runner.mjs +2 -3
- package/cli/evidence-signing.mjs +3 -2
- package/cli/external-observation-mapping.mjs +2 -2
- package/cli/formatters/json.mjs +1 -13
- package/cli/formatters/terminal-command-formats.mjs +6 -0
- package/cli/formatters/terminal-lookup.mjs +6 -0
- package/cli/history-arguments.mjs +39 -0
- package/cli/history-command-definitions.mjs +88 -0
- package/cli/indicator-set-command.mjs +32 -0
- package/cli/inline-command-families.mjs +6 -47
- package/cli/inline-command-runner.mjs +4 -31
- package/cli/input.mjs +2 -2
- package/cli/intake-command.mjs +147 -0
- package/cli/interchange-report.mjs +4 -2
- package/cli/investigation-artifacts.mjs +3 -4
- package/cli/investigation-package-review.mjs +2 -0
- package/cli/investigation-plan.mjs +32 -154
- package/cli/investigation-recipes.mjs +177 -0
- package/cli/investigation-run.mjs +78 -12
- package/cli/local-mmdb-review.mjs +220 -17
- package/cli/lookalike-calibration.mjs +1 -1
- package/cli/lookup-brief.mjs +2 -2
- package/cli/lookup-command-runner.mjs +19 -35
- package/cli/lookup-diff.mjs +10 -9
- package/cli/lookup-plan.mjs +10 -14
- package/cli/lookup-reconcile.mjs +6 -5
- package/cli/lookup-timeline.mjs +7 -6
- package/cli/mail-header-review.mjs +24 -247
- package/cli/mail-review.mjs +9 -7
- package/cli/network-arguments.mjs +51 -0
- package/cli/network-command-definitions.mjs +130 -0
- package/cli/network-command-runner.mjs +43 -31
- package/cli/offline-evidence-review.mjs +85 -6
- package/cli/posture.mjs +1 -1
- package/cli/registry-fixture-scaffold.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +8 -7
- package/cli/review-arguments.mjs +132 -0
- package/cli/review-command-definitions.mjs +279 -0
- package/cli/review-command-runner.mjs +72 -159
- package/cli/risk-calibration.mjs +14 -12
- package/cli/runner.mjs +20 -32
- package/cli/saved-lookup.mjs +2 -2
- package/cli/selected-input-worker.mjs +73 -0
- package/cli/sharing-review.mjs +5 -64
- package/cli/source-reliability.mjs +2 -2
- package/cli/support-arguments.mjs +49 -0
- package/cli/support-command-definitions.mjs +209 -0
- package/cli/support-command-runner.mjs +4 -4
- package/cli/watchlist-review.mjs +43 -0
- package/cli/workflow-arguments.mjs +100 -0
- package/cli/workflow-command-definitions.mjs +103 -0
- package/cli/workflow-command-runner.mjs +40 -1
- package/docs/cli-reference.md +27 -0
- package/docs/cli.md +322 -4
- package/docs/contextual-reviews.md +178 -0
- package/docs/infrastructure-observations.md +126 -0
- package/docs/offline-intake-context.md +213 -0
- package/lib/analyst-taxonomy.mjs +2 -40
- package/lib/application-version.mjs +1 -1
- package/lib/availability.mjs +13 -789
- package/lib/bounded-contract-normalizers.mjs +4 -0
- package/lib/bounded-json.mjs +2 -330
- package/lib/browser-library-profile.mjs +62 -84
- package/lib/candidate-provenance-bounds.mjs +2 -0
- package/lib/classify.mjs +1 -1
- package/lib/credential-form-attribution.mjs +136 -0
- package/lib/credential-surface-profile.mjs +6 -2
- package/lib/ct-query.mjs +2 -27
- package/lib/ct-response-bounds.mjs +2 -9
- package/lib/ct-search.mjs +83 -47
- package/lib/dmarc-discovery.mjs +62 -0
- package/lib/dns-convergence-review.mjs +21 -8
- package/lib/dns-delegation-health.mjs +35 -15
- package/lib/dns-inheritance-review.mjs +53 -63
- package/lib/dns-intelligence.mjs +27 -11
- package/lib/dns-query-coverage.mjs +40 -0
- package/lib/domain-assurance.mjs +1 -1
- package/lib/domain-change-packet.mjs +12 -13
- package/lib/domain-change-review.mjs +32 -14
- package/lib/domain-control-flight-recorder.mjs +103 -27
- package/lib/domain-control-manifest.mjs +25 -17
- package/lib/domain-posture-analysis.mjs +75 -7
- package/lib/domain-posture-parsers.mjs +9 -0
- package/lib/domain-posture.mjs +45 -25
- package/lib/domain-registration.mjs +392 -0
- package/lib/evidence-export-privacy.mjs +27 -17
- package/lib/evidence-export.mjs +3 -3
- package/lib/evidence-report.mjs +5 -1
- package/lib/external-intelligence-risk.mjs +2 -124
- package/lib/favicon.mjs +13 -4
- package/lib/generated/cisa-kev-catalog.mjs +41 -3
- package/lib/generated/registrar-standing-catalogue.mjs +18 -10
- package/lib/generated/retire-browser-catalog.mjs +332 -19
- package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
- package/lib/html-signals.mjs +13 -2
- package/lib/http-evidence-bounds.mjs +2 -8
- package/lib/http-intelligence.mjs +13 -1
- package/lib/idn-confusable-policy.mjs +6 -4
- package/lib/idn-confusables.mjs +1 -1
- package/lib/interchange-fidelity-registry.mjs +10 -0
- package/lib/lookup-child-profile-contract.mjs +28 -11
- package/lib/lookup-contract-primitives.mjs +1 -1
- package/lib/lookup-diagnostics.mjs +1 -23
- package/lib/lookup-network-evidence-bounds.mjs +1 -10
- package/lib/lookup-progress.mjs +50 -1
- package/lib/lookup-response-contract.mjs +70 -40
- package/lib/lookup-response.mjs +5 -15
- package/lib/lookup-source-progress.mjs +8 -22
- package/lib/lookup.mjs +1 -1
- package/lib/operation-budget.mjs +4 -0
- package/lib/opportunity-scoring.mjs +2 -107
- package/lib/outbound-identity.mjs +1 -1
- package/lib/page-fingerprints.mjs +18 -1
- package/lib/perceptual-hash-comparison.mjs +2 -34
- package/lib/perceptual-hash.mjs +36 -10
- package/lib/portable-generator.mjs +2 -29
- package/lib/project-metadata.mjs +2 -8
- package/lib/rdap-client.mjs +11 -7
- package/lib/registrable-domain.mjs +2 -56
- package/lib/registrar-standing-contract.mjs +1 -1
- package/lib/registrar-standing.mjs +9 -13
- package/lib/registry-capabilities.mjs +2 -2
- package/lib/registry-capability-catalogue.mjs +27 -19
- package/lib/risk-calibration-summary.mjs +2 -352
- package/lib/risk-scoring.mjs +2 -345
- package/lib/scoring-evidence-quality.mjs +2 -107
- package/lib/semantic-version.mjs +2 -54
- package/lib/service-binding-dns.mjs +46 -2
- package/lib/smtp-transport-review.mjs +48 -19
- package/lib/sslbl-certificates.generated.mjs +142 -139
- package/lib/static-html-analysis.mjs +32 -4
- package/lib/structured-data-identity.mjs +1 -1
- package/lib/technology-evidence-role.mjs +1 -51
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threat-intelligence-runtime.mjs +1 -1
- package/lib/threat-intelligence-targets.mjs +1 -1
- package/lib/threat-intelligence-types.mjs +2 -75
- package/lib/tls-intelligence.mjs +30 -5
- package/lib/typosquat-generator.mjs +11 -2
- package/lib/website-enrichment.mjs +404 -0
- package/lib/whois-authority.mjs +43 -5
- package/lib/whois-chain.mjs +12 -3
- package/lib/whois-common-formats.mjs +3 -1
- package/lib/whois-contacts.mjs +13 -2
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-parser.mjs +5 -3
- package/package.json +15 -14
- package/packages/analysis/analyst-taxonomy.mjs +40 -0
- package/packages/analysis/bounded-json.mjs +330 -0
- package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
- package/packages/analysis/cli-argument-grammar.mjs +216 -0
- package/packages/analysis/ct-query.mjs +27 -0
- package/packages/analysis/ct-response-bounds.mjs +9 -0
- package/packages/analysis/external-intelligence-risk.mjs +124 -0
- package/packages/analysis/http-evidence-bounds.mjs +8 -0
- package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
- package/packages/analysis/opportunity-scoring.mjs +107 -0
- package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
- package/packages/analysis/portable-generator.mjs +29 -0
- package/packages/analysis/project-metadata.mjs +8 -0
- package/packages/analysis/registrable-domain.mjs +56 -0
- package/packages/analysis/risk-calibration-summary.mjs +352 -0
- package/packages/analysis/risk-scoring.mjs +348 -0
- package/packages/analysis/scoring-evidence-quality.mjs +107 -0
- package/packages/analysis/semantic-version.mjs +54 -0
- package/packages/analysis/threat-intelligence-types.mjs +75 -0
- package/packages/cases/case-containment-handoff.mjs +195 -0
- package/packages/cases/case-evidence-links.mjs +151 -0
- package/packages/cases/case-evidence-model.mjs +89 -12
- package/packages/cases/case-evidence-quality.mjs +5 -0
- package/packages/cases/case-export-input.mjs +7 -5
- package/packages/cases/case-incident-context.mjs +52 -0
- package/packages/cases/case-markdown.mjs +11 -0
- package/packages/cases/case-migration-model.mjs +40 -24
- 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 +124 -36
- package/packages/cases/case-record-contracts.mjs +1 -1
- package/packages/cases/case-record-core.mjs +1 -1
- package/packages/cases/case-record-operations.mjs +111 -118
- package/packages/cases/case-record-projection.mjs +23 -4
- package/packages/cases/case-record-version-input.mjs +41 -0
- package/packages/cases/case-report.mjs +93 -27
- package/packages/cases/case-requested-evidence.mjs +146 -0
- package/packages/cases/case-response-actions.mjs +159 -1
- package/packages/cases/case-response-model.mjs +16 -1
- package/packages/cases/case-response-object.mjs +119 -0
- package/packages/cases/case-response-outcomes.mjs +108 -20
- package/packages/cases/case-response-packet-render.mjs +35 -12
- package/packages/cases/case-response-packet.mjs +55 -13
- package/packages/cases/case-response-records.mjs +0 -1
- package/packages/cases/case-response-review-inputs.mjs +60 -8
- package/packages/cases/case-storage-model.mjs +1 -1
- package/packages/cases/case-workflow-metadata.mjs +217 -0
- package/packages/cases/case-workflow-migration.mjs +43 -0
- package/packages/cases/http-summary.mjs +6 -1
- package/packages/comparison/favicon-similarity.mjs +1 -1
- package/packages/comparison/page-similarity.mjs +1 -1
- package/packages/comparison/relationship-evidence.mjs +21 -9
- package/packages/comparison/relationship-provenance.mjs +7 -1
- package/packages/contracts/analyst-interchange.mjs +35 -15
- package/packages/contracts/candidate-watch-review.mjs +54 -0
- package/packages/contracts/case-portability.mjs +360 -49
- package/packages/contracts/cli-command-semantics.mjs +5 -0
- package/packages/contracts/cli-grammar.mjs +6 -0
- package/packages/contracts/cli-lookup-brief.mjs +2 -0
- package/packages/contracts/context-review.mjs +25 -0
- package/packages/contracts/document-intake.mjs +11 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
- package/packages/contracts/domain-control-manifest.mjs +1 -1
- package/packages/contracts/domain-control-monitor.mjs +13 -2
- package/packages/contracts/domain-control-review.mjs +36 -15
- package/packages/contracts/external-observation-interchange.mjs +96 -28
- package/packages/contracts/har-review.mjs +2 -0
- package/packages/contracts/identity-events.mjs +4 -0
- package/packages/contracts/investigation-run.mjs +2 -0
- package/packages/contracts/ip-address.mjs +10 -0
- package/packages/contracts/lookup-evidence.mjs +3 -2
- package/packages/contracts/mail-authentication.mjs +5 -0
- package/packages/contracts/message-intake.mjs +37 -0
- package/packages/contracts/offline-comparison.mjs +4 -1
- package/packages/contracts/passive-mail.mjs +10 -0
- package/packages/contracts/risk-calibration.mjs +44 -29
- package/packages/contracts/schema-lifecycle.mjs +3 -1
- package/packages/contracts/web-capture.mjs +2 -2
- package/packages/contracts/workspace-portability.mjs +144 -28
- package/packages/evidence/collection-quality.mjs +67 -0
- package/packages/evidence/credential-form-attribution.mjs +60 -0
- package/packages/evidence/dns-query-outcome.mjs +72 -0
- package/packages/evidence/email-recipient.mjs +18 -0
- package/packages/evidence/image-regions.mjs +15 -0
- package/packages/evidence/lookup-target.mjs +5 -1
- package/packages/evidence/observation.mjs +2 -0
- package/packages/evidence/png-pixels.mjs +117 -0
- package/packages/evidence/public-address-policy.mjs +83 -0
- package/packages/evidence/record-identity.mjs +60 -16
- package/packages/evidence/technology-evidence-role.mjs +51 -0
- package/packages/interchange/defensive-indicator-export.mjs +264 -0
- package/packages/interchange/external-findings-import.mjs +36 -29
- package/packages/interchange/json-output.mjs +14 -0
- package/packages/interchange/managed-indicator-export.mjs +36 -0
- package/packages/interchange/managed-indicator-set.mjs +223 -0
- package/packages/interchange/misp-indicator-export.mjs +93 -0
- package/packages/interchange/sharing-policy.mjs +56 -0
- package/packages/interchange/stix-indicator-export.mjs +129 -0
- package/packages/interchange/web-capture-import.mjs +22 -15
- package/packages/investigation/acquisition-decision-packet.mjs +79 -1
- package/packages/investigation/bulk-mail-exposure.mjs +7 -13
- package/packages/investigation/capture-coverage.mjs +57 -0
- package/packages/investigation/connector-provenance-review.mjs +100 -0
- package/packages/investigation/context-review.mjs +35 -0
- package/packages/investigation/document-intake.mjs +87 -0
- package/packages/investigation/docx-intake.mjs +133 -0
- package/packages/investigation/domain-history-review.mjs +132 -0
- package/packages/investigation/domain-transition-review.mjs +49 -0
- package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
- package/packages/investigation/har-intake.mjs +68 -0
- package/packages/investigation/identity-event-intake.mjs +131 -0
- package/packages/investigation/identity-incident-review.mjs +39 -0
- package/packages/investigation/incident-sequence-review.mjs +58 -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 +20 -0
- package/packages/investigation/investigation-manifest.mjs +35 -9
- package/packages/investigation/link-intake.mjs +209 -0
- package/packages/investigation/lookup-asset-graph.mjs +1 -1
- package/packages/investigation/mail-authentication-review.mjs +222 -0
- package/packages/investigation/mail-header-identity.mjs +209 -0
- package/packages/investigation/message-intake.mjs +235 -0
- package/packages/investigation/page-behaviour.mjs +113 -0
- package/packages/investigation/pdf-intake.mjs +155 -0
- package/packages/investigation/platform-continuity-review.mjs +60 -0
- package/packages/investigation/qr-intake.mjs +56 -0
- package/packages/investigation/requested-action-hints.mjs +11 -0
- package/packages/investigation/selected-input-review.mjs +17 -0
- package/packages/investigation/storefront-review.mjs +70 -0
- 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 +48 -4
- package/packages/workspace/bulk-review-model.mjs +28 -26
- package/packages/workspace/bulk-session-model.mjs +27 -13
- package/packages/workspace/campaign-model.mjs +1 -1
- package/packages/workspace/candidate-watch-handoff.mjs +195 -0
- package/packages/workspace/detection-rule-model.mjs +11 -3
- package/packages/workspace/hostile-input.mjs +1 -1
- package/packages/workspace/investigation-guide.mjs +2 -2
- package/packages/workspace/investigation-template-model.mjs +26 -2
- package/packages/workspace/page-baseline.mjs +2 -2
- package/packages/workspace/relationship-observation-model.mjs +17 -13
- package/packages/workspace/shortlist-model.mjs +24 -7
- package/packages/workspace/watchlist-history.mjs +59 -16
- package/packages/workspace/watchlist-store.mjs +152 -11
- package/packages/workspace/website-snapshot-model.mjs +28 -3
- package/packages/workspace/workspace-archive-crypto.mjs +1 -1
- package/packages/workspace/workspace-archive.mjs +5 -4
- package/third-party-notices.txt +549 -16
- package/lib/hostname.mjs +0 -2
|
@@ -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,
|
package/lib/ct-query.mjs
CHANGED
|
@@ -1,27 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const CT_QUERY_ERROR_MESSAGE = `Certificate Transparency query must be at most ${MAX_CT_QUERY_LENGTH} characters and contain no control or invisible formatting characters.`;
|
|
4
|
-
const UNSAFE_QUERY_CHARACTER_RE = /[\u0000-\u001f\u007f-\u009f]|\p{Default_Ignorable_Code_Point}/u;
|
|
5
|
-
class CtQueryError extends Error {
|
|
6
|
-
code = CT_QUERY_ERROR_CODE;
|
|
7
|
-
constructor() {
|
|
8
|
-
super(CT_QUERY_ERROR_MESSAGE);
|
|
9
|
-
this.name = 'CtQueryError';
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
function normalizeCtQuery(value) {
|
|
13
|
-
if (value === undefined || value === null)
|
|
14
|
-
return '';
|
|
15
|
-
if (typeof value !== 'string'
|
|
16
|
-
|| value.length > MAX_CT_QUERY_LENGTH
|
|
17
|
-
|| UNSAFE_QUERY_CHARACTER_RE.test(value)) {
|
|
18
|
-
throw new CtQueryError();
|
|
19
|
-
}
|
|
20
|
-
return value.trim();
|
|
21
|
-
}
|
|
22
|
-
function isCtQueryError(value) {
|
|
23
|
-
return value instanceof Error
|
|
24
|
-
&& 'code' in value
|
|
25
|
-
&& value.code === CT_QUERY_ERROR_CODE;
|
|
26
|
-
}
|
|
27
|
-
export { CT_QUERY_ERROR_CODE, CT_QUERY_ERROR_MESSAGE, CtQueryError, isCtQueryError, MAX_CT_QUERY_LENGTH, normalizeCtQuery, };
|
|
1
|
+
// Retained public import; the runtime-neutral implementation lives in the domain layer.
|
|
2
|
+
export * from "../packages/analysis/ct-query.mjs";
|
|
@@ -1,9 +1,2 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
// not accidentally clipped by a stale client-side mirror.
|
|
4
|
-
export const MAX_CT_RESPONSE_RESULTS = 500;
|
|
5
|
-
export const MAX_CT_RESPONSE_HOSTNAMES_PER_MATCH = 50;
|
|
6
|
-
export const MAX_CT_RESPONSE_TIMESTAMP_LENGTH = 64;
|
|
7
|
-
export const MAX_CT_RESPONSE_CERTIFICATE_GROUPS = 100;
|
|
8
|
-
export const MAX_CT_RESPONSE_DOMAINS_PER_GROUP = 20;
|
|
9
|
-
export const MAX_CT_RESPONSE_HOSTNAMES_PER_GROUP = 30;
|
|
1
|
+
// Retained public import; the runtime-neutral implementation lives in the domain layer.
|
|
2
|
+
export * from "../packages/analysis/ct-response-bounds.mjs";
|
package/lib/ct-search.mjs
CHANGED
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
// Express server and the Netlify Functions.
|
|
9
9
|
import { parse } from 'tldts';
|
|
10
10
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
11
|
-
import { normalizeCtQuery } from "
|
|
11
|
+
import { normalizeCtQuery } from "../packages/analysis/ct-query.mjs";
|
|
12
12
|
import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
|
|
13
13
|
import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
|
|
14
14
|
import { createObservation } from "../packages/evidence/observation.mjs";
|
|
15
|
-
import { isValidAsciiHostname } from "
|
|
15
|
+
import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
|
|
16
16
|
import { normalizeCtTimestamp } from "../packages/evidence/observation.mjs";
|
|
17
|
-
import { MAX_CT_RESPONSE_CERTIFICATE_GROUPS, MAX_CT_RESPONSE_DOMAINS_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_MATCH, MAX_CT_RESPONSE_RESULTS, MAX_CT_RESPONSE_TIMESTAMP_LENGTH, } from "
|
|
17
|
+
import { MAX_CT_RESPONSE_CERTIFICATE_GROUPS, MAX_CT_RESPONSE_DOMAINS_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_MATCH, MAX_CT_RESPONSE_RESULTS, MAX_CT_RESPONSE_TIMESTAMP_LENGTH, } from "../packages/analysis/ct-response-bounds.mjs";
|
|
18
18
|
const CRT_SH_TIMEOUT_MS = 20000;
|
|
19
19
|
const MAX_RESULTS = MAX_CT_RESPONSE_RESULTS;
|
|
20
20
|
// crt.sh's response size scales with how many certificates ever matched the
|
|
@@ -38,6 +38,27 @@ const RETRY_DELAY_MS = 1500;
|
|
|
38
38
|
// delay before a status-code retry above), so this caps the worst case at
|
|
39
39
|
// two attempts (~40s) instead of stacking on top of MAX_RETRIES.
|
|
40
40
|
const MAX_TIMEOUT_RETRIES = 1;
|
|
41
|
+
const COLLECTION_ERRORS = {
|
|
42
|
+
CT_TIMEOUT: [504, 'Certificate search timed out. Try a narrower keyword or try again later.'],
|
|
43
|
+
CT_OVERLOADED: [503, 'Certificate search is temporarily overloaded. Try again later.'],
|
|
44
|
+
CT_QUERY_TOO_BROAD: [422, 'Certificate search returned too many results. Try a narrower keyword.'],
|
|
45
|
+
CT_INVALID_RESPONSE: [502, 'Certificate search returned invalid UTF-8 or an unexpected response format (expected a JSON array). Try again later.'],
|
|
46
|
+
CT_UPSTREAM_ERROR: [502, 'Certificate search could not retrieve a response from its source. Try again later.'],
|
|
47
|
+
};
|
|
48
|
+
export class CtCollectionError extends Error {
|
|
49
|
+
code;
|
|
50
|
+
constructor(code) {
|
|
51
|
+
super(COLLECTION_ERRORS[code][1]);
|
|
52
|
+
this.code = code;
|
|
53
|
+
this.name = 'CtCollectionError';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Only this finite collection vocabulary may cross an HTTP error boundary. */
|
|
57
|
+
export function ctCollectionErrorResponse(error) {
|
|
58
|
+
return error instanceof CtCollectionError
|
|
59
|
+
? { statusCode: COLLECTION_ERRORS[error.code][0], body: { error: COLLECTION_ERRORS[error.code][1], errorCode: error.code } }
|
|
60
|
+
: null;
|
|
61
|
+
}
|
|
41
62
|
// Row-count defense: a hostile or malformed response could fit a very large
|
|
42
63
|
// number of tiny rows into the 5 MB byte cap and impose excessive
|
|
43
64
|
// iteration/set overhead after parsing. Legitimate responses under the byte
|
|
@@ -75,61 +96,76 @@ function normalizeHostname(raw) {
|
|
|
75
96
|
function delay(ms, signal) {
|
|
76
97
|
return sleep(ms, undefined, signal ? { signal } : {});
|
|
77
98
|
}
|
|
78
|
-
async function fetchCrtSh(keyword,
|
|
99
|
+
async function fetchCrtSh(keyword, dependencies = {}) {
|
|
79
100
|
dependencies.signal?.throwIfAborted();
|
|
80
101
|
const fetcher = dependencies.fetcher || safeFetch;
|
|
81
102
|
const wait = dependencies.delay || delay;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
103
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
104
|
+
dependencies.signal?.throwIfAborted();
|
|
105
|
+
const controller = new AbortController();
|
|
106
|
+
const signal = dependencies.signal ? AbortSignal.any([dependencies.signal, controller.signal]) : controller.signal;
|
|
107
|
+
const timeout = setTimeout(() => controller.abort(), CRT_SH_TIMEOUT_MS);
|
|
108
|
+
let retryDelay = 0;
|
|
87
109
|
try {
|
|
88
|
-
res = await fetcher(`https://crt.sh/?q=${encodeURIComponent(keyword)}&output=json`, {
|
|
110
|
+
const res = await fetcher(`https://crt.sh/?q=${encodeURIComponent(keyword)}&output=json`, {
|
|
89
111
|
headers: whoisleuthRequestHeaders({ Accept: 'application/json' }),
|
|
90
112
|
signal,
|
|
91
113
|
}, 0);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
await res.body?.cancel().catch(() => { });
|
|
116
|
+
if (RETRYABLE_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
|
|
117
|
+
retryDelay = RETRY_DELAY_MS * (attempt + 1);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
throw new CtCollectionError(RETRYABLE_STATUSES.has(res.status) || res.status === 429 ? 'CT_OVERLOADED' : 'CT_UPSTREAM_ERROR');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
let text;
|
|
125
|
+
try {
|
|
126
|
+
const body = await readTextCapped(res, CRT_SH_MAX_BYTES, { fatalUtf8: true });
|
|
127
|
+
signal.throwIfAborted();
|
|
128
|
+
if (body.truncated)
|
|
129
|
+
throw new CtCollectionError('CT_QUERY_TOO_BROAD');
|
|
130
|
+
text = body.text;
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
// Preserve transport/deadline failures for the attempt retry below.
|
|
134
|
+
if (error instanceof TypeError && !signal.aborted) {
|
|
135
|
+
throw new CtCollectionError('CT_INVALID_RESPONSE');
|
|
136
|
+
}
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
let data;
|
|
140
|
+
try {
|
|
141
|
+
data = JSON.parse(text);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
throw new CtCollectionError('CT_INVALID_RESPONSE');
|
|
145
|
+
}
|
|
146
|
+
if (!Array.isArray(data))
|
|
147
|
+
throw new CtCollectionError('CT_INVALID_RESPONSE');
|
|
148
|
+
if (data.length > MAX_CT_ROWS)
|
|
149
|
+
throw new CtCollectionError('CT_QUERY_TOO_BROAD');
|
|
150
|
+
return data;
|
|
111
151
|
}
|
|
112
|
-
throw new Error(RETRYABLE_STATUSES.has(res.status)
|
|
113
|
-
? `crt.sh is temporarily overloaded (${res.status} after ${attempt + 1} attempts) - it's a free public service that intermittently struggles under load; try again in a moment.`
|
|
114
|
-
: `crt.sh returned ${res.status}`);
|
|
115
|
-
}
|
|
116
|
-
const { text, truncated } = await readTextCapped(res, CRT_SH_MAX_BYTES, { fatalUtf8: true });
|
|
117
|
-
signal.throwIfAborted();
|
|
118
|
-
if (truncated) {
|
|
119
|
-
throw new Error(`crt.sh returned more than ${CRT_SH_MAX_BYTES / (1024 * 1024)}MB of results for "${keyword}" - try a narrower/more specific keyword.`);
|
|
120
152
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
153
|
+
catch (error) {
|
|
154
|
+
dependencies.signal?.throwIfAborted();
|
|
155
|
+
if (!controller.signal.aborted && (!(error instanceof Error) || error.name !== 'AbortError'))
|
|
156
|
+
throw error;
|
|
157
|
+
if (attempt >= MAX_TIMEOUT_RETRIES)
|
|
158
|
+
throw new CtCollectionError('CT_TIMEOUT');
|
|
124
159
|
}
|
|
125
|
-
|
|
126
|
-
|
|
160
|
+
finally {
|
|
161
|
+
// End this attempt before delay/retry: body reads share the same deadline
|
|
162
|
+
// as headers, and earlier timers cannot fire during a later attempt.
|
|
163
|
+
clearTimeout(timeout);
|
|
127
164
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
finally {
|
|
131
|
-
clearTimeout(timeout);
|
|
165
|
+
if (retryDelay)
|
|
166
|
+
await wait(retryDelay, dependencies.signal);
|
|
132
167
|
}
|
|
168
|
+
throw new CtCollectionError('CT_UPSTREAM_ERROR');
|
|
133
169
|
}
|
|
134
170
|
// ---------------------------------------------------------------------------
|
|
135
171
|
// Certificate identity
|
|
@@ -485,7 +521,7 @@ async function searchCertificateTransparency(keyword, dependencies = {}) {
|
|
|
485
521
|
}),
|
|
486
522
|
};
|
|
487
523
|
}
|
|
488
|
-
const data = await fetchCrtSh(trimmed,
|
|
524
|
+
const data = await fetchCrtSh(trimmed, dependencies);
|
|
489
525
|
const summary = summarizeCtResults(data);
|
|
490
526
|
return {
|
|
491
527
|
certCount: data.length,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
|
|
2
|
+
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
3
|
+
import { parseDmarcRecords } from "./domain-posture-parsers.mjs";
|
|
4
|
+
export function requireDmarcDomain(domain) {
|
|
5
|
+
if (typeof domain !== 'string' || domain.length > 253 || !isValidAsciiHostname(domain) || domain !== domain.toLowerCase()) {
|
|
6
|
+
throw new TypeError('The additional DNS review requires a normalised domain.');
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function dmarcPolicyObservation(owner, observation) {
|
|
10
|
+
const records = observation.records;
|
|
11
|
+
let bytes = 0;
|
|
12
|
+
const admitted = Array.isArray(records) && records.length <= 64 && records.every(raw => {
|
|
13
|
+
const parts = typeof raw === 'string' ? [raw] : raw;
|
|
14
|
+
return Array.isArray(parts) && parts.length <= 256 && parts.every(part => {
|
|
15
|
+
if (typeof part !== 'string' || part.length > 4096)
|
|
16
|
+
return false;
|
|
17
|
+
bytes += Buffer.byteLength(part, 'utf8');
|
|
18
|
+
return bytes <= 65_536;
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
const parsed = parseDmarcRecords(admitted ? records : []);
|
|
22
|
+
const unavailable = Boolean(observation.error) || !admitted;
|
|
23
|
+
const psd = parsed.tags.psd?.toLowerCase() ?? 'u';
|
|
24
|
+
const valid = !unavailable && parsed.valid && ['u', 'n', 'y'].includes(psd);
|
|
25
|
+
const incomplete = unavailable || (!valid && parsed.records.length === 1);
|
|
26
|
+
const state = unavailable ? 'unavailable' : valid ? 'observed' : parsed.records.length ? 'invalid' : 'not found';
|
|
27
|
+
const description = valid
|
|
28
|
+
? `p=${parsed.policy}; sp=${parsed.subdomainPolicy}; np=${parsed.nonexistentSubdomainPolicy}; psd=${psd}; test=${parsed.testMode ? 'yes' : 'no'}`
|
|
29
|
+
: state;
|
|
30
|
+
const time = typeof observation.observedAt === 'string' && observation.observedAt.length <= 64
|
|
31
|
+
? normalizeExplicitIsoTimestamp(observation.observedAt) : null;
|
|
32
|
+
return { owner, parsed, psd, valid, unavailable, incomplete,
|
|
33
|
+
record: `Recursive DNS TXT _dmarc.${owner} · ${time ?? 'time unavailable'} · ${description}` };
|
|
34
|
+
}
|
|
35
|
+
/** RFC 9989 sections 4.10–4.10.2: at most eight owners, never PSL inference. */
|
|
36
|
+
export async function walkDmarcTree(domain, exact, resolve, purpose) {
|
|
37
|
+
requireDmarcDomain(domain);
|
|
38
|
+
const rows = [dmarcPolicyObservation(domain, exact)];
|
|
39
|
+
const first = rows[0];
|
|
40
|
+
const labels = domain.split('.');
|
|
41
|
+
let next = labels.length >= 8 ? labels.slice(-7) : labels.slice(1);
|
|
42
|
+
const policyOnly = purpose === 'policy' && first.valid;
|
|
43
|
+
if (!policyOnly && !first.unavailable && !(first.valid && ['n', 'y'].includes(first.psd))) {
|
|
44
|
+
while (next.length) {
|
|
45
|
+
const owner = next.join('.');
|
|
46
|
+
const row = dmarcPolicyObservation(owner, await resolve(`_dmarc.${owner}`));
|
|
47
|
+
rows.push(row);
|
|
48
|
+
if (row.unavailable || (row.valid && ['n', 'y'].includes(row.psd)))
|
|
49
|
+
break;
|
|
50
|
+
next = next.slice(1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const available = rows.filter(row => row.valid);
|
|
54
|
+
const boundary = available.find(row => row.psd === 'n' || (row.psd === 'y' && row.owner !== domain));
|
|
55
|
+
const incomplete = rows.some(row => row.incomplete);
|
|
56
|
+
// An exact policy alone does not establish the organisational boundary.
|
|
57
|
+
const organisationalDomain = incomplete || (policyOnly && !['n', 'y'].includes(first.psd)) ? null
|
|
58
|
+
: boundary?.psd === 'y'
|
|
59
|
+
? labels.slice(-(boundary.owner.split('.').length + 1)).join('.')
|
|
60
|
+
: boundary?.owner ?? available.at(-1)?.owner ?? domain;
|
|
61
|
+
return { rows, available, boundary, incomplete, organisationalDomain };
|
|
62
|
+
}
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
|
|
2
2
|
import { normaliseRdata } from "./zone-intent-review.mjs";
|
|
3
|
+
import { dnsQueryState, normaliseDnsQueryCoverage } from "./dns-query-coverage.mjs";
|
|
3
4
|
export const DNS_CONVERGENCE_INPUT_SCHEMA = 'whoisleuth.dns-convergence.input';
|
|
4
5
|
export const DNS_CONVERGENCE_REVIEW_SCHEMA = 'whoisleuth.dns-convergence.review';
|
|
5
|
-
export const
|
|
6
|
+
export const DNS_CONVERGENCE_INPUT_VERSION = 2;
|
|
7
|
+
export const SUPPORTED_DNS_CONVERGENCE_INPUT_VERSIONS = Object.freeze([1, DNS_CONVERGENCE_INPUT_VERSION]);
|
|
8
|
+
export const DNS_CONVERGENCE_REVIEW_VERSION = 2;
|
|
6
9
|
export const MAX_DNS_CONVERGENCE_SNAPSHOTS = 100;
|
|
7
10
|
export const MAX_DNS_CONVERGENCE_RECORDS = 2_000;
|
|
8
11
|
const RECORD_TYPES = new Set(['A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'SOA', 'SRV', 'SVCB', 'HTTPS', 'TLSA', 'TXT']);
|
|
9
12
|
const ROOT_KEYS = new Set(['schema', 'version', 'domain', 'expected', 'snapshots']);
|
|
10
13
|
const EXPECTED_KEYS = new Set(['owner', 'type', 'values']);
|
|
11
14
|
const SNAPSHOT_KEYS = new Set(['observer', 'source', 'observedAt', 'state', 'records']);
|
|
15
|
+
const COVERED_SNAPSHOT_KEYS = new Set([...SNAPSHOT_KEYS, 'queries']);
|
|
12
16
|
const RECORD_KEYS = new Set(['owner', 'type', 'value', 'ttl']);
|
|
13
17
|
function type(value, label) {
|
|
14
18
|
const candidate = requireBoundedString(value, label, 16).toUpperCase();
|
|
@@ -83,8 +87,8 @@ function expectedRows(value, apex) {
|
|
|
83
87
|
}
|
|
84
88
|
export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toISOString()) {
|
|
85
89
|
const input = requireRecord(inputRaw, 'DNS convergence input');
|
|
86
|
-
if (input.schema !== DNS_CONVERGENCE_INPUT_SCHEMA || input.version
|
|
87
|
-
throw new TypeError(`DNS convergence input must use ${DNS_CONVERGENCE_INPUT_SCHEMA} version 1.`);
|
|
90
|
+
if (input.schema !== DNS_CONVERGENCE_INPUT_SCHEMA || !SUPPORTED_DNS_CONVERGENCE_INPUT_VERSIONS.includes(input.version)) {
|
|
91
|
+
throw new TypeError(`DNS convergence input must use ${DNS_CONVERGENCE_INPUT_SCHEMA} version 1 or ${DNS_CONVERGENCE_INPUT_VERSION}.`);
|
|
88
92
|
}
|
|
89
93
|
exactKeys(input, ROOT_KEYS, 'DNS convergence input');
|
|
90
94
|
const apex = requireDomainName(input.domain, 'domain');
|
|
@@ -93,9 +97,10 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
|
|
|
93
97
|
throw new TypeError(`snapshots must contain between 2 and ${MAX_DNS_CONVERGENCE_SNAPSHOTS} entries.`);
|
|
94
98
|
}
|
|
95
99
|
let recordCount = 0;
|
|
100
|
+
let queryCount = 0;
|
|
96
101
|
const snapshots = input.snapshots.map((raw, index) => {
|
|
97
102
|
const item = requireRecord(raw, `snapshots[${index}]`);
|
|
98
|
-
exactKeys(item, SNAPSHOT_KEYS, `snapshots[${index}]`);
|
|
103
|
+
exactKeys(item, input.version === 1 ? SNAPSHOT_KEYS : COVERED_SNAPSHOT_KEYS, `snapshots[${index}]`);
|
|
99
104
|
if (!Array.isArray(item.records))
|
|
100
105
|
throw new TypeError(`snapshots[${index}].records must be an array.`);
|
|
101
106
|
recordCount += item.records.length;
|
|
@@ -104,12 +109,20 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
|
|
|
104
109
|
const observationState = state(item.state, `snapshots[${index}].state`);
|
|
105
110
|
if (observationState === 'unavailable' && item.records.length)
|
|
106
111
|
throw new TypeError(`snapshots[${index}] cannot contain records when unavailable.`);
|
|
112
|
+
const records = Object.freeze(item.records.map((entry, recordIndex) => record(entry, apex, `snapshots[${index}].records[${recordIndex}]`)));
|
|
113
|
+
const queries = normaliseDnsQueryCoverage(item.queries, {
|
|
114
|
+
legacy: input.version === 1, label: `snapshots[${index}].queries`,
|
|
115
|
+
maximum: MAX_DNS_CONVERGENCE_RECORDS - queryCount, state: observationState, records,
|
|
116
|
+
owner: (value, label) => owner(value, apex, label), type,
|
|
117
|
+
});
|
|
118
|
+
queryCount += queries.length;
|
|
107
119
|
return Object.freeze({
|
|
108
120
|
observer: requireBoundedString(item.observer, `snapshots[${index}].observer`, 120),
|
|
109
121
|
source: requireBoundedString(item.source, `snapshots[${index}].source`, 240),
|
|
110
122
|
observedAt: requireIsoTimestamp(item.observedAt, `snapshots[${index}].observedAt`),
|
|
111
123
|
state: observationState,
|
|
112
|
-
records
|
|
124
|
+
records,
|
|
125
|
+
queries,
|
|
113
126
|
});
|
|
114
127
|
}).sort((left, right) => left.observedAt.localeCompare(right.observedAt) || left.observer.localeCompare(right.observer));
|
|
115
128
|
const observers = [...new Set(snapshots.map((item) => item.observer))];
|
|
@@ -118,7 +131,7 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
|
|
|
118
131
|
const latestByObserver = observers.map((observer) => snapshots.filter((item) => item.observer === observer).at(-1));
|
|
119
132
|
const keys = [...new Set([
|
|
120
133
|
...expected.map((item) => `${item.owner}\u0000${item.type}`),
|
|
121
|
-
...snapshots.flatMap((item) => item.
|
|
134
|
+
...snapshots.flatMap((item) => item.queries.map((entry) => `${entry.owner}\u0000${entry.type}`)),
|
|
122
135
|
])].sort();
|
|
123
136
|
const rows = keys.map((key) => {
|
|
124
137
|
const [recordOwner = '', recordType = ''] = key.split('\u0000');
|
|
@@ -134,7 +147,7 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
|
|
|
134
147
|
observer: snapshot.observer,
|
|
135
148
|
source: snapshot.source,
|
|
136
149
|
observedAt: snapshot.observedAt,
|
|
137
|
-
state: snapshot.
|
|
150
|
+
state: dnsQueryState(snapshot.queries, recordOwner, recordType),
|
|
138
151
|
values: Object.freeze(values),
|
|
139
152
|
cacheUntil,
|
|
140
153
|
});
|
|
@@ -178,7 +191,7 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
|
|
|
178
191
|
'This local workbench compares only supplied observations and makes no DNS request.',
|
|
179
192
|
'Resolver differences can reflect caches, split-horizon policy, interception, collection timing, or propagation; they do not establish which value is authoritative.',
|
|
180
193
|
'Cache-until times are simple observation-time plus supplied TTL projections and do not reveal actual cache age, refresh behaviour, or resolver policy.',
|
|
181
|
-
'
|
|
194
|
+
'Only an explicitly observed query can establish an empty answer. Unqueried, partial and unavailable record types are never interpreted as absence; legacy inputs establish scope only for their supplied records.',
|
|
182
195
|
]),
|
|
183
196
|
});
|
|
184
197
|
}
|