@slicedearth/whoisleuth-cli 2.3.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 +19 -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 +68 -6
- package/cli/artifact-validation/case-response.mjs +47 -41
- 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 +83 -61
- 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-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +39 -31
- package/cli/command-argument-grammar.mjs +4 -0
- package/cli/command-reference.mjs +145 -75
- package/cli/completion.mjs +298 -79
- 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 +44 -16
- package/cli/discovery-scan-command-runner.mjs +2 -0
- package/cli/discovery-scan.mjs +7 -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 +1 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +4 -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/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-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 +45 -0
- package/cli/runner.mjs +55 -6
- 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 +76 -30
- package/docs/cli.md +289 -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 +43 -3
- 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-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +36 -23
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +7 -2
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +219 -118
- package/lib/domain-control-manifest.mjs +117 -57
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +88 -6
- package/lib/domain-posture.mjs +113 -26
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -592
- package/lib/evidence-report.mjs +3 -1
- 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 +82 -85
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- 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 +7 -10
- package/lib/lookup-source-progress.mjs +17 -14
- package/lib/lookup.mjs +48 -27
- 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-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 +56 -18
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +17 -3
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +10 -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 +117 -146
- 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 +111 -71
- 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/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 +59 -25
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-core.mjs +3 -2
- package/packages/cases/case-record-operations.mjs +89 -27
- package/packages/cases/case-record-projection.mjs +23 -20
- package/packages/cases/case-report.mjs +63 -52
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +41 -1129
- 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 +38 -587
- 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 +25 -0
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
- package/packages/comparison/page-similarity.mjs +2 -1
- 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 +279 -116
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +1 -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 +0 -1
- 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 +160 -225
- 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 +155 -27
- 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 +89 -49
- 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 +161 -141
- 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 +158 -20
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +11 -5
- package/packages/workspace/bulk-session-model.mjs +95 -20
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- 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 -26
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +70 -30
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +85 -21
- package/third-party-notices.txt +29 -2
- 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,9 +165,9 @@ 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
172
|
const observedState = (value) => has(value) ? true : evidenceState === 'complete' ? false : null;
|
|
145
173
|
const pendingDelete = observedState('pendingdelete');
|
|
@@ -200,16 +228,20 @@ function lifecycle(statusesValue, evidenceState) {
|
|
|
200
228
|
serverStatuses: serverLocks,
|
|
201
229
|
},
|
|
202
230
|
acquisitionPath,
|
|
203
|
-
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.` : ''),
|
|
204
233
|
};
|
|
205
234
|
}
|
|
206
|
-
function publicationDiagnostic(source, parsedValue, status, observedAt) {
|
|
235
|
+
function publicationDiagnostic(source, parsedValue, status, observedAt, statuses) {
|
|
207
236
|
const usablePublication = isRecord(parsedValue);
|
|
208
237
|
const parsed = record(parsedValue);
|
|
209
238
|
const partial = parsed.serverTruncated === true
|
|
210
239
|
|| parsed.chainStatus === 'partial'
|
|
211
240
|
|| parsed.entitiesTruncated === true
|
|
212
241
|
|| parsed.statusesTruncated === true
|
|
242
|
+
|| statuses.partial
|
|
243
|
+
|| parsed.eventsTruncated === true
|
|
244
|
+
|| parsed.redactionsTruncated === true
|
|
213
245
|
|| strings(parsed.fieldsTruncated, 20).length > 0;
|
|
214
246
|
const state = usablePublication ? sourceState(status, partial) : 'unavailable';
|
|
215
247
|
const issues = [];
|
|
@@ -219,14 +251,18 @@ function publicationDiagnostic(source, parsedValue, status, observedAt) {
|
|
|
219
251
|
issues.push('The RDAP server declared a truncated response.');
|
|
220
252
|
if (parsed.entitiesTruncated === true)
|
|
221
253
|
issues.push('The normalised entity inventory was capped.');
|
|
222
|
-
if (parsed.statusesTruncated === true)
|
|
223
|
-
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.');
|
|
224
260
|
if (strings(parsed.fieldsTruncated, 20).length)
|
|
225
261
|
issues.push('One or more normalised WHOIS fields were capped.');
|
|
226
262
|
if (parsed.chainStatus === 'partial')
|
|
227
263
|
issues.push('The WHOIS referral chain was incomplete or conflicting.');
|
|
228
264
|
const conformance = strings(parsed.conformance, 30);
|
|
229
|
-
const redactions = Array.isArray(parsed.redactions) ? parsed.redactions.slice(0,
|
|
265
|
+
const redactions = Array.isArray(parsed.redactions) ? parsed.redactions.slice(0, MAX_RDAP_REDACTIONS) : [];
|
|
230
266
|
return {
|
|
231
267
|
source,
|
|
232
268
|
state,
|
|
@@ -277,14 +313,16 @@ export function buildRegistryInsights(input) {
|
|
|
277
313
|
: counts.equivalent
|
|
278
314
|
? 'consistent'
|
|
279
315
|
: 'unavailable';
|
|
316
|
+
const rdapStatuses = statusInventory(rdap.statuses, MAX_RDAP_STATUSES);
|
|
317
|
+
const whoisStatuses = statusInventory(whois.statuses, MAX_WHOIS_STATUSES);
|
|
280
318
|
const publications = [
|
|
281
|
-
publicationDiagnostic('registry_rdap', input.rdapParsed, input.rdapStatus, input.rdapFetchedAt),
|
|
282
|
-
publicationDiagnostic('whois', input.whoisParsed, input.whoisStatus, input.whoisQueriedAt),
|
|
283
|
-
publicationDiagnostic('registrar_rdap', input.registrarRdapParsed, input.registrarRdapStatus, input.registrarRdapFetchedAt),
|
|
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)),
|
|
284
322
|
];
|
|
285
323
|
const lifecycleCandidates = [
|
|
286
|
-
{ statuses:
|
|
287
|
-
{ statuses:
|
|
324
|
+
{ statuses: rdapStatuses.values, state: publications[0].state },
|
|
325
|
+
{ statuses: whoisStatuses.values, state: publications[1].state },
|
|
288
326
|
].filter((candidate) => candidate.state !== 'unavailable');
|
|
289
327
|
const lifecycleSource = lifecycleCandidates.find((candidate) => candidate.statuses.length)
|
|
290
328
|
?? lifecycleCandidates[0]
|
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;
|
|
@@ -207,6 +207,15 @@ export function parseRiskCalibrationSummaryReport(raw) {
|
|
|
207
207
|
throw new Error('Risk calibration summary must contain the fixed threshold set exactly once.');
|
|
208
208
|
}
|
|
209
209
|
const thresholds = Object.freeze(root.thresholds.map((value, index) => thresholdMetrics(value, `Threshold ${index + 1}`, RISK_CALIBRATION_SUMMARY_THRESHOLDS[index], positive + negative, positive, negative)));
|
|
210
|
+
for (let index = 1; index < thresholds.length; index += 1) {
|
|
211
|
+
const lower = thresholds[index - 1];
|
|
212
|
+
const higher = thresholds[index];
|
|
213
|
+
// The population and class totals are already fixed by thresholdMetrics.
|
|
214
|
+
// Raising the threshold can remove positive classifications, never add them.
|
|
215
|
+
if (higher.truePositive > lower.truePositive || higher.falsePositive > lower.falsePositive) {
|
|
216
|
+
throw new Error('Risk calibration positive classifications increase at a higher threshold.');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
210
219
|
if (!Array.isArray(root.strata) || root.strata.length > MAX_RISK_CALIBRATION_SUMMARY_STRATA) {
|
|
211
220
|
throw new Error('Risk calibration strata must be a bounded array.');
|
|
212
221
|
}
|
|
@@ -243,13 +252,18 @@ export function parseRiskCalibrationSummaryReport(raw) {
|
|
|
243
252
|
metrics: thresholdMetrics(stratum.metrics, `${label} metrics`, currentReviewThreshold, sampleCount),
|
|
244
253
|
});
|
|
245
254
|
}));
|
|
255
|
+
const currentMetrics = thresholds.find((metric) => metric.threshold === currentReviewThreshold);
|
|
246
256
|
for (const dimension of ['review_reason', 'scan_depth']) {
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
.reduce((sum, stratum) => sum + stratum.sampleCount, 0);
|
|
257
|
+
const members = strata.filter((stratum) => stratum.dimension === dimension);
|
|
258
|
+
const dimensionTotal = members.reduce((sum, stratum) => sum + stratum.sampleCount, 0);
|
|
250
259
|
if (dimensionTotal !== positive + negative) {
|
|
251
260
|
throw new Error(`Risk calibration ${dimension.replace('_', '-')} strata do not cover every included label exactly once.`);
|
|
252
261
|
}
|
|
262
|
+
for (const field of ['truePositive', 'falsePositive', 'trueNegative', 'falseNegative']) {
|
|
263
|
+
if (members.reduce((sum, stratum) => sum + stratum.metrics[field], 0) !== currentMetrics[field]) {
|
|
264
|
+
throw new Error(`Risk calibration ${dimension.replace('_', '-')} strata disagree with the current-threshold confusion counts.`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
253
267
|
}
|
|
254
268
|
const comparison = object(root.modelComparison, 'Risk model comparison');
|
|
255
269
|
exactKeys(comparison, MODEL_COMPARISON_KEYS, 'Risk model comparison');
|
package/lib/rpki-evidence.mjs
CHANGED
|
@@ -9,9 +9,10 @@ function record(value) {
|
|
|
9
9
|
: null;
|
|
10
10
|
}
|
|
11
11
|
function normalizeAsn(value) {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const number = typeof value === 'number' ? value
|
|
13
|
+
: typeof value === 'string' && value.length <= 16 && /^(?:AS)?\d{1,10}$/iu.test(value.trim())
|
|
14
|
+
? Number(value.trim().replace(/^AS/iu, '')) : NaN;
|
|
15
|
+
return Number.isSafeInteger(number) && number >= 0 && number <= 4_294_967_295 ? number : null;
|
|
15
16
|
}
|
|
16
17
|
function normalizeAuthorization(value) {
|
|
17
18
|
const source = record(value);
|
|
@@ -21,7 +22,8 @@ function normalizeAuthorization(value) {
|
|
|
21
22
|
const parsedPrefix = parseIpPrefix(prefix);
|
|
22
23
|
const asn = normalizeAsn(source.asn ?? source.asID ?? source.origin);
|
|
23
24
|
const maxLengthValue = source.maxLength ?? source.max_length ?? parsedPrefix?.length;
|
|
24
|
-
const maxLength =
|
|
25
|
+
const maxLength = typeof maxLengthValue === 'number' ? maxLengthValue
|
|
26
|
+
: typeof maxLengthValue === 'string' && /^\d{1,3}$/u.test(maxLengthValue) ? Number(maxLengthValue) : NaN;
|
|
25
27
|
const maximum = parsedPrefix?.family === 4 ? 32 : 128;
|
|
26
28
|
if (!parsedPrefix || asn === null || !Number.isInteger(maxLength)
|
|
27
29
|
|| maxLength < parsedPrefix.length || maxLength > maximum)
|
|
@@ -49,8 +51,9 @@ function reviewRpkiRoute(input) {
|
|
|
49
51
|
const baseLimitations = Object.freeze([
|
|
50
52
|
'This offline review evaluates an explicitly supplied route prefix and origin ASN against an analyst-supplied VRP snapshot.',
|
|
51
53
|
'It does not collect BGP announcements, establish route ownership, or prove that the snapshot was current or complete.',
|
|
54
|
+
'AS0 authorisations do not authorise an announcement. An AS0 route origin is invalid input; snapshot validation and signature verification remain external to this review.',
|
|
52
55
|
]);
|
|
53
|
-
if (!route || originAsn === null || !rows) {
|
|
56
|
+
if (!route || originAsn === null || originAsn === 0 || !rows) {
|
|
54
57
|
return Object.freeze({
|
|
55
58
|
schema: RPKI_EVIDENCE_SCHEMA,
|
|
56
59
|
version: RPKI_EVIDENCE_VERSION,
|