@slicedearth/whoisleuth-cli 1.45.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DISCLOSURE +7 -3
- package/NOTICE +5 -4
- package/README.md +74 -124
- package/SECURITY.md +6 -3
- package/bin/whoisleuth.mjs +7 -4
- package/cli/archive-inspect.mjs +28 -10
- package/cli/arguments.mjs +450 -173
- package/cli/artifact-structure.mjs +2507 -0
- package/cli/artifact-verify.mjs +286 -64
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +35 -23
- package/cli/bulk-command-runner.mjs +2 -5
- package/cli/bulk.mjs +9 -2
- package/cli/case-pack.mjs +435 -46
- package/cli/ci-report.mjs +6 -1
- package/cli/collection-preflight.mjs +2 -2
- package/cli/command-catalogue.mjs +12 -3
- package/cli/command-reference.mjs +948 -106
- package/cli/completion.mjs +504 -218
- package/cli/config-profile.mjs +13 -3
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discover.mjs +7 -1
- package/cli/discovery-observation-snapshot.mjs +24 -28
- package/cli/discovery-scan-command-runner.mjs +13 -14
- package/cli/discovery-scan.mjs +17 -3
- package/cli/discovery-snapshot.mjs +15 -7
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +215 -36
- package/cli/domain-control-observations.mjs +34 -17
- package/cli/errors.mjs +8 -3
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +68 -50
- package/cli/export-evidence.mjs +11 -4
- package/cli/external-observation-mapping.mjs +3 -3
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/json.mjs +20 -32
- package/cli/formatters/terminal.mjs +586 -22
- package/cli/input.mjs +62 -0
- package/cli/interactive-launcher.mjs +226 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-plan.mjs +167 -16
- package/cli/investigation-run.mjs +80 -24
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookalike-calibration.mjs +3 -1
- package/cli/lookup-brief.mjs +13 -8
- package/cli/lookup-browser.mjs +844 -0
- package/cli/lookup-command-runner.mjs +96 -33
- package/cli/lookup-diff.mjs +11 -2
- package/cli/lookup-plan.mjs +1 -0
- package/cli/mail-review.mjs +206 -94
- package/cli/manual.mjs +5 -5
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +45 -14
- package/cli/open-asset-model-bridge.mjs +1 -1
- package/cli/output-file.mjs +70 -14
- package/cli/page-compare.mjs +18 -10
- package/cli/progress-events.mjs +2 -1
- package/cli/progress.mjs +3 -3
- package/cli/registry-cohort.mjs +343 -61
- package/cli/registry-doctor.mjs +67 -28
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +375 -0
- package/cli/risk-calibration.mjs +89 -40
- package/cli/runner.mjs +322 -246
- package/cli/saved-lookup.mjs +439 -49
- package/cli/sharing-review.mjs +18 -7
- package/cli/source-reliability.mjs +75 -36
- package/cli/terminal-presentation.mjs +43 -18
- package/docs/cli-reference.md +125 -1334
- package/docs/cli.md +87 -236
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
- package/frontend/src/lib/analysis/domain-control-records.js +3 -0
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +72 -52
- package/lib/bounded-contract-normalizers.mjs +2 -4
- package/lib/bounded-file.mjs +91 -4
- package/lib/bounded-json.mjs +287 -0
- package/lib/browser-library-profile.mjs +1 -1
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/classify.mjs +56 -5
- package/lib/client-behavior-profile.mjs +1 -1
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/cryptographic-assurance.mjs +173 -0
- package/lib/ct-query.mjs +3 -3
- package/lib/ct-search.mjs +120 -64
- package/lib/distributed-operation-budget.mjs +4 -2
- package/lib/dns-delegation-health.mjs +9 -6
- package/lib/dns-intelligence.mjs +4 -3
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1052 -0
- package/lib/dnssec-evidence-validation.mjs +23 -10
- package/lib/domain-assurance.mjs +12 -12
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-flight-recorder.mjs +442 -78
- package/lib/domain-control-manifest.mjs +314 -171
- package/lib/domain-posture-analysis.mjs +5 -2
- package/lib/domain-posture.mjs +7 -4
- package/lib/encrypted-dns-contract.mjs +2 -3
- package/lib/evidence-export.mjs +1243 -88
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/external-intelligence-risk.mjs +26 -10
- package/lib/favicon.mjs +8 -5
- package/lib/feature-policy.mjs +1 -1
- package/lib/homepage-metadata-contract.mjs +290 -0
- package/lib/hostname.mjs +1 -1
- package/lib/html-signals.mjs +89 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +332 -6
- package/lib/interchange-fidelity-registry.mjs +124 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-cache.mjs +6 -4
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup.mjs +27 -4
- package/lib/observed-network-context.mjs +1 -1
- package/lib/outbound-identity.mjs +2 -3
- package/lib/page-fingerprints.mjs +2 -2
- package/lib/page-role-profile.mjs +1 -1
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +29 -0
- package/lib/provider-policy-admission.mjs +11 -4
- package/lib/rdap-bootstrap.mjs +76 -16
- package/lib/rdap-client.mjs +20 -12
- package/lib/rdap-normalization.mjs +324 -80
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/rdap-transport.mjs +2 -2
- package/lib/rdap-validation.mjs +65 -14
- package/lib/rdap.mjs +2 -2
- package/lib/registrable-domain.mjs +36 -0
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/registry-insights.mjs +7 -0
- package/lib/response-policy.mjs +27 -4
- package/lib/risk-calibration-summary.mjs +340 -0
- package/lib/risk-scoring.mjs +4 -3
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +121 -50
- package/lib/security-txt.mjs +27 -4
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +787 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/sslbl-intelligence.mjs +6 -8
- package/lib/static-html-analysis.mjs +301 -4
- package/lib/structured-data-identity.mjs +51 -19
- package/lib/threat-intelligence-fixtures.mjs +2 -4
- package/lib/threat-intelligence-manifests.mjs +2 -7
- package/lib/threat-intelligence-runtime.mjs +5 -15
- package/lib/threat-intelligence-types.mjs +21 -1
- package/lib/threatfox-intelligence.mjs +7 -3
- package/lib/tls-intelligence.mjs +15 -9
- package/lib/tlsa-evidence.mjs +37 -7
- package/lib/urlhaus-intelligence.mjs +4 -2
- package/lib/urlscan-intelligence.mjs +6 -5
- package/lib/website-security-posture.mjs +1 -1
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-authority.mjs +43 -8
- package/lib/whois-chain.mjs +25 -3
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-transport.mjs +12 -3
- package/lib/zone-intent-review.mjs +1 -1
- package/package.json +12 -4
- package/packages/cases/case-brand-profile-references.mjs +85 -0
- package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
- package/packages/cases/case-investigation-branch-model.mjs +132 -0
- package/packages/cases/case-migration-model.mjs +476 -0
- package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
- package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -19
- package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
- package/packages/cases/case-record-model.mjs +9 -0
- package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +106 -33
- package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
- package/packages/cases/case-response-model.mjs +1604 -0
- package/packages/cases/case-response-packet.mjs +1312 -0
- package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
- package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
- package/packages/cases/opaque-reference-id.mjs +9 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
- package/packages/comparison/comparison-ledger-contract.mjs +241 -0
- package/packages/comparison/comparison-ledger-json.mjs +80 -0
- package/packages/comparison/comparison-ledger-serialization.mjs +52 -0
- package/packages/comparison/favicon-similarity.mjs +78 -0
- package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
- package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
- package/packages/contracts/analyst-review-state-contract.mjs +61 -0
- package/packages/contracts/analyst-review-state.mjs +50 -0
- package/packages/contracts/case-portability.mjs +1310 -0
- package/packages/contracts/cli-command-catalogue.mjs +2 -0
- package/packages/contracts/cli-lookup.mjs +394 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
- package/packages/contracts/domain-control-manifest.mjs +1027 -0
- package/packages/contracts/domain-control-monitor.mjs +310 -0
- package/packages/contracts/domain-control-review.mjs +828 -0
- package/packages/contracts/domain-name.mjs +4 -0
- package/packages/contracts/external-observation-interchange.mjs +224 -0
- package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
- package/packages/contracts/investigation-portability.mjs +331 -0
- package/packages/contracts/offline-comparison.mjs +160 -0
- package/packages/contracts/risk-calibration.mjs +1288 -0
- package/packages/contracts/schema-compatibility.mjs +71 -0
- package/packages/contracts/schema-lifecycle.mjs +1702 -0
- package/packages/contracts/sslbl-snapshot.mjs +17 -0
- package/packages/contracts/workspace-portability.mjs +1347 -0
- package/packages/evidence/artifact-integrity.mjs +65 -0
- package/packages/evidence/decision-fact.mjs +735 -0
- package/packages/evidence/domain-control-runtime.mjs +547 -0
- package/packages/evidence/domain-name.mjs +41 -0
- package/packages/evidence/observation.mjs +384 -0
- package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +86 -20
- package/packages/interchange/retained-text.mjs +8 -0
- package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
- package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
- package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
- package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
- package/packages/investigation/bulk-review-policy.mjs +1 -0
- package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
- package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
- package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
- package/packages/investigation/lookup-claim-passport.mjs +232 -0
- package/packages/investigation/lookup-claim-readiness.mjs +287 -0
- package/packages/investigation/lookup-investigation-brief.mjs +172 -0
- package/packages/monitoring/analyst-review-state.mjs +680 -0
- package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +97 -50
- package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
- package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
- package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -5
- package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
- package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
- package/packages/workspace/hostile-input.mjs +172 -0
- package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +51 -46
- package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
- package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
- package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
- package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
- package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
- package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
- package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +18 -12
- package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
- package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +195 -84
- package/third-party-notices.txt +112 -5834
- package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
- package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
- package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
- package/frontend/src/lib/analysis/case-migration-model.js +0 -253
- package/frontend/src/lib/analysis/case-record-model.js +0 -7
- package/frontend/src/lib/analysis/case-response-model.js +0 -669
- package/frontend/src/lib/analysis/case-response-packet.js +0 -573
- package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
- package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
- package/frontend/src/lib/analysis/lookup-response.js +0 -2
- package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
- package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
- package/frontend/src/lib/analysis/scoring.js +0 -55
- package/frontend/src/lib/analysis/utils.js +0 -232
- package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
- package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
- package/frontend/src/lib/analysis/visualization-matrix.js +0 -88
- package/frontend/src/lib/analysis/visualization-models.js +0 -369
- package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
- package/lib/bounded-json-response.mjs +0 -157
- package/lib/lookup-response-contract.mjs +0 -346
- package/lib/observation.mjs +0 -114
- /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
- /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
- /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { validateDnssecEvidence } from "./dnssec-evidence-validation.mjs";
|
|
2
|
+
import { reviewRpkiRoute } from "./rpki-evidence.mjs";
|
|
3
|
+
import { analyzeTlsaEvidence } from "./tlsa-evidence.mjs";
|
|
4
|
+
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
5
|
+
const CRYPTOGRAPHIC_ASSURANCE_INPUT_SCHEMA = 'whoisleuth.cryptographic-assurance.input';
|
|
6
|
+
const CRYPTOGRAPHIC_ASSURANCE_SCHEMA = 'whoisleuth.cryptographic-assurance.review';
|
|
7
|
+
const CRYPTOGRAPHIC_ASSURANCE_INPUT_VERSION = 1;
|
|
8
|
+
const CRYPTOGRAPHIC_ASSURANCE_REVIEW_VERSION = 2;
|
|
9
|
+
function exactRecord(value, keys, label) {
|
|
10
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
11
|
+
throw new TypeError(`${label} must be one object.`);
|
|
12
|
+
const record = value;
|
|
13
|
+
const unexpected = Object.keys(record).find((key) => !keys.has(key));
|
|
14
|
+
if (unexpected)
|
|
15
|
+
throw new TypeError(`${label} contains unsupported field "${unexpected}".`);
|
|
16
|
+
return record;
|
|
17
|
+
}
|
|
18
|
+
function sourceLabel(value, label) {
|
|
19
|
+
if (typeof value !== 'string')
|
|
20
|
+
throw new TypeError(`${label}.source must be text.`);
|
|
21
|
+
const normalized = value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim();
|
|
22
|
+
if (!normalized || normalized.length > 160)
|
|
23
|
+
throw new TypeError(`${label}.source must contain from 1 to 160 characters.`);
|
|
24
|
+
return normalized;
|
|
25
|
+
}
|
|
26
|
+
function observationTime(value, label) {
|
|
27
|
+
const normalized = normalizeExplicitIsoTimestamp(value);
|
|
28
|
+
if (!normalized) {
|
|
29
|
+
throw new TypeError(`${label}.observedAt must be one ISO-compatible timestamp.`);
|
|
30
|
+
}
|
|
31
|
+
return normalized;
|
|
32
|
+
}
|
|
33
|
+
function suppliedEvidence(value, label) {
|
|
34
|
+
if (value === null || value === undefined)
|
|
35
|
+
return null;
|
|
36
|
+
const wrapper = exactRecord(value, new Set(['source', 'observedAt', 'evidence']), label);
|
|
37
|
+
if (!wrapper.evidence || typeof wrapper.evidence !== 'object' || Array.isArray(wrapper.evidence)) {
|
|
38
|
+
throw new TypeError(`${label}.evidence must be one object.`);
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
source: sourceLabel(wrapper.source, label),
|
|
42
|
+
observedAt: observationTime(wrapper.observedAt, label),
|
|
43
|
+
evidence: wrapper.evidence,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function unavailableCard(family, label, authority) {
|
|
47
|
+
return Object.freeze({
|
|
48
|
+
family,
|
|
49
|
+
label,
|
|
50
|
+
authority,
|
|
51
|
+
source: null,
|
|
52
|
+
observedAt: null,
|
|
53
|
+
state: 'unavailable',
|
|
54
|
+
completeness: 'unavailable',
|
|
55
|
+
result: null,
|
|
56
|
+
limitations: Object.freeze([`No ${label.toLowerCase()} evidence was supplied. No state was inferred from another assurance family.`]),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function dnssecCompleteness(report) {
|
|
60
|
+
return report.state === 'consistent' || report.state === 'conflict' || report.state === 'unsigned'
|
|
61
|
+
? 'complete'
|
|
62
|
+
: report.state === 'invalid' ? 'unavailable' : 'partial';
|
|
63
|
+
}
|
|
64
|
+
function rpkiCompleteness(report) {
|
|
65
|
+
return report.state === 'invalid_input' ? 'unavailable' : 'partial';
|
|
66
|
+
}
|
|
67
|
+
function rpkiCardState(report) {
|
|
68
|
+
if (report.state === 'valid')
|
|
69
|
+
return 'matches_supplied_rows';
|
|
70
|
+
if (report.state === 'invalid')
|
|
71
|
+
return 'differs_from_supplied_rows';
|
|
72
|
+
if (report.state === 'not_found')
|
|
73
|
+
return 'not_found_in_supplied_rows';
|
|
74
|
+
return report.state;
|
|
75
|
+
}
|
|
76
|
+
function tlsaCompleteness(report) {
|
|
77
|
+
const comparisonMaterialComplete = !report.truncated
|
|
78
|
+
&& report.rejectedCount === 0
|
|
79
|
+
&& !report.authorityMaterialTruncated
|
|
80
|
+
&& report.authorityMaterialRejectedCount === 0
|
|
81
|
+
&& report.records.every((record) => record.state !== 'unavailable');
|
|
82
|
+
return comparisonMaterialComplete
|
|
83
|
+
&& (report.state === 'matched' || report.state === 'different' || report.state === 'untrusted')
|
|
84
|
+
? 'complete'
|
|
85
|
+
: report.state === 'invalid' || report.state === 'unavailable' ? 'unavailable' : 'partial';
|
|
86
|
+
}
|
|
87
|
+
function buildCryptographicAssuranceReview(inputValue, generatedAt = new Date().toISOString()) {
|
|
88
|
+
const input = exactRecord(inputValue, new Set(['schema', 'version', 'dnssec', 'routeOrigin', 'tlsa']), 'Cryptographic assurance input');
|
|
89
|
+
if (input.schema !== CRYPTOGRAPHIC_ASSURANCE_INPUT_SCHEMA || input.version !== CRYPTOGRAPHIC_ASSURANCE_INPUT_VERSION) {
|
|
90
|
+
throw new TypeError(`Cryptographic assurance input must use ${CRYPTOGRAPHIC_ASSURANCE_INPUT_SCHEMA} version ${CRYPTOGRAPHIC_ASSURANCE_INPUT_VERSION}.`);
|
|
91
|
+
}
|
|
92
|
+
const dnssecInput = suppliedEvidence(input.dnssec, 'dnssec');
|
|
93
|
+
const routeInput = suppliedEvidence(input.routeOrigin, 'routeOrigin');
|
|
94
|
+
const tlsaInput = suppliedEvidence(input.tlsa, 'tlsa');
|
|
95
|
+
if (!dnssecInput && !routeInput && !tlsaInput)
|
|
96
|
+
throw new TypeError('Cryptographic assurance input must supply at least one evidence family.');
|
|
97
|
+
const dnssec = dnssecInput ? validateDnssecEvidence({
|
|
98
|
+
ownerName: dnssecInput.evidence.ownerName,
|
|
99
|
+
delegationSigned: dnssecInput.evidence.delegationSigned,
|
|
100
|
+
dsRecords: dnssecInput.evidence.dsRecords,
|
|
101
|
+
dnskeyRecords: dnssecInput.evidence.dnskeyRecords,
|
|
102
|
+
rrSigRecords: dnssecInput.evidence.rrSigRecords,
|
|
103
|
+
observedAt: dnssecInput.evidence.observedAt ?? dnssecInput.observedAt,
|
|
104
|
+
}) : null;
|
|
105
|
+
const routeOrigin = routeInput ? reviewRpkiRoute({
|
|
106
|
+
routePrefix: routeInput.evidence.routePrefix,
|
|
107
|
+
originAsn: routeInput.evidence.originAsn,
|
|
108
|
+
authorizations: routeInput.evidence.authorizations,
|
|
109
|
+
}) : null;
|
|
110
|
+
const tlsa = tlsaInput ? analyzeTlsaEvidence({
|
|
111
|
+
serviceName: tlsaInput.evidence.serviceName,
|
|
112
|
+
dnssecState: 'unavailable',
|
|
113
|
+
pkixValidationState: 'unavailable',
|
|
114
|
+
records: tlsaInput.evidence.records,
|
|
115
|
+
certificateDerBase64: tlsaInput.evidence.certificateDerBase64,
|
|
116
|
+
spkiDerBase64: tlsaInput.evidence.spkiDerBase64,
|
|
117
|
+
authorityMaterials: tlsaInput.evidence.authorityMaterials,
|
|
118
|
+
}) : null;
|
|
119
|
+
const cards = [
|
|
120
|
+
dnssec && dnssecInput ? Object.freeze({
|
|
121
|
+
family: 'dnssec_validation',
|
|
122
|
+
label: 'DNSSEC evidence review',
|
|
123
|
+
authority: 'Supplied delegation, DS, DNSKEY, and signature-time evidence',
|
|
124
|
+
source: dnssecInput.source,
|
|
125
|
+
observedAt: dnssecInput.observedAt,
|
|
126
|
+
state: dnssec.state,
|
|
127
|
+
completeness: dnssecCompleteness(dnssec),
|
|
128
|
+
result: dnssec,
|
|
129
|
+
limitations: dnssec.limitations,
|
|
130
|
+
}) : unavailableCard('dnssec_validation', 'DNSSEC evidence review', 'Supplied delegation, DS, DNSKEY, and signature-time evidence'),
|
|
131
|
+
routeOrigin && routeInput ? Object.freeze({
|
|
132
|
+
family: 'route_origin_authorisation',
|
|
133
|
+
label: 'Route-origin authorisation',
|
|
134
|
+
authority: 'Analyst-supplied route-origin rows',
|
|
135
|
+
source: routeInput.source,
|
|
136
|
+
observedAt: routeInput.observedAt,
|
|
137
|
+
state: rpkiCardState(routeOrigin),
|
|
138
|
+
completeness: rpkiCompleteness(routeOrigin),
|
|
139
|
+
result: routeOrigin,
|
|
140
|
+
limitations: Object.freeze([
|
|
141
|
+
...routeOrigin.limitations,
|
|
142
|
+
'The supplied rows do not include bounded validator provenance, implementation identity, validation time, or a complete authenticated snapshot, so this card reports only their local comparison and remains partial.',
|
|
143
|
+
]),
|
|
144
|
+
}) : unavailableCard('route_origin_authorisation', 'Route-origin authorisation', 'Analyst-supplied route-origin rows'),
|
|
145
|
+
tlsa && tlsaInput ? Object.freeze({
|
|
146
|
+
family: 'dane_tlsa',
|
|
147
|
+
label: 'DANE and TLSA evidence review',
|
|
148
|
+
authority: 'Supplied TLSA and certificate association material',
|
|
149
|
+
source: tlsaInput.source,
|
|
150
|
+
observedAt: tlsaInput.observedAt,
|
|
151
|
+
state: tlsa.state,
|
|
152
|
+
completeness: tlsaCompleteness(tlsa),
|
|
153
|
+
result: tlsa,
|
|
154
|
+
limitations: Object.freeze([
|
|
155
|
+
...tlsa.limitations,
|
|
156
|
+
'Caller-provided DNSSEC and PKIX state labels are not accepted as validator evidence. Positive DANE state requires separately validated prerequisite artefacts from an explicit collector.',
|
|
157
|
+
]),
|
|
158
|
+
}) : unavailableCard('dane_tlsa', 'DANE and TLSA evidence review', 'Supplied TLSA and certificate association material'),
|
|
159
|
+
];
|
|
160
|
+
return Object.freeze({
|
|
161
|
+
schema: CRYPTOGRAPHIC_ASSURANCE_SCHEMA,
|
|
162
|
+
version: CRYPTOGRAPHIC_ASSURANCE_REVIEW_VERSION,
|
|
163
|
+
generatedAt: observationTime(generatedAt, 'generatedAt'),
|
|
164
|
+
cards: Object.freeze(cards),
|
|
165
|
+
combinedState: null,
|
|
166
|
+
limitations: Object.freeze([
|
|
167
|
+
'DNSSEC, route-origin authorisation, and DANE or TLSA remain independently sourced evidence families with separate states, observation times, completeness, and limitations.',
|
|
168
|
+
'No scalar score or combined verdict is produced. A missing, failed, partial, or unsupported family is never inferred from another family.',
|
|
169
|
+
'These reviews do not establish ownership, availability, activity, safety, or maliciousness.',
|
|
170
|
+
]),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
export { CRYPTOGRAPHIC_ASSURANCE_INPUT_SCHEMA, CRYPTOGRAPHIC_ASSURANCE_INPUT_VERSION, CRYPTOGRAPHIC_ASSURANCE_REVIEW_VERSION, CRYPTOGRAPHIC_ASSURANCE_SCHEMA, buildCryptographicAssuranceReview, };
|
package/lib/ct-query.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const MAX_CT_QUERY_LENGTH = 200;
|
|
2
2
|
const CT_QUERY_ERROR_CODE = 'INVALID_CT_QUERY';
|
|
3
|
-
const CT_QUERY_ERROR_MESSAGE = `Certificate Transparency query must be at most ${MAX_CT_QUERY_LENGTH} characters and contain no control characters.`;
|
|
4
|
-
const
|
|
3
|
+
const CT_QUERY_ERROR_MESSAGE = `Certificate Transparency query must be at most ${MAX_CT_QUERY_LENGTH} characters and contain no control or invisible formatting characters.`;
|
|
4
|
+
const UNSAFE_QUERY_CHARACTER_RE = /[\u0000-\u001f\u007f-\u009f]|\p{Default_Ignorable_Code_Point}/u;
|
|
5
5
|
class CtQueryError extends Error {
|
|
6
6
|
code = CT_QUERY_ERROR_CODE;
|
|
7
7
|
constructor() {
|
|
@@ -14,7 +14,7 @@ function normalizeCtQuery(value) {
|
|
|
14
14
|
return '';
|
|
15
15
|
if (typeof value !== 'string'
|
|
16
16
|
|| value.length > MAX_CT_QUERY_LENGTH
|
|
17
|
-
||
|
|
17
|
+
|| UNSAFE_QUERY_CHARACTER_RE.test(value)) {
|
|
18
18
|
throw new CtQueryError();
|
|
19
19
|
}
|
|
20
20
|
return value.trim();
|
package/lib/ct-search.mjs
CHANGED
|
@@ -10,7 +10,9 @@ import { parse } from 'tldts';
|
|
|
10
10
|
import { normalizeCtQuery } from "./ct-query.mjs";
|
|
11
11
|
import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
|
|
12
12
|
import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
|
|
13
|
-
import { createObservation } from "
|
|
13
|
+
import { createObservation } from "../packages/evidence/observation.mjs";
|
|
14
|
+
import { isValidAsciiHostname } from "./hostname.mjs";
|
|
15
|
+
import { normalizeCtTimestamp } from "../packages/evidence/observation.mjs";
|
|
14
16
|
import { MAX_CT_RESPONSE_CERTIFICATE_GROUPS, MAX_CT_RESPONSE_DOMAINS_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_MATCH, MAX_CT_RESPONSE_RESULTS, MAX_CT_RESPONSE_TIMESTAMP_LENGTH, } from "./ct-response-bounds.mjs";
|
|
15
17
|
const CRT_SH_TIMEOUT_MS = 20000;
|
|
16
18
|
const MAX_RESULTS = MAX_CT_RESPONSE_RESULTS;
|
|
@@ -40,6 +42,12 @@ const MAX_TIMEOUT_RETRIES = 1;
|
|
|
40
42
|
// iteration/set overhead after parsing. Legitimate responses under the byte
|
|
41
43
|
// cap stay well within this limit.
|
|
42
44
|
const MAX_CT_ROWS = 50_000;
|
|
45
|
+
// crt.sh joins SAN values with newlines. Bound the amount of per-row and
|
|
46
|
+
// whole-response hostname work independently of the JSON byte and row caps so
|
|
47
|
+
// a small number of newline-dense strings cannot drive unbounded PSL parsing,
|
|
48
|
+
// Set growth, or sorting before the result projection is truncated.
|
|
49
|
+
const MAX_CT_NAMES_PER_ROW = 1_000;
|
|
50
|
+
const MAX_CT_NAMES_EXAMINED = 100_000;
|
|
43
51
|
// Structured-match bounds.
|
|
44
52
|
const MAX_MATCHES = MAX_RESULTS; // 500
|
|
45
53
|
const MAX_HOSTNAMES_PER_MATCH = MAX_CT_RESPONSE_HOSTNAMES_PER_MATCH;
|
|
@@ -56,10 +64,12 @@ const MAX_SERIAL_LENGTH = 128;
|
|
|
56
64
|
// Maximum length for an entry_timestamp string before it is rejected.
|
|
57
65
|
const MAX_TIMESTAMP_LENGTH = MAX_CT_RESPONSE_TIMESTAMP_LENGTH;
|
|
58
66
|
function normalizeHostname(raw) {
|
|
67
|
+
if (raw.length > 255)
|
|
68
|
+
return null;
|
|
59
69
|
let h = raw.trim().toLowerCase();
|
|
60
70
|
if (h.startsWith('*.'))
|
|
61
71
|
h = h.slice(2);
|
|
62
|
-
return HOSTNAME_RE.test(h) ? h : null;
|
|
72
|
+
return HOSTNAME_RE.test(h) && isValidAsciiHostname(h, { requireLowercase: true }) ? h : null;
|
|
63
73
|
}
|
|
64
74
|
function delay(ms) {
|
|
65
75
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -75,7 +85,7 @@ async function fetchCrtSh(keyword, attempt = 0, dependencies = {}) {
|
|
|
75
85
|
res = await fetcher(`https://crt.sh/?q=${encodeURIComponent(keyword)}&output=json`, {
|
|
76
86
|
headers: whoisleuthRequestHeaders({ Accept: 'application/json' }),
|
|
77
87
|
signal: controller.signal,
|
|
78
|
-
});
|
|
88
|
+
}, 0);
|
|
79
89
|
}
|
|
80
90
|
catch (err) {
|
|
81
91
|
if (!(err instanceof Error) || err.name !== 'AbortError')
|
|
@@ -98,7 +108,7 @@ async function fetchCrtSh(keyword, attempt = 0, dependencies = {}) {
|
|
|
98
108
|
? `crt.sh is temporarily overloaded (${res.status} after ${attempt + 1} attempts) - it's a free public service that intermittently struggles under load; try again in a moment.`
|
|
99
109
|
: `crt.sh returned ${res.status}`);
|
|
100
110
|
}
|
|
101
|
-
const { text, truncated } = await readTextCapped(res, CRT_SH_MAX_BYTES);
|
|
111
|
+
const { text, truncated } = await readTextCapped(res, CRT_SH_MAX_BYTES, { fatalUtf8: true });
|
|
102
112
|
if (truncated) {
|
|
103
113
|
throw new Error(`crt.sh returned more than ${CRT_SH_MAX_BYTES / (1024 * 1024)}MB of results for "${keyword}" - try a narrower/more specific keyword.`);
|
|
104
114
|
}
|
|
@@ -146,6 +156,16 @@ function resolveCertId(row, index) {
|
|
|
146
156
|
}
|
|
147
157
|
return `row:${index}`;
|
|
148
158
|
}
|
|
159
|
+
function suppliedCertificateIdentityMalformed(row) {
|
|
160
|
+
const hasId = Object.hasOwn(row, 'id') && row.id !== null && row.id !== undefined;
|
|
161
|
+
if (hasId)
|
|
162
|
+
return canonicalCertId(row.id) === null;
|
|
163
|
+
const hasIssuer = Object.hasOwn(row, 'issuer_ca_id') && row.issuer_ca_id !== null && row.issuer_ca_id !== undefined;
|
|
164
|
+
const hasSerial = Object.hasOwn(row, 'serial_number') && row.serial_number !== null && row.serial_number !== undefined;
|
|
165
|
+
return hasIssuer || hasSerial
|
|
166
|
+
? canonicalCertId(row.issuer_ca_id) === null || normalizeSerial(row.serial_number) === null
|
|
167
|
+
: false;
|
|
168
|
+
}
|
|
149
169
|
/**
|
|
150
170
|
* Canonicalises a crt.sh certificate ID field for use in identity comparison.
|
|
151
171
|
* Accepts only safe positive integers or digit-only strings that can be
|
|
@@ -221,20 +241,9 @@ function normalizeSerial(value) {
|
|
|
221
241
|
* @returns {string | null}
|
|
222
242
|
*/
|
|
223
243
|
function validateEntryTimestamp(value) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return null;
|
|
228
|
-
// Reject control characters and overlong strings before any parsing.
|
|
229
|
-
if (/[\x00-\x1f\x7f]/.test(value))
|
|
230
|
-
return null;
|
|
231
|
-
const trimmed = value.trim();
|
|
232
|
-
if (trimmed.length === 0)
|
|
233
|
-
return null;
|
|
234
|
-
const parsed = new Date(trimmed);
|
|
235
|
-
if (Number.isNaN(parsed.getTime()))
|
|
236
|
-
return null;
|
|
237
|
-
return parsed.toISOString();
|
|
244
|
+
return typeof value === 'string' && value.length <= MAX_TIMESTAMP_LENGTH
|
|
245
|
+
? normalizeCtTimestamp(value)
|
|
246
|
+
: null;
|
|
238
247
|
}
|
|
239
248
|
// ---------------------------------------------------------------------------
|
|
240
249
|
// Pure summarizer
|
|
@@ -255,6 +264,8 @@ function validateEntryTimestamp(value) {
|
|
|
255
264
|
* certificateCount: number
|
|
256
265
|
* }>,
|
|
257
266
|
* certificateGroups: CtCertificateGroup[],
|
|
267
|
+
* namesExamined: number,
|
|
268
|
+
* workTruncated: boolean,
|
|
258
269
|
* truncated: boolean
|
|
259
270
|
* }}
|
|
260
271
|
*/
|
|
@@ -275,14 +286,34 @@ function summarizeCtResults(rows) {
|
|
|
275
286
|
// firstObservedAt: string|null, lastObservedAt: string|null }>
|
|
276
287
|
const groupMap = new Map();
|
|
277
288
|
const certificateMap = new Map();
|
|
278
|
-
|
|
289
|
+
let namesExamined = 0;
|
|
290
|
+
let workTruncated = false;
|
|
291
|
+
let rejectedRows = 0;
|
|
292
|
+
rowLoop: for (let i = 0; i < rows.length; i++) {
|
|
279
293
|
const row = rows[i];
|
|
280
|
-
if (!row || typeof row !== 'object')
|
|
294
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) {
|
|
295
|
+
rejectedRows += 1;
|
|
281
296
|
continue;
|
|
297
|
+
}
|
|
298
|
+
const missingNameFields = ![row.name_value, row.common_name]
|
|
299
|
+
.some((value) => typeof value === 'string'
|
|
300
|
+
&& /[^\p{White_Space}\p{Default_Ignorable_Code_Point}\u0000-\u001f\u007f-\u009f]/u.test(value));
|
|
301
|
+
const malformedNameField = ['name_value', 'common_name'].some((field) => (Object.hasOwn(row, field) && typeof row[field] !== 'string'));
|
|
302
|
+
const malformedTimestamp = Object.hasOwn(row, 'entry_timestamp')
|
|
303
|
+
&& row.entry_timestamp !== null
|
|
304
|
+
&& row.entry_timestamp !== undefined
|
|
305
|
+
&& validateEntryTimestamp(row.entry_timestamp) === null;
|
|
306
|
+
if (missingNameFields || malformedNameField || malformedTimestamp || suppliedCertificateIdentityMalformed(row)) {
|
|
307
|
+
rejectedRows += 1;
|
|
308
|
+
}
|
|
282
309
|
const certId = resolveCertId(row, i);
|
|
283
|
-
// Collect
|
|
284
|
-
|
|
285
|
-
|
|
310
|
+
// Collect only direct string fields. Implicitly stringifying a nested
|
|
311
|
+
// array or object would traverse attacker-controlled structure before any
|
|
312
|
+
// hostname bound could take effect.
|
|
313
|
+
const nameFields = [
|
|
314
|
+
typeof row.name_value === 'string' ? row.name_value : '',
|
|
315
|
+
typeof row.common_name === 'string' ? row.common_name : '',
|
|
316
|
+
];
|
|
286
317
|
const ts = validateEntryTimestamp(row.entry_timestamp);
|
|
287
318
|
let certificate = certificateMap.get(certId);
|
|
288
319
|
if (!certificate) {
|
|
@@ -297,46 +328,65 @@ function summarizeCtResults(rows) {
|
|
|
297
328
|
else if (ts !== null && (certificate.observedAt === null || ts > certificate.observedAt)) {
|
|
298
329
|
certificate.observedAt = ts;
|
|
299
330
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
regDomain = registrableDomainByHostname.get(host);
|
|
312
|
-
}
|
|
313
|
-
else {
|
|
314
|
-
regDomain = parse(host).domain || null;
|
|
315
|
-
registrableDomainByHostname.set(host, regDomain);
|
|
316
|
-
}
|
|
317
|
-
if (!regDomain)
|
|
318
|
-
continue;
|
|
319
|
-
certificate.hostnames.add(host);
|
|
320
|
-
certificate.domains.add(regDomain);
|
|
321
|
-
let group = groupMap.get(regDomain);
|
|
322
|
-
if (!group) {
|
|
323
|
-
group = {
|
|
324
|
-
hostnames: new Set(),
|
|
325
|
-
certIds: new Set(),
|
|
326
|
-
firstObservedAt: null,
|
|
327
|
-
lastObservedAt: null,
|
|
328
|
-
};
|
|
329
|
-
groupMap.set(regDomain, group);
|
|
330
|
-
}
|
|
331
|
-
group.hostnames.add(host);
|
|
332
|
-
group.certIds.add(certId);
|
|
333
|
-
if (ts !== null) {
|
|
334
|
-
if (group.firstObservedAt === null || ts < group.firstObservedAt) {
|
|
335
|
-
group.firstObservedAt = ts;
|
|
331
|
+
let rowNamesExamined = 0;
|
|
332
|
+
for (const field of nameFields) {
|
|
333
|
+
let offset = 0;
|
|
334
|
+
while (offset <= field.length) {
|
|
335
|
+
if (namesExamined >= MAX_CT_NAMES_EXAMINED) {
|
|
336
|
+
workTruncated = true;
|
|
337
|
+
break rowLoop;
|
|
338
|
+
}
|
|
339
|
+
if (rowNamesExamined >= MAX_CT_NAMES_PER_ROW) {
|
|
340
|
+
workTruncated = true;
|
|
341
|
+
continue rowLoop;
|
|
336
342
|
}
|
|
337
|
-
|
|
338
|
-
|
|
343
|
+
const newline = field.indexOf('\n', offset);
|
|
344
|
+
const line = newline === -1 ? field.slice(offset) : field.slice(offset, newline);
|
|
345
|
+
namesExamined += 1;
|
|
346
|
+
rowNamesExamined += 1;
|
|
347
|
+
if (line.trim().startsWith('*.'))
|
|
348
|
+
certificate.wildcardObserved = true;
|
|
349
|
+
const host = normalizeHostname(line);
|
|
350
|
+
if (host) {
|
|
351
|
+
// Legacy: always add if valid hostname.
|
|
352
|
+
legacyDomains.add(host);
|
|
353
|
+
// Structured: resolve registrable domain.
|
|
354
|
+
let regDomain;
|
|
355
|
+
if (registrableDomainByHostname.has(host)) {
|
|
356
|
+
regDomain = registrableDomainByHostname.get(host);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
regDomain = parse(host).domain || null;
|
|
360
|
+
registrableDomainByHostname.set(host, regDomain);
|
|
361
|
+
}
|
|
362
|
+
if (regDomain) {
|
|
363
|
+
certificate.hostnames.add(host);
|
|
364
|
+
certificate.domains.add(regDomain);
|
|
365
|
+
let group = groupMap.get(regDomain);
|
|
366
|
+
if (!group) {
|
|
367
|
+
group = {
|
|
368
|
+
hostnames: new Set(),
|
|
369
|
+
certIds: new Set(),
|
|
370
|
+
firstObservedAt: null,
|
|
371
|
+
lastObservedAt: null,
|
|
372
|
+
};
|
|
373
|
+
groupMap.set(regDomain, group);
|
|
374
|
+
}
|
|
375
|
+
group.hostnames.add(host);
|
|
376
|
+
group.certIds.add(certId);
|
|
377
|
+
if (ts !== null) {
|
|
378
|
+
if (group.firstObservedAt === null || ts < group.firstObservedAt) {
|
|
379
|
+
group.firstObservedAt = ts;
|
|
380
|
+
}
|
|
381
|
+
if (group.lastObservedAt === null || ts > group.lastObservedAt) {
|
|
382
|
+
group.lastObservedAt = ts;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
339
386
|
}
|
|
387
|
+
if (newline === -1)
|
|
388
|
+
break;
|
|
389
|
+
offset = newline + 1;
|
|
340
390
|
}
|
|
341
391
|
}
|
|
342
392
|
}
|
|
@@ -404,7 +454,10 @@ function summarizeCtResults(rows) {
|
|
|
404
454
|
wildcardObserved: group.wildcardObserved,
|
|
405
455
|
})),
|
|
406
456
|
certificateGroupsTruncated,
|
|
407
|
-
|
|
457
|
+
namesExamined,
|
|
458
|
+
workTruncated,
|
|
459
|
+
rejectedRows,
|
|
460
|
+
truncated: rejectedRows > 0 || workTruncated || legacyTruncated || matchTruncated || perMatchTruncated,
|
|
408
461
|
};
|
|
409
462
|
}
|
|
410
463
|
// ---------------------------------------------------------------------------
|
|
@@ -416,11 +469,12 @@ async function searchCertificateTransparency(keyword, dependencies = {}) {
|
|
|
416
469
|
if (!trimmed) {
|
|
417
470
|
return {
|
|
418
471
|
domains: [], certCount: 0, truncated: false, matches: [], certificateGroups: [], certificateGroupsTruncated: false,
|
|
472
|
+
namesExamined: 0, workTruncated: false, rejectedRows: 0,
|
|
419
473
|
observation: createObservation({
|
|
420
474
|
status: 'success', observedAt: new Date().toISOString(), source: 'certificate_transparency',
|
|
421
475
|
durationMs: Date.now() - startedAt, complete: true, truncated: false,
|
|
422
476
|
limitations: ['Certificate Transparency observations indicate public certificate logging, not current site activity or maliciousness.'],
|
|
423
|
-
diagnostics: { certificateRows: 0, matches: 0, certificateGroups: 0 },
|
|
477
|
+
diagnostics: { certificateRows: 0, rejectedRows: 0, namesExamined: 0, workTruncated: false, matches: 0, certificateGroups: 0 },
|
|
424
478
|
}),
|
|
425
479
|
};
|
|
426
480
|
}
|
|
@@ -438,9 +492,11 @@ async function searchCertificateTransparency(keyword, dependencies = {}) {
|
|
|
438
492
|
truncated: summary.truncated || summary.certificateGroupsTruncated,
|
|
439
493
|
limitations: [
|
|
440
494
|
'Certificate Transparency observations indicate public certificate logging, not current site activity or maliciousness.',
|
|
495
|
+
...(summary.rejectedRows ? [`${summary.rejectedRows} certificate row${summary.rejectedRows === 1 ? '' : 's'} contained rejected content; unusable rows or malformed fields were excluded, so retained results are partial.`] : []),
|
|
496
|
+
...(summary.workTruncated ? ['The response contained more hostname entries than the bounded local analysis could examine; retained results are partial.'] : []),
|
|
441
497
|
...(summary.certificateGroupsTruncated ? ['The optional certificate-group projection was capped independently of the registrable-domain result set.'] : []),
|
|
442
498
|
],
|
|
443
|
-
diagnostics: { certificateRows: data.length, matches: summary.matches.length, certificateGroups: summary.certificateGroups.length, certificateGroupsTruncated: summary.certificateGroupsTruncated },
|
|
499
|
+
diagnostics: { certificateRows: data.length, rejectedRows: summary.rejectedRows, namesExamined: summary.namesExamined, workTruncated: summary.workTruncated, matches: summary.matches.length, certificateGroups: summary.certificateGroups.length, certificateGroupsTruncated: summary.certificateGroupsTruncated },
|
|
444
500
|
}),
|
|
445
501
|
};
|
|
446
502
|
}
|
|
@@ -8,6 +8,8 @@ import crypto from 'node:crypto';
|
|
|
8
8
|
import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
|
|
9
9
|
const DEFAULT_NAMESPACE = 'whoisleuth:operation-budget:v1';
|
|
10
10
|
const DEFAULT_LEASE_TTL_MS = 5 * 60 * 1000;
|
|
11
|
+
const MIN_LEASE_TTL_MS = 30_000;
|
|
12
|
+
const MAX_LEASE_TTL_MS = 15 * 60 * 1000;
|
|
11
13
|
const REQUEST_TIMEOUT_MS = 4_000;
|
|
12
14
|
const MAX_RESPONSE_BYTES = 16 * 1024;
|
|
13
15
|
const PROVIDER_RETRY_AFTER_SECONDS = 5;
|
|
@@ -150,7 +152,7 @@ function normalizedNamespace(value = DEFAULT_NAMESPACE) {
|
|
|
150
152
|
}
|
|
151
153
|
function normalizedLeaseTtl(value = DEFAULT_LEASE_TTL_MS) {
|
|
152
154
|
const ttl = Number(value);
|
|
153
|
-
if (!Number.isSafeInteger(ttl) || ttl <
|
|
155
|
+
if (!Number.isSafeInteger(ttl) || ttl < MIN_LEASE_TTL_MS || ttl > MAX_LEASE_TTL_MS) {
|
|
154
156
|
throw new Error('The distributed-budget lease TTL must be between 30 seconds and 15 minutes');
|
|
155
157
|
}
|
|
156
158
|
return ttl;
|
|
@@ -458,4 +460,4 @@ function createDistributedOperationBudget(config, dependencies = {}) {
|
|
|
458
460
|
status,
|
|
459
461
|
};
|
|
460
462
|
}
|
|
461
|
-
export { DEFAULT_NAMESPACE, DEFAULT_LEASE_TTL_MS, PROVIDER_RETRY_AFTER_SECONDS, DAY_WINDOW_MS, THIRTY_DAY_WINDOW_MS, ACQUIRE_SCRIPT, ACQUIRE_WITH_USAGE_SCRIPT, RELEASE_SCRIPT, STATUS_SCRIPT, normalizedRestUrl, normalizedToken, normalizedNamespace, normalizedLimits, normalizedUsageLimits, normalizedOperationFeature, createRestCommandClient, createDistributedOperationBudget, };
|
|
463
|
+
export { DEFAULT_NAMESPACE, DEFAULT_LEASE_TTL_MS, MIN_LEASE_TTL_MS, MAX_LEASE_TTL_MS, REQUEST_TIMEOUT_MS, MAX_RESPONSE_BYTES, PROVIDER_RETRY_AFTER_SECONDS, MAX_USAGE_LIMIT, DAY_WINDOW_MS, THIRTY_DAY_WINDOW_MS, ACQUIRE_SCRIPT, ACQUIRE_WITH_USAGE_SCRIPT, RELEASE_SCRIPT, STATUS_SCRIPT, normalizedRestUrl, normalizedToken, normalizedNamespace, normalizedLimits, normalizedUsageLimits, normalizedOperationFeature, createRestCommandClient, createDistributedOperationBudget, };
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// availability and never treats a failed query as record absence.
|
|
5
5
|
import { promises as dns } from 'node:dns';
|
|
6
6
|
import * as net from 'node:net';
|
|
7
|
-
import { createObservation } from "
|
|
7
|
+
import { createObservation } from "../packages/evidence/observation.mjs";
|
|
8
|
+
import { isValidAsciiHostname } from "./hostname.mjs";
|
|
8
9
|
import { isPrivateAddress } from "./safe-fetch.mjs";
|
|
9
10
|
const DNS_DELEGATION_HEALTH_VERSION = 1;
|
|
10
11
|
const MAX_AUTHORITIES = 4;
|
|
@@ -26,10 +27,8 @@ function boundedText(value, maximum) {
|
|
|
26
27
|
return value.replace(/\s+/gu, ' ').trim() || null;
|
|
27
28
|
}
|
|
28
29
|
function hostname(value) {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
return null;
|
|
32
|
-
return normalized.split('.').every((label) => (/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/iu.test(label))) ? normalized : null;
|
|
30
|
+
const candidate = boundedText(value, 253)?.replace(/\.+$/u, '') ?? '';
|
|
31
|
+
return isValidAsciiHostname(candidate) ? candidate.toLowerCase() : null;
|
|
33
32
|
}
|
|
34
33
|
function hostnames(value, maximum = MAX_NAMESERVERS) {
|
|
35
34
|
const output = new Set();
|
|
@@ -529,7 +528,8 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
529
528
|
? 'Fewer than two complete authority observations were available for one or more record types'
|
|
530
529
|
: 'Observed A, AAAA, CAA, and MX answers agree across selected authorities', recordMatrix.map((row) => `${row.type}: ${row.state}`).join(' · '), 'Review propagation timing and the intended zone values before relying on a DNS change.')] : []),
|
|
531
530
|
];
|
|
532
|
-
const collectionIncomplete = parentStatus
|
|
531
|
+
const collectionIncomplete = parentStatus !== 'success'
|
|
532
|
+
|| candidates.length === 0
|
|
533
533
|
|| parentQuery.truncated === true
|
|
534
534
|
|| registry.truncated
|
|
535
535
|
|| authorities.some((authority) => authority.state !== 'success')
|
|
@@ -557,6 +557,9 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
557
557
|
'A direct answer does not prove global reachability, and a failed query is not evidence that the record is absent.',
|
|
558
558
|
'DNS health does not decide registration availability, ownership, control, intent, safety, or maliciousness.',
|
|
559
559
|
'The DNSSEC check compares bounded registry publication fields; it does not validate the full cryptographic chain.',
|
|
560
|
+
...(!candidates.length
|
|
561
|
+
? ['No eligible authority was identified, so the delegation-health observation is incomplete.']
|
|
562
|
+
: []),
|
|
560
563
|
],
|
|
561
564
|
diagnostics: {
|
|
562
565
|
authorityCount: authorities.length,
|
package/lib/dns-intelligence.mjs
CHANGED
|
@@ -6,12 +6,13 @@ import { promises as dns } from 'node:dns';
|
|
|
6
6
|
import * as net from 'node:net';
|
|
7
7
|
import { classifyMxRecords } from "./dns-mx.mjs";
|
|
8
8
|
import { collectDnsDelegationHealth, skippedDnsDelegationHealth, } from "./dns-delegation-health.mjs";
|
|
9
|
-
import { createObservation } from "
|
|
9
|
+
import { createObservation } from "../packages/evidence/observation.mjs";
|
|
10
|
+
import { MAX_LOOKUP_DNS_RECORDS_PER_TYPE, MAX_LOOKUP_REVERSE_DNS_PTR_RECORDS, } from "./lookup-network-evidence-bounds.mjs";
|
|
10
11
|
import { isPrivateAddress } from "./safe-fetch.mjs";
|
|
11
12
|
import { resolveServiceBindingRecords } from "./service-binding-dns.mjs";
|
|
12
13
|
const DNS_TIMEOUT_MS = 5000;
|
|
13
|
-
const MAX_RECORDS_PER_TYPE =
|
|
14
|
-
const MAX_PTR_RECORDS =
|
|
14
|
+
const MAX_RECORDS_PER_TYPE = MAX_LOOKUP_DNS_RECORDS_PER_TYPE;
|
|
15
|
+
const MAX_PTR_RECORDS = MAX_LOOKUP_REVERSE_DNS_PTR_RECORDS;
|
|
15
16
|
const MAX_HOSTNAME_LENGTH = 253;
|
|
16
17
|
const MAX_POLICY_LENGTH = 1024;
|
|
17
18
|
const MAX_ERROR_LENGTH = 180;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Resolver } from 'node:dns/promises';
|
|
2
2
|
import { isIP } from 'node:net';
|
|
3
|
+
import { isPrivateAddress } from "./safe-fetch.mjs";
|
|
3
4
|
const MAX_SELECTED_DNS_RESOLVERS = 3;
|
|
4
5
|
const MAX_SELECTED_DNS_RESOLVER_TEXT = 256;
|
|
5
6
|
function normalizeSelectedDnsResolvers(value) {
|
|
@@ -7,15 +8,16 @@ function normalizeSelectedDnsResolvers(value) {
|
|
|
7
8
|
throw new TypeError(`DNS resolver selection must contain at most ${MAX_SELECTED_DNS_RESOLVER_TEXT} characters.`);
|
|
8
9
|
}
|
|
9
10
|
const values = [...new Set(value.split(',').map((item) => item.trim()).filter(Boolean))];
|
|
10
|
-
if (!values.length || values.length > MAX_SELECTED_DNS_RESOLVERS
|
|
11
|
-
|
|
11
|
+
if (!values.length || values.length > MAX_SELECTED_DNS_RESOLVERS
|
|
12
|
+
|| values.some((item) => isIP(item) === 0 || isPrivateAddress(item))) {
|
|
13
|
+
throw new TypeError(`DNS resolver selection requires 1 to ${MAX_SELECTED_DNS_RESOLVERS} publicly routable literal IPv4 or IPv6 addresses.`);
|
|
12
14
|
}
|
|
13
15
|
return values;
|
|
14
16
|
}
|
|
15
17
|
function createSelectedDnsResolvers(servers) {
|
|
16
18
|
if (!Array.isArray(servers) || !servers.length || servers.length > MAX_SELECTED_DNS_RESOLVERS
|
|
17
|
-
|| servers.some((item) => typeof item !== 'string' || isIP(item) === 0)) {
|
|
18
|
-
throw new TypeError('Validated DNS resolver addresses are required.');
|
|
19
|
+
|| servers.some((item) => typeof item !== 'string' || isIP(item) === 0 || isPrivateAddress(item))) {
|
|
20
|
+
throw new TypeError('Validated publicly routable DNS resolver addresses are required.');
|
|
19
21
|
}
|
|
20
22
|
const resolver = new Resolver();
|
|
21
23
|
resolver.setServers([...servers]);
|