@slicedearth/whoisleuth-cli 2.2.0 → 2.4.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/NOTICE +6 -0
- package/README.md +20 -15
- package/bin/whoisleuth.mjs +8 -1
- package/cli/archive-content-digest.mjs +14 -0
- package/cli/archive-inspect.mjs +20 -53
- package/cli/arguments.mjs +454 -1748
- package/cli/artifact-validation/case-response.mjs +66 -35
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +12 -6
- package/cli/artifact-validation/signed-review.mjs +112 -72
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +90 -24
- package/cli/assurance-command-runner.mjs +39 -4
- package/cli/bagit-folder.mjs +57 -0
- package/cli/bagit-review.mjs +23 -0
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +34 -3
- package/cli/bulk-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +66 -111
- package/cli/command-argument-grammar.mjs +215 -0
- package/cli/command-reference.mjs +342 -285
- package/cli/completion.mjs +303 -80
- package/cli/config-profile.mjs +7 -5
- package/cli/ct-event-intake.mjs +12 -12
- package/cli/discover.mjs +4 -3
- package/cli/discovery-observation-snapshot.mjs +59 -18
- package/cli/discovery-scan-command-runner.mjs +21 -0
- package/cli/discovery-scan.mjs +13 -4
- package/cli/discovery-workflow.mjs +1 -1
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +149 -102
- package/cli/errors.mjs +33 -1
- package/cli/evidence-command-runner.mjs +16 -5
- package/cli/evidence-signing.mjs +2 -2
- package/cli/external-observation-mapping.mjs +7 -8
- package/cli/formatters/terminal-lookup.mjs +18 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/inline-command-families.mjs +2 -0
- package/cli/input.mjs +116 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +18 -8
- package/cli/investigation-artifacts.mjs +176 -0
- package/cli/investigation-checkpoint.mjs +10 -0
- package/cli/investigation-folder.mjs +114 -0
- package/cli/investigation-manifest.mjs +1 -112
- package/cli/investigation-package-review.mjs +103 -0
- package/cli/investigation-plan.mjs +32 -19
- package/cli/investigation-run.mjs +188 -33
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +17 -6
- package/cli/lookup-diff.mjs +37 -3
- package/cli/lookup-plan.mjs +11 -5
- package/cli/lookup-reconcile.mjs +10 -8
- package/cli/mail-header-review.mjs +467 -0
- package/cli/mail-review.mjs +66 -14
- package/cli/manual.mjs +1 -0
- package/cli/network-command-runner.mjs +2 -0
- package/cli/output-file.mjs +31 -5
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +2 -2
- package/cli/review-command-runner.mjs +70 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +62 -7
- package/cli/sharing-review.mjs +21 -34
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +74 -83
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-command-runner.mjs +68 -13
- package/docs/cli-reference.md +77 -24
- package/docs/cli.md +300 -25
- package/lib/abort.mjs +4 -1
- package/lib/availability.mjs +161 -112
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +51 -8
- package/lib/browser-library-profile.mjs +42 -51
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-search.mjs +11 -4
- package/lib/dns-convergence-review.mjs +1 -0
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +73 -26
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +49 -16
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +221 -117
- package/lib/domain-control-manifest.mjs +129 -69
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +201 -8
- package/lib/domain-posture.mjs +147 -29
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -543
- package/lib/evidence-report.mjs +5 -3
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +12 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +2335 -940
- package/lib/hostname.mjs +2 -38
- package/lib/html-signals.mjs +111 -108
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/local-geoip-evidence.mjs +19 -3
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-diagnostics.mjs +24 -1
- package/lib/lookup-progress.mjs +30 -13
- package/lib/lookup-response-contract.mjs +1048 -0
- package/lib/lookup-response.mjs +8 -10
- package/lib/lookup-source-progress.mjs +40 -14
- package/lib/lookup.mjs +48 -27
- package/lib/nameserver-preflight-review.mjs +1 -0
- package/lib/observed-network-context.mjs +43 -27
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +69 -133
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash.mjs +43 -14
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-capabilities.mjs +1 -1
- package/lib/rdap-client.mjs +15 -7
- package/lib/rdap-normalization.mjs +33 -18
- package/lib/rdap-search-workbench.mjs +1 -1
- package/lib/rdap-transport.mjs +47 -20
- package/lib/rdap-types.mjs +2 -1
- package/lib/rdap.mjs +18 -3
- package/lib/registrable-domain.mjs +21 -1
- package/lib/registrar-standing-contract.mjs +1 -0
- package/lib/registry-capabilities.mjs +5 -1
- package/lib/registry-capability-catalogue.mjs +2 -1
- package/lib/registry-dates.mjs +2 -190
- package/lib/registry-insights.mjs +85 -34
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +18 -6
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +42 -7
- package/lib/service-binding-dns.mjs +8 -0
- package/lib/smtp-transport-review.mjs +27 -15
- package/lib/sslbl-certificates.generated.mjs +136 -135
- package/lib/static-html-analysis.mjs +178 -151
- package/lib/structured-data-identity.mjs +1 -0
- package/lib/technology-markup.mjs +100 -0
- package/lib/threat-intelligence-manifests.mjs +11 -139
- package/lib/threat-intelligence-runtime.mjs +24 -52
- package/lib/threat-intelligence-targets.mjs +6 -34
- package/lib/threat-intelligence-types.mjs +39 -0
- package/lib/typosquat-generator.mjs +19 -15
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +153 -45
- package/lib/whois-authority.mjs +14 -2
- package/lib/whois-chain.mjs +9 -2
- package/lib/whois-common-formats.mjs +5 -5
- package/lib/whois-contracts.mjs +2 -1
- package/lib/whois-dialects.mjs +2 -2
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-transport.mjs +23 -7
- package/lib/zone-intent-review.mjs +16 -6
- package/package.json +2 -1
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-model.mjs +156 -62
- package/packages/cases/case-export-input.mjs +53 -0
- package/packages/cases/case-migration-model.mjs +127 -44
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +12 -22
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +95 -31
- package/packages/cases/case-record-projection.mjs +221 -0
- package/packages/cases/case-report.mjs +70 -54
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +64 -1115
- package/packages/cases/case-response-outcomes.mjs +317 -0
- package/packages/cases/case-response-packet-render.mjs +153 -0
- package/packages/cases/case-response-packet-types.mjs +1 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
- package/packages/cases/case-response-packet.mjs +74 -572
- package/packages/cases/case-response-records.mjs +94 -0
- package/packages/cases/case-response-review-inputs.mjs +371 -0
- package/packages/cases/case-response-values.mjs +85 -0
- package/packages/cases/case-review-package.mjs +45 -0
- package/packages/cases/case-selection.mjs +46 -0
- package/packages/cases/case-storage-model.mjs +31 -2
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
- package/packages/comparison/page-similarity.mjs +5 -2
- package/packages/comparison/relationship-evidence.mjs +85 -8
- package/packages/comparison/relationship-provenance.mjs +93 -0
- package/packages/contracts/analyst-interchange.mjs +216 -0
- package/packages/contracts/analyst-review-state-contract.mjs +11 -0
- package/packages/contracts/analyst-review-state.mjs +5 -4
- package/packages/contracts/case-portability.mjs +366 -83
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +94 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
- package/packages/contracts/domain-control-manifest.mjs +127 -73
- package/packages/contracts/domain-control-monitor.mjs +48 -45
- package/packages/contracts/domain-control-review.mjs +179 -121
- package/packages/contracts/domain-name.mjs +21 -0
- package/packages/contracts/external-observation-interchange.mjs +2 -1
- package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +16 -0
- package/packages/contracts/ip-address.mjs +34 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/offline-comparison.mjs +1 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/risk-calibration.mjs +15 -11
- package/packages/contracts/schema-compatibility.mjs +1 -1
- package/packages/contracts/schema-lifecycle.mjs +83 -153
- package/packages/contracts/selected-file-limits.mjs +6 -0
- package/packages/contracts/vulnerability-identifiers.mjs +5 -0
- package/packages/contracts/web-capture.mjs +10 -0
- package/packages/contracts/workspace-portability.mjs +183 -211
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/domain-control-runtime.mjs +201 -33
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +104 -0
- package/packages/evidence/observation.mjs +16 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/record-identity.mjs +72 -0
- package/packages/evidence/registry-dates.mjs +190 -0
- package/packages/evidence/retained-file.mjs +69 -0
- package/packages/evidence/review-age.mjs +14 -0
- package/packages/interchange/bagit.mjs +342 -0
- package/packages/interchange/bounded-zip-extraction.mjs +278 -0
- package/packages/interchange/crc32.mjs +17 -0
- package/packages/interchange/external-findings-import.mjs +92 -51
- package/packages/interchange/web-capture-import.mjs +382 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -1
- package/packages/investigation/investigation-bagit.mjs +14 -0
- package/packages/investigation/investigation-capsule.mjs +33 -5
- package/packages/investigation/investigation-manifest.mjs +200 -0
- package/packages/investigation/investigation-package-crypto.mjs +98 -0
- package/packages/investigation/investigation-package.mjs +200 -0
- package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
- package/packages/investigation/lookup-asset-graph.mjs +193 -165
- package/packages/investigation/lookup-claim-readiness.mjs +18 -0
- package/packages/investigation/lookup-investigation-brief.mjs +3 -3
- package/packages/investigation/lookup-task-guidance.mjs +58 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +276 -22
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +23 -11
- package/packages/workspace/bulk-session-model.mjs +100 -24
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/detection-rule-model.mjs +70 -22
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-template-model.mjs +19 -4
- package/packages/workspace/page-baseline.mjs +18 -15
- package/packages/workspace/relationship-observation-model.mjs +41 -23
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +164 -26
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +87 -21
- package/third-party-notices.txt +29 -2
- package/cli/evidence-command-arguments.mjs +0 -156
- package/packages/investigation/bulk-review-export.mjs +0 -63
package/lib/registry-dates.mjs
CHANGED
|
@@ -1,190 +1,2 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
// helpers only produce additive Date/ISO companions for comparison, storage,
|
|
4
|
-
// and presentation.
|
|
5
|
-
const REGISTRY_MONTHS = Object.freeze({
|
|
6
|
-
jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
|
|
7
|
-
jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
|
|
8
|
-
});
|
|
9
|
-
const REGISTRY_MONTH_NAMES = Object.freeze({
|
|
10
|
-
january: 1, february: 2, march: 3, april: 4, may: 5, june: 6,
|
|
11
|
-
july: 7, august: 8, september: 9, october: 10, november: 11, december: 12,
|
|
12
|
-
});
|
|
13
|
-
function utcDateFromParts(year, month, day, hour = 0, minute = 0, second = 0, millisecond = 0) {
|
|
14
|
-
if (![year, month, day, hour, minute, second, millisecond].every(Number.isInteger))
|
|
15
|
-
return null;
|
|
16
|
-
if (year < 1000 || year > 9999 || month < 1 || month > 12 || day < 1
|
|
17
|
-
|| hour < 0 || hour > 23 || minute < 0 || minute > 59
|
|
18
|
-
|| second < 0 || second > 59 || millisecond < 0 || millisecond > 999)
|
|
19
|
-
return null;
|
|
20
|
-
const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second, millisecond));
|
|
21
|
-
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day
|
|
22
|
-
|| date.getUTCHours() !== hour || date.getUTCMinutes() !== minute
|
|
23
|
-
|| date.getUTCSeconds() !== second || date.getUTCMilliseconds() !== millisecond)
|
|
24
|
-
return null;
|
|
25
|
-
return date;
|
|
26
|
-
}
|
|
27
|
-
// Parse the explicit non-ISO formats observed across supported ccTLD WHOIS
|
|
28
|
-
// services before considering ISO-shaped input. Runtime Date parsing treats
|
|
29
|
-
// dotted dates ambiguously and timezone-less timestamps in the host's local
|
|
30
|
-
// timezone, so every accepted shape is validated and normalized through UTC.
|
|
31
|
-
function parseRegistryDate(input) {
|
|
32
|
-
if (typeof input !== 'string')
|
|
33
|
-
return null;
|
|
34
|
-
// The documented .nz WHOIS examples place one space before the RFC3339
|
|
35
|
-
// `T` separator. Canonicalize that registry-specific presentation quirk
|
|
36
|
-
// before applying the existing strict ISO-shaped parser.
|
|
37
|
-
const value = input.trim()
|
|
38
|
-
.replace(/^(\d{4}-\d{2}-\d{2})[ ]+[Tt](?=\d{2}:)/, '$1T')
|
|
39
|
-
// The .ee WHOIS service separates an otherwise ISO-shaped numeric
|
|
40
|
-
// timezone from its clock with one space.
|
|
41
|
-
.replace(/^(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?)[ ]+([+-]\d{2}:?\d{2})$/, '$1$2');
|
|
42
|
-
if (!value)
|
|
43
|
-
return null;
|
|
44
|
-
// DD.MM.YYYY[ HH:MM:SS] - e.g. 14.03.2024 10:46:48
|
|
45
|
-
let match = value.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}):(\d{2}):(\d{2}))?$/);
|
|
46
|
-
if (match) {
|
|
47
|
-
const [, day = '', month = '', year = '', hour = '0', minute = '0', second = '0'] = match;
|
|
48
|
-
return utcDateFromParts(+year, +month, +day, +(hour || 0), +(minute || 0), +(second || 0));
|
|
49
|
-
}
|
|
50
|
-
// YYYY. MM. DD. - e.g. 2006. 09. 18.
|
|
51
|
-
match = value.match(/^(\d{4})\.\s*(\d{1,2})\.\s*(\d{1,2})\.?$/);
|
|
52
|
-
if (match) {
|
|
53
|
-
const [, year = '', month = '', day = ''] = match;
|
|
54
|
-
return utcDateFromParts(+year, +month, +day);
|
|
55
|
-
}
|
|
56
|
-
// YYYY.MM.DD HH:MM:SS - used by NASK's .pl WHOIS service.
|
|
57
|
-
match = value.match(/^(\d{4})\.(\d{1,2})\.(\d{1,2})\s+(\d{1,2}):(\d{2}):(\d{2})$/);
|
|
58
|
-
if (match) {
|
|
59
|
-
const [, year = '', month = '', day = '', hour = '', minute = '', second = ''] = match;
|
|
60
|
-
return utcDateFromParts(+year, +month, +day, +hour, +minute, +second);
|
|
61
|
-
}
|
|
62
|
-
// YYYY/MM/DD - an unambiguous year-first form used by CIRA WHOIS.
|
|
63
|
-
match = value.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/);
|
|
64
|
-
if (match) {
|
|
65
|
-
const [, year = '', month = '', day = ''] = match;
|
|
66
|
-
return utcDateFromParts(+year, +month, +day);
|
|
67
|
-
}
|
|
68
|
-
// YYYYMMDD with an optional time or Registro.br contact-reference suffix.
|
|
69
|
-
// The raw value remains available as provenance while the validated date
|
|
70
|
-
// and optional clock time are projected into the additive ISO companion.
|
|
71
|
-
match = value.match(/^(\d{4})(\d{2})(\d{2})(?:\s+(\d{1,2}):(\d{2}):(\d{2})|\s+#[0-9]{1,20})?$/);
|
|
72
|
-
if (match) {
|
|
73
|
-
const [, year = '', month = '', day = '', hour = '0', minute = '0', second = '0'] = match;
|
|
74
|
-
return utcDateFromParts(+year, +month, +day, +(hour || 0), +(minute || 0), +(second || 0));
|
|
75
|
-
}
|
|
76
|
-
// YYYY-Mon-DD. - e.g. 1999-Feb-16.
|
|
77
|
-
match = value.match(/^(\d{4})-([A-Za-z]{3})-(\d{1,2})\.?$/);
|
|
78
|
-
if (match) {
|
|
79
|
-
const month = REGISTRY_MONTHS[(match[2] ?? '').toLowerCase()];
|
|
80
|
-
return month ? utcDateFromParts(+(match[1] ?? ''), month, +(match[3] ?? '')) : null;
|
|
81
|
-
}
|
|
82
|
-
// DD-Mon-YYYY[ HH:MM:SS] - used by several ICANN-style ccTLD services.
|
|
83
|
-
match = value.match(/^(\d{1,2})-([A-Za-z]{3})-(\d{4})(?:\s+(\d{1,2}):(\d{2}):(\d{2}))?$/);
|
|
84
|
-
if (match) {
|
|
85
|
-
const month = REGISTRY_MONTHS[(match[2] ?? '').toLowerCase()];
|
|
86
|
-
return month
|
|
87
|
-
? utcDateFromParts(+(match[3] ?? ''), month, +(match[1] ?? ''), +(match[4] || 0), +(match[5] || 0), +(match[6] || 0))
|
|
88
|
-
: null;
|
|
89
|
-
}
|
|
90
|
-
// DD-MM-YYYY - the day-first form published by the currently profiled
|
|
91
|
-
// registries that use bare numeric hyphen dates. A future month-first
|
|
92
|
-
// source must receive its own marker-gated parser rather than reusing this
|
|
93
|
-
// global shape. The four-digit final year remains distinct from ISO below.
|
|
94
|
-
match = value.match(/^(\d{1,2})-(\d{1,2})-(\d{4})$/);
|
|
95
|
-
if (match) {
|
|
96
|
-
const [, day = '', month = '', year = ''] = match;
|
|
97
|
-
return utcDateFromParts(+year, +month, +day);
|
|
98
|
-
}
|
|
99
|
-
// YYYY-MM-DD HH:MM:SS (GMT+H:MM) - the explicit offset form published by
|
|
100
|
-
// the Kazakhstan registry. Parentheses and the GMT marker are required so
|
|
101
|
-
// unrelated prose is not accepted as a lifecycle timestamp.
|
|
102
|
-
match = value.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})\s+\(GMT([+-])(\d{1,2}):(\d{2})\)$/i);
|
|
103
|
-
if (match) {
|
|
104
|
-
const [, year = '', month = '', day = '', hour = '', minute = '', second = '', sign = '', offsetHours = '', offsetMinutes = '',] = match;
|
|
105
|
-
const local = utcDateFromParts(+year, +month, +day, +hour, +minute, +second);
|
|
106
|
-
if (!local || +offsetHours > 23 || +offsetMinutes > 59)
|
|
107
|
-
return null;
|
|
108
|
-
const offsetMs = (+offsetHours * 60 + +offsetMinutes) * 60000;
|
|
109
|
-
return new Date(local.getTime() + (sign === '+' ? -offsetMs : offsetMs));
|
|
110
|
-
}
|
|
111
|
-
// DD-MM-YYYY HH:MM:SS GMT+H - the Tunisian registry's bounded local-time
|
|
112
|
-
// presentation. Accept an optional minute component but require the GMT
|
|
113
|
-
// marker and validate the calendar and offset before normalization.
|
|
114
|
-
match = value.match(/^(\d{1,2})-(\d{1,2})-(\d{4})\s+(\d{2}):(\d{2}):(\d{2})\s+GMT([+-])(\d{1,2})(?::(\d{2}))?$/i);
|
|
115
|
-
if (match) {
|
|
116
|
-
const [, day = '', month = '', year = '', hour = '', minute = '', second = '', sign = '', offsetHours = '', offsetMinutes = '0',] = match;
|
|
117
|
-
const local = utcDateFromParts(+year, +month, +day, +hour, +minute, +second);
|
|
118
|
-
if (!local || +offsetHours > 23 || +offsetMinutes > 59)
|
|
119
|
-
return null;
|
|
120
|
-
const offsetMs = (+offsetHours * 60 + +offsetMinutes) * 60000;
|
|
121
|
-
return new Date(local.getTime() + (sign === '+' ? -offsetMs : offsetMs));
|
|
122
|
-
}
|
|
123
|
-
// DD Mon YYYY - the compact English month form published by THNIC.
|
|
124
|
-
match = value.match(/^(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})$/);
|
|
125
|
-
if (match) {
|
|
126
|
-
const month = REGISTRY_MONTHS[(match[2] ?? '').toLowerCase()];
|
|
127
|
-
return month ? utcDateFromParts(+(match[3] ?? ''), month, +(match[1] ?? '')) : null;
|
|
128
|
-
}
|
|
129
|
-
// Ordinal-day Month YYYY[ at HH:MM:SS.sss] - the English form published
|
|
130
|
-
// by the Channel Islands registry. The suffix and `at` token are required
|
|
131
|
-
// so ordinary prose containing a month name is not accepted as lifecycle
|
|
132
|
-
// evidence.
|
|
133
|
-
match = value.match(/^(\d{1,2})(st|nd|rd|th)\s+([A-Za-z]{3,9})\s+(\d{4})(?:\s+at\s+(\d{1,2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?)?$/i);
|
|
134
|
-
if (match) {
|
|
135
|
-
const day = +(match[1] ?? '');
|
|
136
|
-
const suffix = (match[2] ?? '').toLowerCase();
|
|
137
|
-
const expectedSuffix = day % 100 >= 11 && day % 100 <= 13
|
|
138
|
-
? 'th'
|
|
139
|
-
: ({ 1: 'st', 2: 'nd', 3: 'rd' }[day % 10] || 'th');
|
|
140
|
-
if (suffix !== expectedSuffix)
|
|
141
|
-
return null;
|
|
142
|
-
const monthText = (match[3] ?? '').toLowerCase();
|
|
143
|
-
const month = REGISTRY_MONTH_NAMES[monthText] || REGISTRY_MONTHS[monthText];
|
|
144
|
-
const millisecond = match[8] ? Number(`${match[8]}00`.slice(0, 3)) : 0;
|
|
145
|
-
return month
|
|
146
|
-
? utcDateFromParts(+(match[4] ?? ''), month, day, +(match[5] || 0), +(match[6] || 0), +(match[7] || 0), millisecond)
|
|
147
|
-
: null;
|
|
148
|
-
}
|
|
149
|
-
// Ddd Mon DD YYYY[ HH:MM:SS] - the English textual form used by the
|
|
150
|
-
// Belgian registry. Weekday text is presentation-only; the validated
|
|
151
|
-
// calendar components determine the canonical UTC companion.
|
|
152
|
-
match = value.match(/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+([A-Za-z]{3})\s+(\d{1,2})\s+(\d{4})(?:\s+(\d{1,2}):(\d{2}):(\d{2}))?$/i);
|
|
153
|
-
if (match) {
|
|
154
|
-
const month = REGISTRY_MONTHS[(match[1] ?? '').toLowerCase()];
|
|
155
|
-
return month
|
|
156
|
-
? utcDateFromParts(+(match[3] ?? ''), month, +(match[2] ?? ''), +(match[4] || 0), +(match[5] || 0), +(match[6] || 0))
|
|
157
|
-
: null;
|
|
158
|
-
}
|
|
159
|
-
// Month DD YYYY - ISNIC publishes English month names with variable
|
|
160
|
-
// spacing and no timezone. Keep the accepted shape narrow and accept only
|
|
161
|
-
// the twelve exact English names from a fixed table.
|
|
162
|
-
match = value.match(/^([A-Za-z]{3,9})\s+(\d{1,2})\s+(\d{4})$/);
|
|
163
|
-
if (match) {
|
|
164
|
-
const month = REGISTRY_MONTH_NAMES[(match[1] ?? '').toLowerCase()];
|
|
165
|
-
return month ? utcDateFromParts(+(match[3] ?? ''), month, +(match[2] ?? '')) : null;
|
|
166
|
-
}
|
|
167
|
-
// ISO 8601-shaped dates and timestamps. A missing timezone is deliberately
|
|
168
|
-
// interpreted as UTC so Express and function deployments agree.
|
|
169
|
-
match = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:[Tt ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(?:([Zz])|([+-])(\d{2}):?(\d{2}))?)?$/);
|
|
170
|
-
if (!match)
|
|
171
|
-
return null;
|
|
172
|
-
const [, year = '', month = '', day = '', hour = '0', minute = '0', second = '0', fraction, zulu, offsetSign, offsetHours = '0', offsetMinutes = '0',] = match;
|
|
173
|
-
const millisecond = fraction ? Number(`${fraction}000`.slice(0, 3)) : 0;
|
|
174
|
-
const local = utcDateFromParts(+year, +month, +day, +(hour || 0), +(minute || 0), +(second || 0), millisecond);
|
|
175
|
-
if (!local)
|
|
176
|
-
return null;
|
|
177
|
-
if (!offsetSign || zulu)
|
|
178
|
-
return local;
|
|
179
|
-
const offsetHour = +offsetHours;
|
|
180
|
-
const offsetMinute = +offsetMinutes;
|
|
181
|
-
if (offsetHour > 23 || offsetMinute > 59)
|
|
182
|
-
return null;
|
|
183
|
-
const offsetMs = (offsetHour * 60 + offsetMinute) * 60000;
|
|
184
|
-
return new Date(local.getTime() + (offsetSign === '+' ? -offsetMs : offsetMs));
|
|
185
|
-
}
|
|
186
|
-
function registryDateIso(value) {
|
|
187
|
-
const date = parseRegistryDate(value);
|
|
188
|
-
return date ? date.toISOString() : null;
|
|
189
|
-
}
|
|
190
|
-
export { parseRegistryDate, registryDateIso };
|
|
1
|
+
// Stable source-facing entry point; portable consumers use the evidence owner.
|
|
2
|
+
export { parseRegistryDate, registryDateIso } from "../packages/evidence/registry-dates.mjs";
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import { compareRegistrySources } from "./registry-comparison.mjs";
|
|
8
8
|
import { inspectRdapCapabilities } from "./rdap-capabilities.mjs";
|
|
9
9
|
import { isRecord, recordOrEmpty } from "./json-record.mjs";
|
|
10
|
+
import { MAX_RDAP_REDACTIONS, MAX_RDAP_STATUSES } from "./rdap-types.mjs";
|
|
11
|
+
import { MAX_WHOIS_STATUSES } from "./whois-contracts.mjs";
|
|
10
12
|
export const REGISTRY_INSIGHTS_VERSION = 1;
|
|
11
13
|
export const MAX_REGISTRY_INSIGHT_STATUSES = 40;
|
|
12
14
|
export const MAX_REGISTRY_INSIGHT_ISSUES = 12;
|
|
@@ -37,6 +39,20 @@ function strings(value, maximum = MAX_REGISTRY_INSIGHT_STATUSES) {
|
|
|
37
39
|
function normalizedStatus(value) {
|
|
38
40
|
return text(value, 160).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
|
39
41
|
}
|
|
42
|
+
function statusInventory(value, maximum) {
|
|
43
|
+
if (!Array.isArray(value))
|
|
44
|
+
return { values: [], partial: value !== undefined };
|
|
45
|
+
const unique = new Set();
|
|
46
|
+
let partial = value.length > maximum;
|
|
47
|
+
for (const item of value.slice(0, maximum)) {
|
|
48
|
+
if (typeof item !== 'string' || item.length > 160 || CONTROL_RE.test(item) || !item.trim()) {
|
|
49
|
+
partial = true;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
unique.add(item.trim());
|
|
53
|
+
}
|
|
54
|
+
return { values: [...unique], partial };
|
|
55
|
+
}
|
|
40
56
|
function sourceState(status, partial) {
|
|
41
57
|
if (partial || status === 'partial')
|
|
42
58
|
return 'partial';
|
|
@@ -45,9 +61,13 @@ function sourceState(status, partial) {
|
|
|
45
61
|
? 'complete'
|
|
46
62
|
: 'unavailable';
|
|
47
63
|
}
|
|
48
|
-
function
|
|
49
|
-
const redactions = Array.isArray(parsed.redactions) ? parsed.redactions.slice(0,
|
|
50
|
-
return redactions.
|
|
64
|
+
function registrantRedactionBlob(parsed) {
|
|
65
|
+
const redactions = Array.isArray(parsed.redactions) ? parsed.redactions.slice(0, MAX_RDAP_REDACTIONS) : [];
|
|
66
|
+
return redactions.filter((item) => {
|
|
67
|
+
// A path or reason can mention a role without selecting it. Do not infer
|
|
68
|
+
// registrant scope from arbitrary JSONPath expressions or other prose.
|
|
69
|
+
return /^(?:registry )?registrant(?:\b|$)/iu.test(text(record(item).name, 240));
|
|
70
|
+
}).map((item) => {
|
|
51
71
|
const value = record(item);
|
|
52
72
|
return [
|
|
53
73
|
value.name,
|
|
@@ -92,7 +112,8 @@ function disclosure(source, parsedValue, status) {
|
|
|
92
112
|
? parsed.chainStatus === 'partial' || strings(parsed.fieldsTruncated, 20).length > 0
|
|
93
113
|
: parsed.serverTruncated === true
|
|
94
114
|
|| parsed.entitiesTruncated === true
|
|
95
|
-
|| parsed.statusesTruncated === true
|
|
115
|
+
|| parsed.statusesTruncated === true
|
|
116
|
+
|| parsed.redactionsTruncated === true;
|
|
96
117
|
const publicationState = sourceState(status, partial);
|
|
97
118
|
if (publicationState === 'unavailable') {
|
|
98
119
|
return {
|
|
@@ -110,7 +131,7 @@ function disclosure(source, parsedValue, status) {
|
|
|
110
131
|
parsed.registrantPhone,
|
|
111
132
|
parsed.registrantAddress,
|
|
112
133
|
].map((part) => text(part, 320)).filter(Boolean).join(' ');
|
|
113
|
-
const redactions =
|
|
134
|
+
const redactions = registrantRedactionBlob(parsed);
|
|
114
135
|
const combined = `${registrant} ${redactions}`.trim();
|
|
115
136
|
if (WITHHELD_RE.test(combined)) {
|
|
116
137
|
return { source, state: 'withheld', detail: 'The publication indicates that registrant data was withheld or not disclosed.' };
|
|
@@ -124,6 +145,13 @@ function disclosure(source, parsedValue, status) {
|
|
|
124
145
|
if (registrant) {
|
|
125
146
|
return { source, state: 'public', detail: 'The source published at least one usable registrant contact field.' };
|
|
126
147
|
}
|
|
148
|
+
if (Array.isArray(parsed.redactions) && parsed.redactions.length > 0) {
|
|
149
|
+
return {
|
|
150
|
+
source,
|
|
151
|
+
state: 'unavailable',
|
|
152
|
+
detail: 'Redaction declarations do not establish registrant scope, so missing registrant fields remain unclassified.',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
127
155
|
if (publicationState === 'partial') {
|
|
128
156
|
return {
|
|
129
157
|
source,
|
|
@@ -137,21 +165,22 @@ function disclosure(source, parsedValue, status) {
|
|
|
137
165
|
detail: 'The source completed without a usable registrant contact or an explicit disclosure marker.',
|
|
138
166
|
};
|
|
139
167
|
}
|
|
140
|
-
function lifecycle(
|
|
141
|
-
const rawStatuses =
|
|
142
|
-
const normalized =
|
|
168
|
+
function lifecycle(statuses, evidenceState) {
|
|
169
|
+
const rawStatuses = statuses.slice(0, MAX_REGISTRY_INSIGHT_STATUSES);
|
|
170
|
+
const normalized = statuses.map(normalizedStatus);
|
|
143
171
|
const has = (value) => normalized.includes(value);
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
const
|
|
172
|
+
const observedState = (value) => has(value) ? true : evidenceState === 'complete' ? false : null;
|
|
173
|
+
const pendingDelete = observedState('pendingdelete');
|
|
174
|
+
const redemption = observedState('redemptionperiod');
|
|
175
|
+
const pendingTransfer = observedState('pendingtransfer');
|
|
176
|
+
const serverHold = observedState('serverhold');
|
|
177
|
+
const clientHold = observedState('clienthold');
|
|
149
178
|
const serverLocks = normalized.filter((value) => /^server(?:delete|renew|transfer|update)prohibited$/u.test(value));
|
|
150
179
|
const clientLocks = normalized.filter((value) => /^client(?:delete|renew|transfer|update)prohibited$/u.test(value));
|
|
151
|
-
let stage = '
|
|
152
|
-
let label = '
|
|
153
|
-
let acquisitionPath = ['
|
|
154
|
-
if (pendingDelete) {
|
|
180
|
+
let stage = 'unknown';
|
|
181
|
+
let label = 'Lifecycle unavailable';
|
|
182
|
+
let acquisitionPath = ['Obtain a qualifying registry or WHOIS publication before interpreting lifecycle or lock state.'];
|
|
183
|
+
if (pendingDelete === true) {
|
|
155
184
|
stage = 'pending_delete';
|
|
156
185
|
label = 'Pending delete';
|
|
157
186
|
acquisitionPath = [
|
|
@@ -160,7 +189,7 @@ function lifecycle(statusesValue) {
|
|
|
160
189
|
'Use a registrar or registry-supported acquisition path when policy permits.',
|
|
161
190
|
];
|
|
162
191
|
}
|
|
163
|
-
else if (redemption) {
|
|
192
|
+
else if (redemption === true) {
|
|
164
193
|
stage = 'redemption';
|
|
165
194
|
label = 'Redemption period';
|
|
166
195
|
acquisitionPath = [
|
|
@@ -169,16 +198,21 @@ function lifecycle(statusesValue) {
|
|
|
169
198
|
'Do not treat the domain as available or guaranteed to drop.',
|
|
170
199
|
];
|
|
171
200
|
}
|
|
172
|
-
else if (pendingTransfer) {
|
|
201
|
+
else if (pendingTransfer === true) {
|
|
173
202
|
stage = 'pending_transfer';
|
|
174
203
|
label = 'Pending transfer';
|
|
175
204
|
acquisitionPath = ['A registrar transfer is pending; this is not an availability signal.'];
|
|
176
205
|
}
|
|
177
|
-
else if (serverHold || clientHold) {
|
|
206
|
+
else if (serverHold === true || clientHold === true) {
|
|
178
207
|
stage = 'hold';
|
|
179
208
|
label = 'Registration hold';
|
|
180
209
|
acquisitionPath = ['A hold affects delegation or publication; it does not mean the registration is available.'];
|
|
181
210
|
}
|
|
211
|
+
else if (evidenceState === 'complete' || rawStatuses.length > 0) {
|
|
212
|
+
stage = 'registered';
|
|
213
|
+
label = 'Registered';
|
|
214
|
+
acquisitionPath = ['Continue monitoring registry status and expiry.'];
|
|
215
|
+
}
|
|
182
216
|
return {
|
|
183
217
|
stage,
|
|
184
218
|
label,
|
|
@@ -188,22 +222,26 @@ function lifecycle(statusesValue) {
|
|
|
188
222
|
pendingTransfer,
|
|
189
223
|
hold: { client: clientHold, server: serverHold },
|
|
190
224
|
locks: {
|
|
191
|
-
client: clientLocks.length > 0,
|
|
192
|
-
server: serverLocks.length > 0,
|
|
225
|
+
client: clientLocks.length > 0 ? true : evidenceState === 'complete' ? false : null,
|
|
226
|
+
server: serverLocks.length > 0 ? true : evidenceState === 'complete' ? false : null,
|
|
193
227
|
clientStatuses: clientLocks,
|
|
194
228
|
serverStatuses: serverLocks,
|
|
195
229
|
},
|
|
196
230
|
acquisitionPath,
|
|
197
|
-
limitation: 'EPP statuses describe the current registry lifecycle. They do not guarantee deletion, release timing, transfer completion, eligibility, price, or acquisition success.'
|
|
231
|
+
limitation: 'EPP statuses describe the current registry lifecycle. They do not guarantee deletion, release timing, transfer completion, eligibility, price, or acquisition success.'
|
|
232
|
+
+ (statuses.length > rawStatuses.length ? ` The display lists ${rawStatuses.length} of ${statuses.length} distinct statuses; lifecycle decisions use the full admitted inventory.` : ''),
|
|
198
233
|
};
|
|
199
234
|
}
|
|
200
|
-
function publicationDiagnostic(source, parsedValue, status, observedAt) {
|
|
235
|
+
function publicationDiagnostic(source, parsedValue, status, observedAt, statuses) {
|
|
201
236
|
const usablePublication = isRecord(parsedValue);
|
|
202
237
|
const parsed = record(parsedValue);
|
|
203
238
|
const partial = parsed.serverTruncated === true
|
|
204
239
|
|| parsed.chainStatus === 'partial'
|
|
205
240
|
|| parsed.entitiesTruncated === true
|
|
206
241
|
|| parsed.statusesTruncated === true
|
|
242
|
+
|| statuses.partial
|
|
243
|
+
|| parsed.eventsTruncated === true
|
|
244
|
+
|| parsed.redactionsTruncated === true
|
|
207
245
|
|| strings(parsed.fieldsTruncated, 20).length > 0;
|
|
208
246
|
const state = usablePublication ? sourceState(status, partial) : 'unavailable';
|
|
209
247
|
const issues = [];
|
|
@@ -213,14 +251,18 @@ function publicationDiagnostic(source, parsedValue, status, observedAt) {
|
|
|
213
251
|
issues.push('The RDAP server declared a truncated response.');
|
|
214
252
|
if (parsed.entitiesTruncated === true)
|
|
215
253
|
issues.push('The normalised entity inventory was capped.');
|
|
216
|
-
if (parsed.statusesTruncated === true)
|
|
217
|
-
issues.push('The normalised status inventory
|
|
254
|
+
if (parsed.statusesTruncated === true || statuses.partial)
|
|
255
|
+
issues.push('The normalised status inventory contains omitted or rejected values.');
|
|
256
|
+
if (parsed.eventsTruncated === true)
|
|
257
|
+
issues.push('The normalised event inventory contains omitted or rejected fields.');
|
|
258
|
+
if (parsed.redactionsTruncated === true)
|
|
259
|
+
issues.push('The normalised redaction inventory contains omitted or rejected declarations.');
|
|
218
260
|
if (strings(parsed.fieldsTruncated, 20).length)
|
|
219
261
|
issues.push('One or more normalised WHOIS fields were capped.');
|
|
220
262
|
if (parsed.chainStatus === 'partial')
|
|
221
263
|
issues.push('The WHOIS referral chain was incomplete or conflicting.');
|
|
222
264
|
const conformance = strings(parsed.conformance, 30);
|
|
223
|
-
const redactions = Array.isArray(parsed.redactions) ? parsed.redactions.slice(0,
|
|
265
|
+
const redactions = Array.isArray(parsed.redactions) ? parsed.redactions.slice(0, MAX_RDAP_REDACTIONS) : [];
|
|
224
266
|
return {
|
|
225
267
|
source,
|
|
226
268
|
state,
|
|
@@ -271,7 +313,20 @@ export function buildRegistryInsights(input) {
|
|
|
271
313
|
: counts.equivalent
|
|
272
314
|
? 'consistent'
|
|
273
315
|
: 'unavailable';
|
|
274
|
-
const
|
|
316
|
+
const rdapStatuses = statusInventory(rdap.statuses, MAX_RDAP_STATUSES);
|
|
317
|
+
const whoisStatuses = statusInventory(whois.statuses, MAX_WHOIS_STATUSES);
|
|
318
|
+
const publications = [
|
|
319
|
+
publicationDiagnostic('registry_rdap', input.rdapParsed, input.rdapStatus, input.rdapFetchedAt, rdapStatuses),
|
|
320
|
+
publicationDiagnostic('whois', input.whoisParsed, input.whoisStatus, input.whoisQueriedAt, whoisStatuses),
|
|
321
|
+
publicationDiagnostic('registrar_rdap', input.registrarRdapParsed, input.registrarRdapStatus, input.registrarRdapFetchedAt, statusInventory(registrarRdap.statuses, MAX_RDAP_STATUSES)),
|
|
322
|
+
];
|
|
323
|
+
const lifecycleCandidates = [
|
|
324
|
+
{ statuses: rdapStatuses.values, state: publications[0].state },
|
|
325
|
+
{ statuses: whoisStatuses.values, state: publications[1].state },
|
|
326
|
+
].filter((candidate) => candidate.state !== 'unavailable');
|
|
327
|
+
const lifecycleSource = lifecycleCandidates.find((candidate) => candidate.statuses.length)
|
|
328
|
+
?? lifecycleCandidates[0]
|
|
329
|
+
?? { statuses: [], state: 'unavailable' };
|
|
275
330
|
const routes = [
|
|
276
331
|
...contactRoute('registry', rdap.abuse, 'Registry RDAP abuse entity'),
|
|
277
332
|
...contactRoute('registrar', rdap.registrar, 'Registry RDAP registrar entity'),
|
|
@@ -294,7 +349,7 @@ export function buildRegistryInsights(input) {
|
|
|
294
349
|
whois: disclosure('whois', input.whoisParsed, input.whoisStatus),
|
|
295
350
|
limitation: 'Disclosure state describes what each point-in-time source published. It does not infer the identity, intent, reachability, or legal status of a registrant.',
|
|
296
351
|
},
|
|
297
|
-
lifecycle: lifecycle(statuses),
|
|
352
|
+
lifecycle: lifecycle(lifecycleSource.statuses, lifecycleSource.state),
|
|
298
353
|
reconciliation: {
|
|
299
354
|
state: reconciliationState,
|
|
300
355
|
conflictCount,
|
|
@@ -312,11 +367,7 @@ export function buildRegistryInsights(input) {
|
|
|
312
367
|
? 'The comparable fields are normalised as equivalent.'
|
|
313
368
|
: 'No comparable fields were available.',
|
|
314
369
|
},
|
|
315
|
-
publications
|
|
316
|
-
publicationDiagnostic('registry_rdap', input.rdapParsed, input.rdapStatus, input.rdapFetchedAt),
|
|
317
|
-
publicationDiagnostic('whois', input.whoisParsed, input.whoisStatus, input.whoisQueriedAt),
|
|
318
|
-
publicationDiagnostic('registrar_rdap', input.registrarRdapParsed, input.registrarRdapStatus, input.registrarRdapFetchedAt),
|
|
319
|
-
],
|
|
370
|
+
publications,
|
|
320
371
|
rdapCapabilities: {
|
|
321
372
|
registry: inspectRdapCapabilities(input.rdapParsed, input.rdapStatus),
|
|
322
373
|
registrar: inspectRdapCapabilities(input.registrarRdapParsed, input.registrarRdapStatus),
|
package/lib/response-policy.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Privacy-minimized interpretation of selected response policies. Header and
|
|
2
2
|
// cookie values are read transiently from the already-selected homepage
|
|
3
3
|
// response and reduced to fixed signal identifiers plus bounded counts.
|
|
4
|
-
export const RESPONSE_POLICY_VERSION =
|
|
4
|
+
export const RESPONSE_POLICY_VERSION = 3;
|
|
5
5
|
export const MAX_RESPONSE_POLICY_HEADER_BYTES = 8 * 1024;
|
|
6
6
|
export const MAX_CSP_META_POLICIES = 4;
|
|
7
7
|
export const MAX_RESPONSE_POLICY_DIRECTIVES = 64;
|
|
@@ -13,7 +13,9 @@ export const MIN_RECOMMENDED_HSTS_SECONDS = 180 * 24 * 60 * 60;
|
|
|
13
13
|
const CONTROL_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
|
|
14
14
|
const DIRECTIVE_NAME_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
15
15
|
const ATTRIBUTE_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]{1,128}$/;
|
|
16
|
-
|
|
16
|
+
// CSP source-list grammar, not an entropy or digest-strength judgement. Invalid
|
|
17
|
+
// source expressions are ignored by browsers and cannot suppress unsafe-inline.
|
|
18
|
+
const CSP_NONCE_OR_HASH_RE = /^'(?:nonce-|sha(?:256|384|512)-)[A-Za-z0-9+/_-]+={0,2}'$/i;
|
|
17
19
|
const REFERRER_POLICIES = new Set([
|
|
18
20
|
'no-referrer',
|
|
19
21
|
'no-referrer-when-downgrade',
|
|
@@ -42,7 +44,8 @@ function readHeader(headers, name) {
|
|
|
42
44
|
return { state: 'absent', value: null };
|
|
43
45
|
if (CONTROL_RE.test(raw))
|
|
44
46
|
return { state: 'malformed', value: null };
|
|
45
|
-
if (
|
|
47
|
+
if (raw.length > MAX_RESPONSE_POLICY_HEADER_BYTES
|
|
48
|
+
|| new TextEncoder().encode(raw).byteLength > MAX_RESPONSE_POLICY_HEADER_BYTES) {
|
|
46
49
|
return { state: 'partial', value: null };
|
|
47
50
|
}
|
|
48
51
|
return { state: 'present', value: raw.trim() };
|
|
@@ -55,61 +58,96 @@ function addSignal(signals, id, count) {
|
|
|
55
58
|
function unavailableHeaderState(header) {
|
|
56
59
|
return header.state === 'present' ? 'malformed' : header.state;
|
|
57
60
|
}
|
|
58
|
-
function
|
|
61
|
+
function inlineControl(sources, scriptsBlocked) {
|
|
62
|
+
if (scriptsBlocked)
|
|
63
|
+
return 'restricted';
|
|
64
|
+
if (!sources)
|
|
65
|
+
return 'uncontrolled';
|
|
66
|
+
return sources.includes("'unsafe-inline'")
|
|
67
|
+
&& !sources.includes("'strict-dynamic'")
|
|
68
|
+
&& !sources.some((token) => CSP_NONCE_OR_HASH_RE.test(token))
|
|
69
|
+
? 'unqualified'
|
|
70
|
+
: 'restricted';
|
|
71
|
+
}
|
|
72
|
+
function intersectInlineControls(controls) {
|
|
73
|
+
if (controls.includes('restricted'))
|
|
74
|
+
return 'restricted';
|
|
75
|
+
if (controls.includes('unknown'))
|
|
76
|
+
return 'unknown';
|
|
77
|
+
return controls.includes('unqualified') ? 'unqualified' : 'uncontrolled';
|
|
78
|
+
}
|
|
79
|
+
function analyzeCsp(header, signals, fromMeta = false) {
|
|
59
80
|
if (header.state !== 'present' || !header.value) {
|
|
60
81
|
return { state: unavailableHeaderState(header), inlineControl: 'unknown' };
|
|
61
82
|
}
|
|
62
|
-
|
|
83
|
+
// Fetch combines repeated policy headers with commas. Each policy is
|
|
84
|
+
// independently enforced; later policies cannot loosen an earlier one.
|
|
85
|
+
const policies = [];
|
|
86
|
+
let directiveCount = 0;
|
|
63
87
|
let tokenCount = 0;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
88
|
+
for (const rawPolicy of (fromMeta ? [header.value] : header.value.split(','))) {
|
|
89
|
+
const directives = new Map();
|
|
90
|
+
for (const rawDirective of rawPolicy.split(';')) {
|
|
91
|
+
const normalized = rawDirective.trim();
|
|
92
|
+
if (!normalized)
|
|
93
|
+
continue;
|
|
94
|
+
const [rawName = '', ...rawTokens] = normalized.split(/[\t\n\f\r ]+/u);
|
|
95
|
+
const name = rawName.toLowerCase();
|
|
96
|
+
if (!DIRECTIVE_NAME_RE.test(name))
|
|
97
|
+
return { state: 'malformed', inlineControl: 'unknown' };
|
|
98
|
+
// Duplicate directives use their first value. Count even ignored input
|
|
99
|
+
// against the aggregate work bound rather than resetting per policy.
|
|
100
|
+
directiveCount += 1;
|
|
101
|
+
tokenCount += rawTokens.length;
|
|
102
|
+
if (directiveCount > MAX_RESPONSE_POLICY_DIRECTIVES || tokenCount > MAX_RESPONSE_POLICY_TOKENS) {
|
|
103
|
+
return { state: 'partial', inlineControl: 'unknown' };
|
|
104
|
+
}
|
|
105
|
+
if (!directives.has(name))
|
|
106
|
+
directives.set(name, rawTokens.map((token) => token.toLowerCase()));
|
|
78
107
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (tokens.length !== rawTokens.length)
|
|
82
|
-
partial = true;
|
|
83
|
-
tokenCount += tokens.length;
|
|
84
|
-
directives.set(name, tokens);
|
|
108
|
+
if (directives.size)
|
|
109
|
+
policies.push(directives);
|
|
85
110
|
}
|
|
86
|
-
if (!
|
|
111
|
+
if (!policies.length)
|
|
87
112
|
return { state: 'malformed', inlineControl: 'unknown' };
|
|
88
|
-
|
|
89
|
-
const scriptSources = directives.get('script-src') || defaultSources;
|
|
90
|
-
if (!defaultSources)
|
|
113
|
+
if (policies.every((policy) => !policy.has('default-src')))
|
|
91
114
|
addSignal(signals, 'csp_default_source_missing');
|
|
92
|
-
if (!
|
|
115
|
+
if (policies.every((policy) => !policy.has('base-uri')))
|
|
93
116
|
addSignal(signals, 'csp_base_uri_missing');
|
|
94
|
-
if (!
|
|
117
|
+
if (policies.every((policy) => !policy.has('object-src') && !policy.has('default-src'))) {
|
|
95
118
|
addSignal(signals, 'csp_object_source_unbounded');
|
|
96
|
-
let inlineControl = scriptSources ? 'restricted' : 'uncontrolled';
|
|
97
|
-
if (scriptSources) {
|
|
98
|
-
if (scriptSources.some((token) => ['*', 'http:', 'https:', 'data:', 'blob:'].includes(token))) {
|
|
99
|
-
addSignal(signals, 'csp_permissive_script_source');
|
|
100
|
-
}
|
|
101
|
-
if (scriptSources.includes("'unsafe-eval'"))
|
|
102
|
-
addSignal(signals, 'csp_unsafe_eval');
|
|
103
|
-
if (scriptSources.includes("'unsafe-inline'")
|
|
104
|
-
&& !scriptSources.some((token) => CSP_NONCE_OR_HASH_RE.test(token))) {
|
|
105
|
-
addSignal(signals, 'csp_unsafe_inline');
|
|
106
|
-
inlineControl = 'unqualified';
|
|
107
|
-
}
|
|
108
119
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
120
|
+
const contexts = policies.map((policy) => {
|
|
121
|
+
const scripts = policy.get('script-src') ?? policy.get('default-src');
|
|
122
|
+
const elements = policy.get('script-src-elem') ?? scripts;
|
|
123
|
+
const attributes = policy.get('script-src-attr') ?? scripts;
|
|
124
|
+
const sandbox = fromMeta ? undefined : policy.get('sandbox');
|
|
125
|
+
const scriptsBlocked = sandbox !== undefined && !sandbox.includes('allow-scripts');
|
|
126
|
+
return {
|
|
127
|
+
blocks: inlineControl(elements, scriptsBlocked),
|
|
128
|
+
attributes: inlineControl(attributes, scriptsBlocked),
|
|
129
|
+
// script-src-elem/attr never govern evaluation, even when they contain
|
|
130
|
+
// an unsafe-eval token. An absent script/default source is uncontrolled.
|
|
131
|
+
evaluation: !scriptsBlocked && (!scripts || scripts.includes("'unsafe-eval'")),
|
|
132
|
+
declaresEvaluation: scripts?.includes("'unsafe-eval'") === true,
|
|
133
|
+
broadSource: !scriptsBlocked && (!elements || (!elements.includes("'strict-dynamic'")
|
|
134
|
+
&& elements.some((token) => ['*', 'http:', 'https:', 'data:', 'blob:'].includes(token)))),
|
|
135
|
+
declaresBroadSource: elements !== undefined,
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
if (contexts.every((context) => context.broadSource) && contexts.some((context) => context.declaresBroadSource)) {
|
|
139
|
+
addSignal(signals, 'csp_permissive_script_source');
|
|
140
|
+
}
|
|
141
|
+
if (contexts.every((context) => context.evaluation) && contexts.some((context) => context.declaresEvaluation)) {
|
|
142
|
+
addSignal(signals, 'csp_unsafe_eval');
|
|
143
|
+
}
|
|
144
|
+
const blocks = intersectInlineControls(contexts.map((context) => context.blocks));
|
|
145
|
+
if (blocks === 'unqualified')
|
|
146
|
+
addSignal(signals, 'csp_unsafe_inline');
|
|
147
|
+
if (intersectInlineControls(contexts.map((context) => context.attributes)) === 'unqualified') {
|
|
148
|
+
addSignal(signals, 'csp_unsafe_inline_attributes');
|
|
149
|
+
}
|
|
150
|
+
return { state: 'parsed', inlineControl: blocks };
|
|
113
151
|
}
|
|
114
152
|
export function analyzeCspMetaPolicies(value, sourceTruncated = false) {
|
|
115
153
|
const candidates = Array.isArray(value) ? value : [];
|
|
@@ -127,7 +165,7 @@ export function analyzeCspMetaPolicies(value, sourceTruncated = false) {
|
|
|
127
165
|
}
|
|
128
166
|
const policyContent = policy.content;
|
|
129
167
|
const content = readHeader({ get: () => policyContent }, 'content-security-policy');
|
|
130
|
-
const analysis = analyzeCsp(content, []);
|
|
168
|
+
const analysis = analyzeCsp(content, [], true);
|
|
131
169
|
if (analysis.state !== 'parsed') {
|
|
132
170
|
truncated = true;
|
|
133
171
|
continue;
|