@slicedearth/whoisleuth-cli 1.43.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 +67 -10
- package/bin/whoisleuth.mjs +22 -9
- 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 +67 -31
- package/cli/bulk-command-runner.mjs +16 -6
- package/cli/bulk.mjs +18 -7
- package/cli/case-pack.mjs +819 -34
- package/cli/command-reference.mjs +48 -24
- package/cli/completion.mjs +50 -11
- package/cli/config-profile.mjs +15 -12
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discovery-observation-snapshot.mjs +8 -14
- package/cli/discovery-scan-command-runner.mjs +18 -6
- package/cli/discovery-snapshot.mjs +8 -15
- 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/errors.mjs +7 -1
- 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/json.mjs +12 -4
- package/cli/formatters/terminal.mjs +4 -1
- package/cli/http.mjs +2 -1
- 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/lookup-reconcile.mjs +1 -1
- package/cli/mail-review.mjs +5 -5
- package/cli/manual.mjs +1 -1
- 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-fixture-scaffold.mjs +4 -3
- 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 +150 -177
- package/cli/saved-lookup.mjs +26 -3
- package/cli/sharing-review.mjs +12 -6
- package/docs/cli-reference.md +467 -74
- package/docs/cli.md +25 -5
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
- package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +33 -7
- 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 +9 -1
- 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-core.js +1 -1
- package/frontend/src/lib/analysis/case-record-model.js +2 -0
- package/frontend/src/lib/analysis/case-record-operations.js +43 -13
- 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-asset-graph.js +8 -8
- 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 +63 -8
- package/frontend/src/lib/analysis/lookup-presentation.js +70 -14
- package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
- package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
- package/frontend/src/lib/analysis/utils.js +27 -4
- 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-crypto.js +1 -4
- package/frontend/src/lib/analysis/workspace-archive.js +105 -19
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +14 -12
- package/lib/bounded-file.mjs +82 -0
- package/lib/bounded-json-response.mjs +170 -0
- package/lib/bounded-json.mjs +249 -0
- package/lib/browser-library-profile.mjs +167 -38
- 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-cache.mjs +5 -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/rdap-transport.mjs +6 -6
- 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 +96 -42
- 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 +4 -13
- package/lib/threat-intelligence-types.mjs +18 -1
- package/lib/tls-intelligence.mjs +11 -5
- package/lib/tlsa-evidence.mjs +37 -7
- package/lib/website-technology.mjs +2 -6
- package/package.json +1 -1
- package/third-party-notices.txt +95 -5817
|
@@ -4,10 +4,19 @@
|
|
|
4
4
|
// scripts. Upstream URLs, filenames, inline bodies, and catalogue expressions
|
|
5
5
|
// are treated as untrusted inputs and are not retained in the result.
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
|
+
import { Worker } from 'node:worker_threads';
|
|
7
8
|
import { RETIRE_BROWSER_CATALOG } from "./generated/retire-browser-catalog.mjs";
|
|
8
9
|
import { CISA_KEV_CATALOG } from "./generated/cisa-kev-catalog.mjs";
|
|
9
10
|
import { createObservation } from "./observation.mjs";
|
|
10
11
|
import { MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_SCRIPT_ELEMENTS, MAX_STATIC_HTML_CHARS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
|
|
12
|
+
// Catalogue expressions are reviewed third-party inputs. Keep every individual
|
|
13
|
+
// regular-expression evaluation and the aggregate inline-signature work small
|
|
14
|
+
// enough that a target-controlled script cannot monopolise the Node event loop.
|
|
15
|
+
// Full-content hashes still use the complete already-capped script body.
|
|
16
|
+
const MAX_INLINE_LIBRARY_SCAN_CHARS = 1_024;
|
|
17
|
+
const MAX_INLINE_LIBRARY_SCAN_TOTAL_CHARS = 4_096;
|
|
18
|
+
const MAX_INLINE_LIBRARY_SCAN_MS = 750;
|
|
19
|
+
const MAX_INLINE_LIBRARY_WORKER_BYTES = 64 * 1024;
|
|
11
20
|
const BROWSER_LIBRARY_PROFILE_VERSION = 2;
|
|
12
21
|
const MAX_LIBRARY_HTML_CHARS = MAX_STATIC_HTML_CHARS;
|
|
13
22
|
const MAX_LIBRARY_FINDINGS = 16;
|
|
@@ -29,6 +38,68 @@ const SEVERITY_WEIGHT = Object.freeze({
|
|
|
29
38
|
});
|
|
30
39
|
const CATALOG_COMPONENTS = RETIRE_BROWSER_CATALOG.components;
|
|
31
40
|
const KNOWN_EXPLOITED_IDENTIFIERS = new Set(CISA_KEV_CATALOG.identifiers);
|
|
41
|
+
const INLINE_EXTRACTOR_CATALOGUE = Object.freeze(Object.entries(CATALOG_COMPONENTS).map(([component, value]) => {
|
|
42
|
+
const extractors = record(record(value).extractors);
|
|
43
|
+
return Object.freeze({
|
|
44
|
+
component,
|
|
45
|
+
patterns: Object.freeze((Array.isArray(extractors.filecontent) ? extractors.filecontent : [])
|
|
46
|
+
.filter((pattern) => typeof pattern === 'string' && pattern.length > 0 && pattern.length <= 2_048)
|
|
47
|
+
.slice(0, 64)),
|
|
48
|
+
replacements: Object.freeze((Array.isArray(extractors.filecontentreplace) ? extractors.filecontentreplace : [])
|
|
49
|
+
.filter((pattern) => typeof pattern === 'string' && pattern.length > 0 && pattern.length <= 2_048)
|
|
50
|
+
.slice(0, 64)),
|
|
51
|
+
});
|
|
52
|
+
}));
|
|
53
|
+
const INLINE_REGEX_WORKER_SOURCE = String.raw `
|
|
54
|
+
const { parentPort, workerData } = require('node:worker_threads');
|
|
55
|
+
const versions = /^[0-9][0-9.a-z_-]{0,63}$/i;
|
|
56
|
+
parentPort.on('message', (job) => {
|
|
57
|
+
const control = new Int32Array(job.control);
|
|
58
|
+
const output = new Uint8Array(job.output);
|
|
59
|
+
const matches = [];
|
|
60
|
+
const add = (component, version) => {
|
|
61
|
+
const normalized = String(version).replace(/(?:\.|-)?min$/i, '').slice(0, 64);
|
|
62
|
+
if (versions.test(normalized) && matches.length < 64) matches.push([component, normalized]);
|
|
63
|
+
};
|
|
64
|
+
try {
|
|
65
|
+
for (const entry of workerData.catalogue) {
|
|
66
|
+
for (const pattern of entry.patterns) {
|
|
67
|
+
let expression;
|
|
68
|
+
try { expression = new RegExp(pattern, 'g'); } catch { continue; }
|
|
69
|
+
let match;
|
|
70
|
+
let count = 0;
|
|
71
|
+
while ((match = expression.exec(job.value)) && count < 4) {
|
|
72
|
+
if (typeof match[1] === 'string') add(entry.component, match[1]);
|
|
73
|
+
if (match[0] === '') expression.lastIndex += 1;
|
|
74
|
+
count += 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const descriptorValue of entry.replacements) {
|
|
78
|
+
const descriptor = /^\/(.*[^\\])\/([^/]+)\/$/.exec(descriptorValue);
|
|
79
|
+
if (!descriptor || !descriptor[1]) continue;
|
|
80
|
+
let expression;
|
|
81
|
+
try { expression = new RegExp(descriptor[1], 'g'); } catch { continue; }
|
|
82
|
+
let match;
|
|
83
|
+
let count = 0;
|
|
84
|
+
while ((match = expression.exec(job.value)) && count < 4) {
|
|
85
|
+
let replaced = match[0];
|
|
86
|
+
try { replaced = match[0].replace(new RegExp(descriptor[1]), descriptor[2]); } catch { break; }
|
|
87
|
+
add(entry.component, replaced);
|
|
88
|
+
if (match[0] === '') expression.lastIndex += 1;
|
|
89
|
+
count += 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const encoded = Buffer.from(JSON.stringify(matches));
|
|
94
|
+
if (encoded.length > output.length) Atomics.store(control, 0, -2);
|
|
95
|
+
else { output.set(encoded); Atomics.store(control, 0, encoded.length); }
|
|
96
|
+
} catch {
|
|
97
|
+
Atomics.store(control, 0, -1);
|
|
98
|
+
}
|
|
99
|
+
Atomics.notify(control, 0);
|
|
100
|
+
});
|
|
101
|
+
`;
|
|
102
|
+
let inlineRegexWorker = null;
|
|
32
103
|
function record(value) {
|
|
33
104
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
34
105
|
? value
|
|
@@ -106,32 +177,6 @@ function matchPattern(pattern, value) {
|
|
|
106
177
|
}
|
|
107
178
|
return matches;
|
|
108
179
|
}
|
|
109
|
-
function matchReplacementPattern(pattern, value) {
|
|
110
|
-
if (typeof pattern !== 'string' || pattern.length === 0 || pattern.length > 2_048)
|
|
111
|
-
return [];
|
|
112
|
-
const descriptor = /^\/(.*[^\\])\/([^/]+)\/$/.exec(pattern);
|
|
113
|
-
const expressionSource = descriptor?.[1];
|
|
114
|
-
const replacement = descriptor?.[2];
|
|
115
|
-
if (!expressionSource || replacement === undefined)
|
|
116
|
-
return [];
|
|
117
|
-
let expression;
|
|
118
|
-
try {
|
|
119
|
-
expression = new RegExp(expressionSource, 'g');
|
|
120
|
-
}
|
|
121
|
-
catch {
|
|
122
|
-
return [];
|
|
123
|
-
}
|
|
124
|
-
const matches = [];
|
|
125
|
-
let match;
|
|
126
|
-
while ((match = expression.exec(value)) && matches.length < MAX_MATCHES_PER_PATTERN) {
|
|
127
|
-
const replaced = match[0].replace(new RegExp(expressionSource), replacement).replace(/(?:\.|-)?min$/i, '');
|
|
128
|
-
if (VERSION_RE.test(replaced))
|
|
129
|
-
matches.push(replaced);
|
|
130
|
-
if (match[0] === '')
|
|
131
|
-
expression.lastIndex += 1;
|
|
132
|
-
}
|
|
133
|
-
return matches;
|
|
134
|
-
}
|
|
135
180
|
function addDetected(detected, component, version, method) {
|
|
136
181
|
const normalizedVersion = version.replace(/(?:\.|-)?min$/i, '').slice(0, MAX_VERSION_LENGTH);
|
|
137
182
|
if (!COMPONENT_RE.test(component) || !VERSION_RE.test(normalizedVersion))
|
|
@@ -174,24 +219,87 @@ function scanFilename(detected, reference) {
|
|
|
174
219
|
}
|
|
175
220
|
}
|
|
176
221
|
}
|
|
177
|
-
function
|
|
222
|
+
function scanInlineSignatures(detected, value) {
|
|
223
|
+
if (!value)
|
|
224
|
+
return { timedOut: false, unavailable: false };
|
|
225
|
+
const controlBuffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
|
|
226
|
+
const outputBuffer = new SharedArrayBuffer(MAX_INLINE_LIBRARY_WORKER_BYTES);
|
|
227
|
+
const control = new Int32Array(controlBuffer);
|
|
228
|
+
try {
|
|
229
|
+
if (!inlineRegexWorker) {
|
|
230
|
+
const createdWorker = new Worker(INLINE_REGEX_WORKER_SOURCE, {
|
|
231
|
+
eval: true,
|
|
232
|
+
workerData: { catalogue: INLINE_EXTRACTOR_CATALOGUE },
|
|
233
|
+
});
|
|
234
|
+
inlineRegexWorker = createdWorker;
|
|
235
|
+
createdWorker.unref();
|
|
236
|
+
createdWorker.once('exit', () => {
|
|
237
|
+
if (inlineRegexWorker === createdWorker)
|
|
238
|
+
inlineRegexWorker = null;
|
|
239
|
+
});
|
|
240
|
+
createdWorker.once('error', () => {
|
|
241
|
+
if (inlineRegexWorker === createdWorker)
|
|
242
|
+
inlineRegexWorker = null;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
inlineRegexWorker.postMessage({ value, control: controlBuffer, output: outputBuffer });
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
void inlineRegexWorker?.terminate().catch(() => { });
|
|
249
|
+
inlineRegexWorker = null;
|
|
250
|
+
return { timedOut: false, unavailable: true };
|
|
251
|
+
}
|
|
252
|
+
const wait = Atomics.wait(control, 0, 0, MAX_INLINE_LIBRARY_SCAN_MS);
|
|
253
|
+
const length = Atomics.load(control, 0);
|
|
254
|
+
if (wait === 'timed-out') {
|
|
255
|
+
void inlineRegexWorker?.terminate().catch(() => { });
|
|
256
|
+
inlineRegexWorker = null;
|
|
257
|
+
return { timedOut: true, unavailable: false };
|
|
258
|
+
}
|
|
259
|
+
if (length < 1 || length > MAX_INLINE_LIBRARY_WORKER_BYTES) {
|
|
260
|
+
return { timedOut: false, unavailable: true };
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
const parsed = JSON.parse(Buffer.from(new Uint8Array(outputBuffer, 0, length)).toString('utf8'));
|
|
264
|
+
if (!Array.isArray(parsed))
|
|
265
|
+
return { timedOut: false, unavailable: true };
|
|
266
|
+
for (const item of parsed.slice(0, MAX_LIBRARY_FINDINGS * 4)) {
|
|
267
|
+
if (!Array.isArray(item) || item.length !== 2)
|
|
268
|
+
continue;
|
|
269
|
+
const [component, version] = item;
|
|
270
|
+
if (typeof component === 'string' && typeof version === 'string') {
|
|
271
|
+
addDetected(detected, component, version, 'inline signature');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return { timedOut: false, unavailable: false };
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return { timedOut: false, unavailable: true };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function scanInlineContent(detected, content, maximumSignatureCharacters) {
|
|
178
281
|
const normalized = content.replace(/\r\n?|\n/g, '\n');
|
|
179
282
|
const digest = createHash('sha1').update(normalized).digest('hex');
|
|
180
|
-
|
|
283
|
+
const retainedCharacters = Math.max(0, Math.min(MAX_INLINE_LIBRARY_SCAN_CHARS, maximumSignatureCharacters, normalized.length));
|
|
284
|
+
const headCharacters = Math.ceil(retainedCharacters / 2);
|
|
285
|
+
const tailCharacters = retainedCharacters - headCharacters;
|
|
286
|
+
const signatureContent = retainedCharacters === 0
|
|
287
|
+
? ''
|
|
288
|
+
: normalized.length <= retainedCharacters
|
|
289
|
+
? normalized
|
|
290
|
+
: `${normalized.slice(0, headCharacters)}${tailCharacters ? normalized.slice(-tailCharacters) : ''}`;
|
|
181
291
|
for (const [componentName, rawComponent] of Object.entries(CATALOG_COMPONENTS)) {
|
|
182
|
-
const
|
|
183
|
-
const extractors = record(component.extractors);
|
|
184
|
-
const replacementPatterns = Array.isArray(extractors.filecontentreplace) ? extractors.filecontentreplace : [];
|
|
185
|
-
for (const pattern of replacementPatterns.slice(0, 64)) {
|
|
186
|
-
for (const version of matchReplacementPattern(pattern, normalized)) {
|
|
187
|
-
addDetected(detected, componentName, version, 'inline signature');
|
|
188
|
-
}
|
|
189
|
-
}
|
|
292
|
+
const extractors = record(record(rawComponent).extractors);
|
|
190
293
|
const hashes = record(extractors.hashes);
|
|
191
294
|
const version = hashes[digest];
|
|
192
295
|
if (typeof version === 'string')
|
|
193
296
|
addDetected(detected, componentName, version, 'inline hash');
|
|
194
297
|
}
|
|
298
|
+
return {
|
|
299
|
+
charactersExamined: retainedCharacters,
|
|
300
|
+
truncated: retainedCharacters < normalized.length,
|
|
301
|
+
signatureContent,
|
|
302
|
+
};
|
|
195
303
|
}
|
|
196
304
|
function severity(value) {
|
|
197
305
|
return typeof value === 'string' && Object.hasOwn(SEVERITY_WEIGHT, value)
|
|
@@ -243,6 +351,9 @@ function analyzeBrowserLibraries(input = {}) {
|
|
|
243
351
|
const detected = new Map();
|
|
244
352
|
let referencesExamined = 0;
|
|
245
353
|
let inlineScriptsExamined = 0;
|
|
354
|
+
let inlineSignatureCharactersExamined = 0;
|
|
355
|
+
let inlineSignatureLimitReached = false;
|
|
356
|
+
const inlineSignatureSamples = [];
|
|
246
357
|
for (const script of htmlAnalysis.scripts) {
|
|
247
358
|
if (script.reference) {
|
|
248
359
|
referencesExamined += 1;
|
|
@@ -251,9 +362,16 @@ function analyzeBrowserLibraries(input = {}) {
|
|
|
251
362
|
}
|
|
252
363
|
else if (script.inlineContent && script.mediaType !== 'application/ld+json') {
|
|
253
364
|
inlineScriptsExamined += 1;
|
|
254
|
-
|
|
365
|
+
const remaining = Math.max(0, MAX_INLINE_LIBRARY_SCAN_TOTAL_CHARS - inlineSignatureCharactersExamined);
|
|
366
|
+
const signatureScan = scanInlineContent(detected, script.inlineContent, remaining);
|
|
367
|
+
inlineSignatureCharactersExamined += signatureScan.charactersExamined;
|
|
368
|
+
inlineSignatureLimitReached ||= signatureScan.truncated;
|
|
369
|
+
if (signatureScan.signatureContent)
|
|
370
|
+
inlineSignatureSamples.push(signatureScan.signatureContent);
|
|
255
371
|
}
|
|
256
372
|
}
|
|
373
|
+
const inlineSignatureResult = scanInlineSignatures(detected, inlineSignatureSamples.join('\u0000'));
|
|
374
|
+
const inlineSignatureUnavailable = inlineSignatureResult.timedOut || inlineSignatureResult.unavailable;
|
|
257
375
|
const allFindings = [...detected.values()]
|
|
258
376
|
.map(findingFromDetection)
|
|
259
377
|
.sort((left, right) => ((right.highestSeverity ? SEVERITY_WEIGHT[right.highestSeverity] : -1)
|
|
@@ -267,6 +385,8 @@ function analyzeBrowserLibraries(input = {}) {
|
|
|
267
385
|
|| htmlAnalysis.tagLimitReached
|
|
268
386
|
|| htmlAnalysis.scriptLimitReached
|
|
269
387
|
|| htmlAnalysis.inlineLimitReached
|
|
388
|
+
|| inlineSignatureLimitReached
|
|
389
|
+
|| inlineSignatureUnavailable
|
|
270
390
|
|| findingLimitReached;
|
|
271
391
|
const limitations = [
|
|
272
392
|
'Library versions are inferred from passive static signatures and may be absent, transformed, or misleading.',
|
|
@@ -284,6 +404,12 @@ function analyzeBrowserLibraries(input = {}) {
|
|
|
284
404
|
limitations.push(`Only the first ${MAX_SCRIPT_ELEMENTS} script elements were evaluated.`);
|
|
285
405
|
if (htmlAnalysis.inlineLimitReached)
|
|
286
406
|
limitations.push(`Inline script evaluation reached its ${MAX_INLINE_SCRIPT_TOTAL_CHARS}-character cumulative boundary.`);
|
|
407
|
+
if (inlineSignatureLimitReached)
|
|
408
|
+
limitations.push(`Passive library signature matching evaluated a deterministic ${MAX_INLINE_LIBRARY_SCAN_TOTAL_CHARS}-character cumulative sample across inline scripts; full-content hashes still used the complete bounded script content.`);
|
|
409
|
+
if (inlineSignatureResult.timedOut)
|
|
410
|
+
limitations.push(`Passive library signature matching exceeded its ${MAX_INLINE_LIBRARY_SCAN_MS} ms isolated-worker deadline; hash evidence was still evaluated.`);
|
|
411
|
+
if (inlineSignatureResult.unavailable)
|
|
412
|
+
limitations.push('Passive library signature matching was unavailable in its isolated worker; hash evidence was still evaluated.');
|
|
287
413
|
if (findingLimitReached)
|
|
288
414
|
limitations.push(`Only the first ${MAX_LIBRARY_FINDINGS} library findings were retained.`);
|
|
289
415
|
return {
|
|
@@ -311,6 +437,9 @@ function analyzeBrowserLibraries(input = {}) {
|
|
|
311
437
|
referencesExamined,
|
|
312
438
|
inlineScriptsExamined,
|
|
313
439
|
inlineCharactersExamined: htmlAnalysis.inlineCharactersExamined,
|
|
440
|
+
inlineSignatureCharactersExamined,
|
|
441
|
+
inlineSignatureTimedOut: inlineSignatureResult.timedOut,
|
|
442
|
+
inlineSignatureUnavailable: inlineSignatureResult.unavailable,
|
|
314
443
|
catalogComponents: Object.keys(CATALOG_COMPONENTS).length,
|
|
315
444
|
findings: allFindings.length,
|
|
316
445
|
advisoryMatches: allFindings.reduce((sum, finding) => sum + finding.advisoryCount, 0),
|
|
@@ -320,4 +449,4 @@ function analyzeBrowserLibraries(input = {}) {
|
|
|
320
449
|
findings: allFindings.slice(0, MAX_LIBRARY_FINDINGS),
|
|
321
450
|
};
|
|
322
451
|
}
|
|
323
|
-
export { BROWSER_LIBRARY_PROFILE_VERSION, MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_LIBRARY_FINDINGS, MAX_LIBRARY_HTML_CHARS, MAX_SCRIPT_ELEMENTS, analyzeBrowserLibraries, };
|
|
452
|
+
export { BROWSER_LIBRARY_PROFILE_VERSION, MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_INLINE_LIBRARY_SCAN_CHARS, MAX_INLINE_LIBRARY_SCAN_TOTAL_CHARS, MAX_LIBRARY_FINDINGS, MAX_LIBRARY_HTML_CHARS, MAX_SCRIPT_ELEMENTS, analyzeBrowserLibraries, };
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// retained; malformed extensions fail soft and leave an explicit partial flag.
|
|
4
4
|
import 'reflect-metadata';
|
|
5
5
|
import { CertificatePolicyExtension, CRLDistributionPointsExtension, X509Certificate, } from '@peculiar/x509';
|
|
6
|
-
|
|
6
|
+
import { MAX_LOOKUP_TLS_CERTIFICATE_POLICIES } from "./lookup-network-evidence-bounds.mjs";
|
|
7
|
+
export const MAX_CERTIFICATE_POLICIES = MAX_LOOKUP_TLS_CERTIFICATE_POLICIES;
|
|
7
8
|
export const MAX_CRL_DISTRIBUTION_POINTS = 32;
|
|
8
9
|
function emptyProfile(partial = false) {
|
|
9
10
|
return Object.freeze({
|
|
@@ -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
|
}
|