@slicedearth/whoisleuth-cli 1.45.0 → 1.47.4
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 +59 -6
- package/bin/whoisleuth.mjs +5 -3
- package/cli/arguments.mjs +256 -9
- package/cli/artifact-structure.mjs +1833 -0
- package/cli/artifact-verify.mjs +242 -49
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +2 -3
- package/cli/bulk-command-runner.mjs +2 -5
- package/cli/case-pack.mjs +819 -34
- package/cli/command-reference.mjs +48 -24
- package/cli/completion.mjs +16 -8
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discovery-scan-command-runner.mjs +4 -5
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +21 -15
- package/cli/domain-control-observations.mjs +25 -2
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +73 -45
- package/cli/export-evidence.mjs +11 -4
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/terminal.mjs +3 -0
- package/cli/input.mjs +57 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-run.mjs +71 -23
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookup-command-runner.mjs +2 -2
- package/cli/lookup-diff.mjs +9 -0
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +28 -8
- package/cli/page-compare.mjs +8 -5
- package/cli/registry-cohort.mjs +385 -61
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +376 -0
- package/cli/risk-calibration.mjs +7 -5
- package/cli/runner.mjs +147 -185
- package/cli/saved-lookup.mjs +26 -3
- package/cli/sharing-review.mjs +12 -6
- package/docs/cli-reference.md +444 -70
- package/docs/cli.md +24 -5
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
- package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +29 -5
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
- package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
- package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
- package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
- package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
- package/frontend/src/lib/analysis/bulk-sort.js +4 -0
- package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +8 -0
- package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
- package/frontend/src/lib/analysis/case-migration-model.js +83 -31
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-model.js +2 -0
- package/frontend/src/lib/analysis/case-record-operations.js +42 -12
- package/frontend/src/lib/analysis/case-report.js +39 -6
- package/frontend/src/lib/analysis/case-response-model.js +66 -7
- package/frontend/src/lib/analysis/case-response-packet.js +15 -5
- package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
- package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
- package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
- package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
- package/frontend/src/lib/analysis/domain-control-records.js +88 -0
- package/frontend/src/lib/analysis/external-findings-import.js +70 -6
- package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
- package/frontend/src/lib/analysis/investigation-guide.js +52 -10
- package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
- package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
- package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +61 -6
- package/frontend/src/lib/analysis/lookup-presentation.js +62 -0
- package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
- package/frontend/src/lib/analysis/utils.js +26 -3
- package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
- package/frontend/src/lib/analysis/visualization-models.js +63 -94
- package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
- package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
- package/frontend/src/lib/analysis/workspace-archive.js +104 -14
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +14 -12
- package/lib/bounded-file.mjs +13 -0
- package/lib/bounded-json-response.mjs +17 -4
- package/lib/bounded-json.mjs +249 -0
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/cryptographic-assurance.mjs +155 -0
- package/lib/ct-search.mjs +87 -47
- package/lib/dns-intelligence.mjs +3 -2
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1053 -0
- package/lib/dnssec-evidence-validation.mjs +10 -3
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-manifest.mjs +17 -129
- package/lib/domain-posture.mjs +7 -4
- package/lib/evidence-export.mjs +751 -78
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/favicon.mjs +8 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +2 -5
- package/lib/interchange-fidelity-registry.mjs +122 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup-response-contract.mjs +691 -3
- package/lib/lookup.mjs +2 -2
- package/lib/observation.mjs +7 -6
- package/lib/outbound-identity.mjs +2 -3
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +21 -0
- package/lib/provider-policy-admission.mjs +9 -4
- package/lib/rdap-normalization.mjs +18 -5
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/risk-calibration-summary.mjs +338 -0
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +59 -10
- package/lib/security-txt.mjs +26 -3
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +784 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/threat-intelligence-runtime.mjs +3 -11
- package/lib/threat-intelligence-types.mjs +18 -1
- package/lib/tls-intelligence.mjs +11 -5
- package/lib/tlsa-evidence.mjs +37 -7
- package/package.json +1 -1
- package/third-party-notices.txt +95 -5817
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { validateDnssecEvidence } from "./dnssec-evidence-validation.mjs";
|
|
2
|
+
import { reviewRpkiRoute } from "./rpki-evidence.mjs";
|
|
3
|
+
import { analyzeTlsaEvidence } from "./tlsa-evidence.mjs";
|
|
4
|
+
const CRYPTOGRAPHIC_ASSURANCE_INPUT_SCHEMA = 'whoisleuth.cryptographic-assurance.input';
|
|
5
|
+
const CRYPTOGRAPHIC_ASSURANCE_SCHEMA = 'whoisleuth.cryptographic-assurance.review';
|
|
6
|
+
const CRYPTOGRAPHIC_ASSURANCE_VERSION = 1;
|
|
7
|
+
function exactRecord(value, keys, label) {
|
|
8
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
9
|
+
throw new TypeError(`${label} must be one object.`);
|
|
10
|
+
const record = value;
|
|
11
|
+
const unexpected = Object.keys(record).find((key) => !keys.has(key));
|
|
12
|
+
if (unexpected)
|
|
13
|
+
throw new TypeError(`${label} contains unsupported field "${unexpected}".`);
|
|
14
|
+
return record;
|
|
15
|
+
}
|
|
16
|
+
function sourceLabel(value, label) {
|
|
17
|
+
if (typeof value !== 'string')
|
|
18
|
+
throw new TypeError(`${label}.source must be text.`);
|
|
19
|
+
const normalized = value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim();
|
|
20
|
+
if (!normalized || normalized.length > 160)
|
|
21
|
+
throw new TypeError(`${label}.source must contain from 1 to 160 characters.`);
|
|
22
|
+
return normalized;
|
|
23
|
+
}
|
|
24
|
+
function observationTime(value, label) {
|
|
25
|
+
if (typeof value !== 'string' || value.length > 64 || !Number.isFinite(Date.parse(value))) {
|
|
26
|
+
throw new TypeError(`${label}.observedAt must be one ISO-compatible timestamp.`);
|
|
27
|
+
}
|
|
28
|
+
return new Date(value).toISOString();
|
|
29
|
+
}
|
|
30
|
+
function suppliedEvidence(value, label) {
|
|
31
|
+
if (value === null || value === undefined)
|
|
32
|
+
return null;
|
|
33
|
+
const wrapper = exactRecord(value, new Set(['source', 'observedAt', 'evidence']), label);
|
|
34
|
+
if (!wrapper.evidence || typeof wrapper.evidence !== 'object' || Array.isArray(wrapper.evidence)) {
|
|
35
|
+
throw new TypeError(`${label}.evidence must be one object.`);
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
source: sourceLabel(wrapper.source, label),
|
|
39
|
+
observedAt: observationTime(wrapper.observedAt, label),
|
|
40
|
+
evidence: wrapper.evidence,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function unavailableCard(family, label, authority) {
|
|
44
|
+
return Object.freeze({
|
|
45
|
+
family,
|
|
46
|
+
label,
|
|
47
|
+
authority,
|
|
48
|
+
source: null,
|
|
49
|
+
observedAt: null,
|
|
50
|
+
state: 'unavailable',
|
|
51
|
+
completeness: 'unavailable',
|
|
52
|
+
result: null,
|
|
53
|
+
limitations: Object.freeze([`No ${label.toLowerCase()} evidence was supplied. No state was inferred from another assurance family.`]),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function dnssecCompleteness(report) {
|
|
57
|
+
return report.state === 'consistent' || report.state === 'conflict' || report.state === 'unsigned'
|
|
58
|
+
? 'complete'
|
|
59
|
+
: report.state === 'invalid' ? 'unavailable' : 'partial';
|
|
60
|
+
}
|
|
61
|
+
function rpkiCompleteness(report) {
|
|
62
|
+
return report.state === 'partial' ? 'partial' : report.state === 'invalid_input' ? 'unavailable' : 'complete';
|
|
63
|
+
}
|
|
64
|
+
function tlsaCompleteness(report) {
|
|
65
|
+
const comparisonMaterialComplete = !report.truncated
|
|
66
|
+
&& report.rejectedCount === 0
|
|
67
|
+
&& !report.authorityMaterialTruncated
|
|
68
|
+
&& report.authorityMaterialRejectedCount === 0
|
|
69
|
+
&& report.records.every((record) => record.state !== 'unavailable');
|
|
70
|
+
return comparisonMaterialComplete
|
|
71
|
+
&& (report.state === 'matched' || report.state === 'different' || report.state === 'untrusted')
|
|
72
|
+
? 'complete'
|
|
73
|
+
: report.state === 'invalid' || report.state === 'unavailable' ? 'unavailable' : 'partial';
|
|
74
|
+
}
|
|
75
|
+
function buildCryptographicAssuranceReview(inputValue, generatedAt = new Date().toISOString()) {
|
|
76
|
+
const input = exactRecord(inputValue, new Set(['schema', 'version', 'dnssec', 'routeOrigin', 'tlsa']), 'Cryptographic assurance input');
|
|
77
|
+
if (input.schema !== CRYPTOGRAPHIC_ASSURANCE_INPUT_SCHEMA || input.version !== CRYPTOGRAPHIC_ASSURANCE_VERSION) {
|
|
78
|
+
throw new TypeError(`Cryptographic assurance input must use ${CRYPTOGRAPHIC_ASSURANCE_INPUT_SCHEMA} version ${CRYPTOGRAPHIC_ASSURANCE_VERSION}.`);
|
|
79
|
+
}
|
|
80
|
+
const dnssecInput = suppliedEvidence(input.dnssec, 'dnssec');
|
|
81
|
+
const routeInput = suppliedEvidence(input.routeOrigin, 'routeOrigin');
|
|
82
|
+
const tlsaInput = suppliedEvidence(input.tlsa, 'tlsa');
|
|
83
|
+
if (!dnssecInput && !routeInput && !tlsaInput)
|
|
84
|
+
throw new TypeError('Cryptographic assurance input must supply at least one evidence family.');
|
|
85
|
+
const dnssec = dnssecInput ? validateDnssecEvidence({
|
|
86
|
+
ownerName: dnssecInput.evidence.ownerName,
|
|
87
|
+
delegationSigned: dnssecInput.evidence.delegationSigned,
|
|
88
|
+
dsRecords: dnssecInput.evidence.dsRecords,
|
|
89
|
+
dnskeyRecords: dnssecInput.evidence.dnskeyRecords,
|
|
90
|
+
rrSigRecords: dnssecInput.evidence.rrSigRecords,
|
|
91
|
+
observedAt: dnssecInput.evidence.observedAt ?? dnssecInput.observedAt,
|
|
92
|
+
}) : null;
|
|
93
|
+
const routeOrigin = routeInput ? reviewRpkiRoute({
|
|
94
|
+
routePrefix: routeInput.evidence.routePrefix,
|
|
95
|
+
originAsn: routeInput.evidence.originAsn,
|
|
96
|
+
authorizations: routeInput.evidence.authorizations,
|
|
97
|
+
}) : null;
|
|
98
|
+
const tlsa = tlsaInput ? analyzeTlsaEvidence({
|
|
99
|
+
serviceName: tlsaInput.evidence.serviceName,
|
|
100
|
+
dnssecState: tlsaInput.evidence.dnssecState,
|
|
101
|
+
pkixValidationState: tlsaInput.evidence.pkixValidationState,
|
|
102
|
+
records: tlsaInput.evidence.records,
|
|
103
|
+
certificateDerBase64: tlsaInput.evidence.certificateDerBase64,
|
|
104
|
+
spkiDerBase64: tlsaInput.evidence.spkiDerBase64,
|
|
105
|
+
authorityMaterials: tlsaInput.evidence.authorityMaterials,
|
|
106
|
+
}) : null;
|
|
107
|
+
const cards = [
|
|
108
|
+
dnssec && dnssecInput ? Object.freeze({
|
|
109
|
+
family: 'dnssec_validation',
|
|
110
|
+
label: 'DNSSEC evidence review',
|
|
111
|
+
authority: 'Supplied delegation, DS, DNSKEY, and signature-time evidence',
|
|
112
|
+
source: dnssecInput.source,
|
|
113
|
+
observedAt: dnssecInput.observedAt,
|
|
114
|
+
state: dnssec.state,
|
|
115
|
+
completeness: dnssecCompleteness(dnssec),
|
|
116
|
+
result: dnssec,
|
|
117
|
+
limitations: dnssec.limitations,
|
|
118
|
+
}) : unavailableCard('dnssec_validation', 'DNSSEC evidence review', 'Supplied delegation, DS, DNSKEY, and signature-time evidence'),
|
|
119
|
+
routeOrigin && routeInput ? Object.freeze({
|
|
120
|
+
family: 'route_origin_authorisation',
|
|
121
|
+
label: 'Route-origin authorisation',
|
|
122
|
+
authority: 'Analyst-supplied validated route-origin snapshot',
|
|
123
|
+
source: routeInput.source,
|
|
124
|
+
observedAt: routeInput.observedAt,
|
|
125
|
+
state: routeOrigin.state,
|
|
126
|
+
completeness: rpkiCompleteness(routeOrigin),
|
|
127
|
+
result: routeOrigin,
|
|
128
|
+
limitations: routeOrigin.limitations,
|
|
129
|
+
}) : unavailableCard('route_origin_authorisation', 'Route-origin authorisation', 'Analyst-supplied validated route-origin snapshot'),
|
|
130
|
+
tlsa && tlsaInput ? Object.freeze({
|
|
131
|
+
family: 'dane_tlsa',
|
|
132
|
+
label: 'DANE and TLSA evidence review',
|
|
133
|
+
authority: 'Supplied TLSA, DNSSEC, PKIX, and certificate material',
|
|
134
|
+
source: tlsaInput.source,
|
|
135
|
+
observedAt: tlsaInput.observedAt,
|
|
136
|
+
state: tlsa.state,
|
|
137
|
+
completeness: tlsaCompleteness(tlsa),
|
|
138
|
+
result: tlsa,
|
|
139
|
+
limitations: tlsa.limitations,
|
|
140
|
+
}) : unavailableCard('dane_tlsa', 'DANE and TLSA evidence review', 'Supplied TLSA, DNSSEC, PKIX, and certificate material'),
|
|
141
|
+
];
|
|
142
|
+
return Object.freeze({
|
|
143
|
+
schema: CRYPTOGRAPHIC_ASSURANCE_SCHEMA,
|
|
144
|
+
version: CRYPTOGRAPHIC_ASSURANCE_VERSION,
|
|
145
|
+
generatedAt: observationTime(generatedAt, 'generatedAt'),
|
|
146
|
+
cards: Object.freeze(cards),
|
|
147
|
+
combinedState: null,
|
|
148
|
+
limitations: Object.freeze([
|
|
149
|
+
'DNSSEC, route-origin authorisation, and DANE or TLSA remain independently sourced evidence families with separate states, observation times, completeness, and limitations.',
|
|
150
|
+
'No scalar score or combined verdict is produced. A missing, failed, partial, or unsupported family is never inferred from another family.',
|
|
151
|
+
'These reviews do not establish ownership, availability, activity, safety, or maliciousness.',
|
|
152
|
+
]),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
export { CRYPTOGRAPHIC_ASSURANCE_INPUT_SCHEMA, CRYPTOGRAPHIC_ASSURANCE_SCHEMA, CRYPTOGRAPHIC_ASSURANCE_VERSION, buildCryptographicAssuranceReview, };
|
package/lib/ct-search.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { normalizeCtQuery } from "./ct-query.mjs";
|
|
|
11
11
|
import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
|
|
12
12
|
import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
|
|
13
13
|
import { createObservation } from "./observation.mjs";
|
|
14
|
+
import { isValidAsciiHostname } from "./hostname.mjs";
|
|
14
15
|
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
16
|
const CRT_SH_TIMEOUT_MS = 20000;
|
|
16
17
|
const MAX_RESULTS = MAX_CT_RESPONSE_RESULTS;
|
|
@@ -40,6 +41,12 @@ const MAX_TIMEOUT_RETRIES = 1;
|
|
|
40
41
|
// iteration/set overhead after parsing. Legitimate responses under the byte
|
|
41
42
|
// cap stay well within this limit.
|
|
42
43
|
const MAX_CT_ROWS = 50_000;
|
|
44
|
+
// crt.sh joins SAN values with newlines. Bound the amount of per-row and
|
|
45
|
+
// whole-response hostname work independently of the JSON byte and row caps so
|
|
46
|
+
// a small number of newline-dense strings cannot drive unbounded PSL parsing,
|
|
47
|
+
// Set growth, or sorting before the result projection is truncated.
|
|
48
|
+
const MAX_CT_NAMES_PER_ROW = 1_000;
|
|
49
|
+
const MAX_CT_NAMES_EXAMINED = 100_000;
|
|
43
50
|
// Structured-match bounds.
|
|
44
51
|
const MAX_MATCHES = MAX_RESULTS; // 500
|
|
45
52
|
const MAX_HOSTNAMES_PER_MATCH = MAX_CT_RESPONSE_HOSTNAMES_PER_MATCH;
|
|
@@ -56,10 +63,12 @@ const MAX_SERIAL_LENGTH = 128;
|
|
|
56
63
|
// Maximum length for an entry_timestamp string before it is rejected.
|
|
57
64
|
const MAX_TIMESTAMP_LENGTH = MAX_CT_RESPONSE_TIMESTAMP_LENGTH;
|
|
58
65
|
function normalizeHostname(raw) {
|
|
66
|
+
if (raw.length > 255)
|
|
67
|
+
return null;
|
|
59
68
|
let h = raw.trim().toLowerCase();
|
|
60
69
|
if (h.startsWith('*.'))
|
|
61
70
|
h = h.slice(2);
|
|
62
|
-
return HOSTNAME_RE.test(h) ? h : null;
|
|
71
|
+
return HOSTNAME_RE.test(h) && isValidAsciiHostname(h, { requireLowercase: true }) ? h : null;
|
|
63
72
|
}
|
|
64
73
|
function delay(ms) {
|
|
65
74
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -75,7 +84,7 @@ async function fetchCrtSh(keyword, attempt = 0, dependencies = {}) {
|
|
|
75
84
|
res = await fetcher(`https://crt.sh/?q=${encodeURIComponent(keyword)}&output=json`, {
|
|
76
85
|
headers: whoisleuthRequestHeaders({ Accept: 'application/json' }),
|
|
77
86
|
signal: controller.signal,
|
|
78
|
-
});
|
|
87
|
+
}, 0);
|
|
79
88
|
}
|
|
80
89
|
catch (err) {
|
|
81
90
|
if (!(err instanceof Error) || err.name !== 'AbortError')
|
|
@@ -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,20 @@ 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
|
+
rowLoop: for (let i = 0; i < rows.length; i++) {
|
|
279
292
|
const row = rows[i];
|
|
280
293
|
if (!row || typeof row !== 'object')
|
|
281
294
|
continue;
|
|
282
295
|
const certId = resolveCertId(row, i);
|
|
283
|
-
// Collect
|
|
284
|
-
|
|
285
|
-
|
|
296
|
+
// Collect only direct string fields. Implicitly stringifying a nested
|
|
297
|
+
// array or object would traverse attacker-controlled structure before any
|
|
298
|
+
// hostname bound could take effect.
|
|
299
|
+
const nameFields = [
|
|
300
|
+
typeof row.name_value === 'string' ? row.name_value : '',
|
|
301
|
+
typeof row.common_name === 'string' ? row.common_name : '',
|
|
302
|
+
];
|
|
286
303
|
const ts = validateEntryTimestamp(row.entry_timestamp);
|
|
287
304
|
let certificate = certificateMap.get(certId);
|
|
288
305
|
if (!certificate) {
|
|
@@ -297,46 +314,65 @@ function summarizeCtResults(rows) {
|
|
|
297
314
|
else if (ts !== null && (certificate.observedAt === null || ts > certificate.observedAt)) {
|
|
298
315
|
certificate.observedAt = ts;
|
|
299
316
|
}
|
|
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;
|
|
317
|
+
let rowNamesExamined = 0;
|
|
318
|
+
for (const field of nameFields) {
|
|
319
|
+
let offset = 0;
|
|
320
|
+
while (offset <= field.length) {
|
|
321
|
+
if (namesExamined >= MAX_CT_NAMES_EXAMINED) {
|
|
322
|
+
workTruncated = true;
|
|
323
|
+
break rowLoop;
|
|
324
|
+
}
|
|
325
|
+
if (rowNamesExamined >= MAX_CT_NAMES_PER_ROW) {
|
|
326
|
+
workTruncated = true;
|
|
327
|
+
continue rowLoop;
|
|
336
328
|
}
|
|
337
|
-
|
|
338
|
-
|
|
329
|
+
const newline = field.indexOf('\n', offset);
|
|
330
|
+
const line = newline === -1 ? field.slice(offset) : field.slice(offset, newline);
|
|
331
|
+
namesExamined += 1;
|
|
332
|
+
rowNamesExamined += 1;
|
|
333
|
+
if (line.trim().startsWith('*.'))
|
|
334
|
+
certificate.wildcardObserved = true;
|
|
335
|
+
const host = normalizeHostname(line);
|
|
336
|
+
if (host) {
|
|
337
|
+
// Legacy: always add if valid hostname.
|
|
338
|
+
legacyDomains.add(host);
|
|
339
|
+
// Structured: resolve registrable domain.
|
|
340
|
+
let regDomain;
|
|
341
|
+
if (registrableDomainByHostname.has(host)) {
|
|
342
|
+
regDomain = registrableDomainByHostname.get(host);
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
regDomain = parse(host).domain || null;
|
|
346
|
+
registrableDomainByHostname.set(host, regDomain);
|
|
347
|
+
}
|
|
348
|
+
if (regDomain) {
|
|
349
|
+
certificate.hostnames.add(host);
|
|
350
|
+
certificate.domains.add(regDomain);
|
|
351
|
+
let group = groupMap.get(regDomain);
|
|
352
|
+
if (!group) {
|
|
353
|
+
group = {
|
|
354
|
+
hostnames: new Set(),
|
|
355
|
+
certIds: new Set(),
|
|
356
|
+
firstObservedAt: null,
|
|
357
|
+
lastObservedAt: null,
|
|
358
|
+
};
|
|
359
|
+
groupMap.set(regDomain, group);
|
|
360
|
+
}
|
|
361
|
+
group.hostnames.add(host);
|
|
362
|
+
group.certIds.add(certId);
|
|
363
|
+
if (ts !== null) {
|
|
364
|
+
if (group.firstObservedAt === null || ts < group.firstObservedAt) {
|
|
365
|
+
group.firstObservedAt = ts;
|
|
366
|
+
}
|
|
367
|
+
if (group.lastObservedAt === null || ts > group.lastObservedAt) {
|
|
368
|
+
group.lastObservedAt = ts;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
339
372
|
}
|
|
373
|
+
if (newline === -1)
|
|
374
|
+
break;
|
|
375
|
+
offset = newline + 1;
|
|
340
376
|
}
|
|
341
377
|
}
|
|
342
378
|
}
|
|
@@ -404,7 +440,9 @@ function summarizeCtResults(rows) {
|
|
|
404
440
|
wildcardObserved: group.wildcardObserved,
|
|
405
441
|
})),
|
|
406
442
|
certificateGroupsTruncated,
|
|
407
|
-
|
|
443
|
+
namesExamined,
|
|
444
|
+
workTruncated,
|
|
445
|
+
truncated: workTruncated || legacyTruncated || matchTruncated || perMatchTruncated,
|
|
408
446
|
};
|
|
409
447
|
}
|
|
410
448
|
// ---------------------------------------------------------------------------
|
|
@@ -416,11 +454,12 @@ async function searchCertificateTransparency(keyword, dependencies = {}) {
|
|
|
416
454
|
if (!trimmed) {
|
|
417
455
|
return {
|
|
418
456
|
domains: [], certCount: 0, truncated: false, matches: [], certificateGroups: [], certificateGroupsTruncated: false,
|
|
457
|
+
namesExamined: 0, workTruncated: false,
|
|
419
458
|
observation: createObservation({
|
|
420
459
|
status: 'success', observedAt: new Date().toISOString(), source: 'certificate_transparency',
|
|
421
460
|
durationMs: Date.now() - startedAt, complete: true, truncated: false,
|
|
422
461
|
limitations: ['Certificate Transparency observations indicate public certificate logging, not current site activity or maliciousness.'],
|
|
423
|
-
diagnostics: { certificateRows: 0, matches: 0, certificateGroups: 0 },
|
|
462
|
+
diagnostics: { certificateRows: 0, namesExamined: 0, workTruncated: false, matches: 0, certificateGroups: 0 },
|
|
424
463
|
}),
|
|
425
464
|
};
|
|
426
465
|
}
|
|
@@ -438,9 +477,10 @@ async function searchCertificateTransparency(keyword, dependencies = {}) {
|
|
|
438
477
|
truncated: summary.truncated || summary.certificateGroupsTruncated,
|
|
439
478
|
limitations: [
|
|
440
479
|
'Certificate Transparency observations indicate public certificate logging, not current site activity or maliciousness.',
|
|
480
|
+
...(summary.workTruncated ? ['The response contained more hostname entries than the bounded local analysis could examine; retained results are partial.'] : []),
|
|
441
481
|
...(summary.certificateGroupsTruncated ? ['The optional certificate-group projection was capped independently of the registrable-domain result set.'] : []),
|
|
442
482
|
],
|
|
443
|
-
diagnostics: { certificateRows: data.length, matches: summary.matches.length, certificateGroups: summary.certificateGroups.length, certificateGroupsTruncated: summary.certificateGroupsTruncated },
|
|
483
|
+
diagnostics: { certificateRows: data.length, namesExamined: summary.namesExamined, workTruncated: summary.workTruncated, matches: summary.matches.length, certificateGroups: summary.certificateGroups.length, certificateGroupsTruncated: summary.certificateGroupsTruncated },
|
|
444
484
|
}),
|
|
445
485
|
};
|
|
446
486
|
}
|
package/lib/dns-intelligence.mjs
CHANGED
|
@@ -7,11 +7,12 @@ 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
9
|
import { createObservation } from "./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]);
|