@slicedearth/whoisleuth-cli 1.32.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/LICENSE +661 -0
- package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
- package/NOTICE +76 -0
- package/README.md +84 -0
- package/TRADEMARKS.md +41 -0
- package/bin/whoisleuth.mjs +35 -0
- package/cli/archive-inspect.mjs +198 -0
- package/cli/arguments.mjs +1022 -0
- package/cli/artifact-verify.mjs +210 -0
- package/cli/bulk-checkpoint.mjs +218 -0
- package/cli/bulk-command-runner.mjs +109 -0
- package/cli/bulk-output.mjs +112 -0
- package/cli/bulk.mjs +133 -0
- package/cli/compare.mjs +238 -0
- package/cli/completion.mjs +175 -0
- package/cli/csv.mjs +1 -0
- package/cli/discover.mjs +50 -0
- package/cli/discovery-command-runner.mjs +36 -0
- package/cli/discovery-observation-snapshot.mjs +315 -0
- package/cli/discovery-scan-command-runner.mjs +141 -0
- package/cli/discovery-scan.mjs +325 -0
- package/cli/discovery-snapshot.mjs +133 -0
- package/cli/discovery-workflow.mjs +73 -0
- package/cli/doctor.mjs +137 -0
- package/cli/errors.mjs +21 -0
- package/cli/evidence-command-arguments.mjs +145 -0
- package/cli/evidence-command-runner.mjs +106 -0
- package/cli/evidence-signing.mjs +215 -0
- package/cli/exit-codes.mjs +9 -0
- package/cli/export-evidence.mjs +28 -0
- package/cli/formatters/evidence-report.mjs +3 -0
- package/cli/formatters/html.mjs +69 -0
- package/cli/formatters/json.mjs +135 -0
- package/cli/formatters/markdown.mjs +3 -0
- package/cli/formatters/terminal.mjs +658 -0
- package/cli/http.mjs +27 -0
- package/cli/lookup-command-runner.mjs +87 -0
- package/cli/lookup-diff.mjs +171 -0
- package/cli/mail-review.mjs +204 -0
- package/cli/manual.mjs +34 -0
- package/cli/network-command-runner.mjs +88 -0
- package/cli/output-file.mjs +97 -0
- package/cli/page-compare.mjs +123 -0
- package/cli/posture.mjs +20 -0
- package/cli/progress-events.mjs +53 -0
- package/cli/progress.mjs +56 -0
- package/cli/registry-access.mjs +3 -0
- package/cli/registry-support.mjs +120 -0
- package/cli/risk-calibration.mjs +294 -0
- package/cli/runner-types.mjs +1 -0
- package/cli/runner.mjs +726 -0
- package/cli/saved-lookup.mjs +87 -0
- package/cli/source-reliability.mjs +727 -0
- package/cli/strict-exit.mjs +55 -0
- package/cli/terminal-presentation.mjs +147 -0
- package/docs/cli.md +827 -0
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
- package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
- package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
- package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
- package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
- package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
- package/frontend/src/lib/analysis/bulk-sort.js +49 -0
- package/frontend/src/lib/analysis/campaign-model.js +265 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
- package/frontend/src/lib/analysis/case-migration-model.js +248 -0
- package/frontend/src/lib/analysis/case-model.js +5 -0
- package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
- package/frontend/src/lib/analysis/case-record-core.js +232 -0
- package/frontend/src/lib/analysis/case-record-model.js +7 -0
- package/frontend/src/lib/analysis/case-record-operations.js +224 -0
- package/frontend/src/lib/analysis/case-response-model.js +640 -0
- package/frontend/src/lib/analysis/case-response-packet.js +573 -0
- package/frontend/src/lib/analysis/case-storage-model.js +93 -0
- package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
- package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
- package/frontend/src/lib/analysis/http-summary.js +165 -0
- package/frontend/src/lib/analysis/investigation-guide.js +500 -0
- package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
- package/frontend/src/lib/analysis/page-baseline.js +272 -0
- package/frontend/src/lib/analysis/page-similarity.js +179 -0
- package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
- package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
- package/frontend/src/lib/analysis/scoring.js +135 -0
- package/frontend/src/lib/analysis/shortlist-model.js +209 -0
- package/frontend/src/lib/analysis/utils.js +265 -0
- package/frontend/src/lib/analysis/watchlist-history.js +643 -0
- package/frontend/src/lib/analysis/watchlist-store.js +127 -0
- package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
- package/frontend/src/lib/analysis/workspace-archive.js +472 -0
- package/lib/abort.mjs +26 -0
- package/lib/abusech-auth.mjs +19 -0
- package/lib/availability.mjs +716 -0
- package/lib/bounded-contract-normalizers.mjs +61 -0
- package/lib/browser-library-profile.mjs +310 -0
- package/lib/bulk-comparison-evidence.mjs +83 -0
- package/lib/classify.mjs +93 -0
- package/lib/client-behavior-profile.mjs +83 -0
- package/lib/credential-surface-profile.mjs +66 -0
- package/lib/csv.mjs +23 -0
- package/lib/ct-query.mjs +27 -0
- package/lib/ct-search.mjs +393 -0
- package/lib/distributed-operation-budget.mjs +461 -0
- package/lib/dns-delegation-health.mjs +344 -0
- package/lib/dns-intelligence.mjs +739 -0
- package/lib/dns-mx.mjs +24 -0
- package/lib/dns-resolver-selection.mjs +33 -0
- package/lib/domain-posture-analysis.mjs +254 -0
- package/lib/domain-posture-parsers.mjs +358 -0
- package/lib/domain-posture.mjs +745 -0
- package/lib/error-detail.mjs +10 -0
- package/lib/evidence-export.mjs +431 -0
- package/lib/evidence-report-markdown.mjs +71 -0
- package/lib/evidence-report.mjs +391 -0
- package/lib/external-intelligence-risk.mjs +108 -0
- package/lib/favicon.mjs +146 -0
- package/lib/feature-policy.mjs +70 -0
- package/lib/generated/retire-browser-catalog.mjs +8724 -0
- package/lib/generated/unicode-confusables-17.mjs +93 -0
- package/lib/hostname.mjs +38 -0
- package/lib/html-signals.mjs +680 -0
- package/lib/http-intelligence.mjs +232 -0
- package/lib/idn-confusable-policy.mjs +86 -0
- package/lib/idn-confusables.mjs +465 -0
- package/lib/lookup-cache.mjs +130 -0
- package/lib/lookup-progress.mjs +291 -0
- package/lib/lookup-source-progress.mjs +109 -0
- package/lib/lookup-threat-provider-inventory.mjs +13 -0
- package/lib/lookup.mjs +592 -0
- package/lib/observation.mjs +114 -0
- package/lib/observed-network-context.mjs +355 -0
- package/lib/operation-budget.mjs +489 -0
- package/lib/page-fingerprints.mjs +481 -0
- package/lib/page-role-profile.mjs +117 -0
- package/lib/perceptual-hash.mjs +451 -0
- package/lib/rdap-attempts.mjs +34 -0
- package/lib/rdap-bootstrap.mjs +173 -0
- package/lib/rdap-capabilities.mjs +292 -0
- package/lib/rdap-client.mjs +125 -0
- package/lib/rdap-normalization.mjs +691 -0
- package/lib/rdap-registrar.mjs +196 -0
- package/lib/rdap-transport.mjs +39 -0
- package/lib/rdap-types.mjs +1 -0
- package/lib/rdap-validation.mjs +94 -0
- package/lib/rdap.mjs +16 -0
- package/lib/registry-access.mjs +32 -0
- package/lib/registry-capabilities.mjs +117 -0
- package/lib/registry-capability-catalogue.mjs +1206 -0
- package/lib/registry-comparison.mjs +318 -0
- package/lib/registry-dates.mjs +190 -0
- package/lib/registry-insights.mjs +325 -0
- package/lib/response-policy.mjs +256 -0
- package/lib/risk-scoring.mjs +164 -0
- package/lib/safe-fetch.mjs +446 -0
- package/lib/security-txt.mjs +359 -0
- package/lib/service-binding-dns.mjs +608 -0
- package/lib/sslbl-certificates.generated.mjs +143 -0
- package/lib/sslbl-intelligence.mjs +241 -0
- package/lib/static-html-analysis.mjs +340 -0
- package/lib/structured-data-identity.mjs +323 -0
- package/lib/threat-intelligence-contract.mjs +5 -0
- package/lib/threat-intelligence-definition-registry.mjs +34 -0
- package/lib/threat-intelligence-fixtures.mjs +82 -0
- package/lib/threat-intelligence-manifests.mjs +512 -0
- package/lib/threat-intelligence-runtime.mjs +662 -0
- package/lib/threat-intelligence-targets.mjs +168 -0
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +307 -0
- package/lib/tls-intelligence.mjs +814 -0
- package/lib/typosquat-generator.mjs +884 -0
- package/lib/urlhaus-intelligence.mjs +361 -0
- package/lib/urlscan-intelligence.mjs +349 -0
- package/lib/website-security-posture.mjs +325 -0
- package/lib/website-technology.mjs +437 -0
- package/lib/whois-authority.mjs +98 -0
- package/lib/whois-chain.mjs +261 -0
- package/lib/whois-contacts.mjs +163 -0
- package/lib/whois-contracts.mjs +3 -0
- package/lib/whois-normalization.mjs +27 -0
- package/lib/whois-transport.mjs +100 -0
- package/lib/whois-values.mjs +92 -0
- package/lib/whois.mjs +1187 -0
- package/package.json +55 -0
- package/third-party-notices.txt +5571 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Privacy-minimized credential-surface evidence derived from live input and
|
|
2
|
+
// form start tags in the already-captured deep-Lookup homepage response. The
|
|
3
|
+
// shared tokenizer projects only fixed categories and bounded counts; field
|
|
4
|
+
// names, values, labels, placeholders, arbitrary attributes, and action URLs
|
|
5
|
+
// never enter this profile.
|
|
6
|
+
import { createObservation } from "./observation.mjs";
|
|
7
|
+
import { MAX_STATIC_FORMS, MAX_STATIC_INPUTS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
|
|
8
|
+
const CREDENTIAL_SURFACE_PROFILE_VERSION = 1;
|
|
9
|
+
function analyzeCredentialSurfaceProfile(input = {}) {
|
|
10
|
+
const htmlAnalysis = input.htmlAnalysis ?? analyzeStaticHtml(input.html, { baseUrl: input.baseUrl });
|
|
11
|
+
const forms = htmlAnalysis.forms;
|
|
12
|
+
const sourceTruncated = input.sourceTruncated === true;
|
|
13
|
+
const truncated = sourceTruncated
|
|
14
|
+
|| htmlAnalysis.inputLimitReached
|
|
15
|
+
|| htmlAnalysis.tagLimitReached
|
|
16
|
+
|| forms.truncated;
|
|
17
|
+
const unclassifiedActions = forms.actions.unclassified;
|
|
18
|
+
const partial = truncated || unclassifiedActions > 0;
|
|
19
|
+
const limitations = [
|
|
20
|
+
'Static input categories use only bounded type and autocomplete declarations; JavaScript-rendered, non-semantic, disabled, and hidden controls are not classified.',
|
|
21
|
+
'Category counts can overlap when one input declares more than one recognized purpose.',
|
|
22
|
+
'External submission can be legitimate and does not establish phishing, unsafe handling, ownership, intent, or maliciousness.',
|
|
23
|
+
'No field names, values, labels, placeholders, complete action URLs, paths, queries, fragments, or arbitrary attributes are retained.',
|
|
24
|
+
];
|
|
25
|
+
if (sourceTruncated || htmlAnalysis.inputLimitReached) {
|
|
26
|
+
limitations.push('The captured homepage body was truncated, so credential-surface evidence may be incomplete.');
|
|
27
|
+
}
|
|
28
|
+
if (htmlAnalysis.tagLimitReached) {
|
|
29
|
+
limitations.push('Static tokenization reached its tag boundary, so later form or input elements may be absent.');
|
|
30
|
+
}
|
|
31
|
+
if (forms.truncated) {
|
|
32
|
+
limitations.push(`Form or input profiling reached an element-count or attribute-classification boundary. At most the first ${MAX_STATIC_FORMS} forms and ${MAX_STATIC_INPUTS} input elements were eligible.`);
|
|
33
|
+
}
|
|
34
|
+
if (unclassifiedActions > 0) {
|
|
35
|
+
limitations.push(`${unclassifiedActions} form action${unclassifiedActions === 1 ? ' could' : 's could'} not be safely classified.`);
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
credentialSurfaceVersion: CREDENTIAL_SURFACE_PROFILE_VERSION,
|
|
39
|
+
...createObservation({
|
|
40
|
+
status: partial ? 'partial' : 'success',
|
|
41
|
+
observedAt: input.observedAt,
|
|
42
|
+
scanMode: 'deep',
|
|
43
|
+
source: 'html',
|
|
44
|
+
complete: !partial,
|
|
45
|
+
truncated,
|
|
46
|
+
limitations,
|
|
47
|
+
diagnostics: {
|
|
48
|
+
formsObserved: forms.formsObserved,
|
|
49
|
+
inputsObserved: forms.inputsObserved,
|
|
50
|
+
classifiedInputs: forms.classifiedInputs,
|
|
51
|
+
unclassifiedActions,
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
forms: {
|
|
55
|
+
count: forms.formsObserved,
|
|
56
|
+
methods: { ...forms.methods },
|
|
57
|
+
actions: { ...forms.actions },
|
|
58
|
+
},
|
|
59
|
+
inputs: {
|
|
60
|
+
count: forms.inputsObserved,
|
|
61
|
+
classifiedCount: forms.classifiedInputs,
|
|
62
|
+
categories: { ...forms.categories },
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export { CREDENTIAL_SURFACE_PROFILE_VERSION, analyzeCredentialSurfaceProfile, };
|
package/lib/csv.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Shared bounded CSV serialization for browser and CLI exports. Values can
|
|
2
|
+
// contain upstream evidence, so normalize controls and prevent spreadsheet
|
|
3
|
+
// formula execution before applying ordinary CSV quoting.
|
|
4
|
+
const CSV_FORMULA_TRIGGER_RE = /^(?:[\t\r\n ]*[=+\-@]|[\t\r\n])/u;
|
|
5
|
+
export const MAX_CSV_CELL_LENGTH = 32_768;
|
|
6
|
+
function csvText(value) {
|
|
7
|
+
const raw = Array.isArray(value)
|
|
8
|
+
? value.map((item) => item == null ? '' : String(item)).join(' | ')
|
|
9
|
+
: value == null ? '' : String(value);
|
|
10
|
+
return raw
|
|
11
|
+
.replace(/[\u0000-\u001f\u007f]+/gu, ' ')
|
|
12
|
+
.replace(/\s+/gu, ' ')
|
|
13
|
+
.trim()
|
|
14
|
+
.slice(0, MAX_CSV_CELL_LENGTH);
|
|
15
|
+
}
|
|
16
|
+
export function serializeCsvCell(value) {
|
|
17
|
+
const text = csvText(value);
|
|
18
|
+
const safe = CSV_FORMULA_TRIGGER_RE.test(text) ? `'${text}` : text;
|
|
19
|
+
return /[",\r\n]/u.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe;
|
|
20
|
+
}
|
|
21
|
+
export function serializeCsvRows(rows) {
|
|
22
|
+
return rows.map((row) => row.map(serializeCsvCell).join(',')).join('\n');
|
|
23
|
+
}
|
package/lib/ct-query.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const MAX_CT_QUERY_LENGTH = 200;
|
|
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 CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/u;
|
|
5
|
+
class CtQueryError extends Error {
|
|
6
|
+
code = CT_QUERY_ERROR_CODE;
|
|
7
|
+
constructor() {
|
|
8
|
+
super(CT_QUERY_ERROR_MESSAGE);
|
|
9
|
+
this.name = 'CtQueryError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function normalizeCtQuery(value) {
|
|
13
|
+
if (value === undefined || value === null)
|
|
14
|
+
return '';
|
|
15
|
+
if (typeof value !== 'string'
|
|
16
|
+
|| value.length > MAX_CT_QUERY_LENGTH
|
|
17
|
+
|| CONTROL_CHARACTER_RE.test(value)) {
|
|
18
|
+
throw new CtQueryError();
|
|
19
|
+
}
|
|
20
|
+
return value.trim();
|
|
21
|
+
}
|
|
22
|
+
function isCtQueryError(value) {
|
|
23
|
+
return value instanceof Error
|
|
24
|
+
&& 'code' in value
|
|
25
|
+
&& value.code === CT_QUERY_ERROR_CODE;
|
|
26
|
+
}
|
|
27
|
+
export { CT_QUERY_ERROR_CODE, CT_QUERY_ERROR_MESSAGE, CtQueryError, isCtQueryError, MAX_CT_QUERY_LENGTH, normalizeCtQuery, };
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// Certificate Transparency search - finds hostnames that have ever had a
|
|
2
|
+
// publicly-issued TLS certificate containing a given keyword, via crt.sh's
|
|
3
|
+
// public JSON API. This catches lookalike domains the typosquat generator's
|
|
4
|
+
// fixed permutation algorithms would never guess (arbitrary misspellings,
|
|
5
|
+
// unrelated TLDs, subdomain tricks) - almost every phishing site gets a
|
|
6
|
+
// free certificate within minutes of going live, so CT logs are often the
|
|
7
|
+
// earliest public signal a new lookalike exists at all. Shared by the
|
|
8
|
+
// Express server and the Netlify Functions.
|
|
9
|
+
import { parse } from 'tldts';
|
|
10
|
+
import { normalizeCtQuery } from "./ct-query.mjs";
|
|
11
|
+
import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
|
|
12
|
+
import { createObservation } from "./observation.mjs";
|
|
13
|
+
const CRT_SH_TIMEOUT_MS = 20000;
|
|
14
|
+
const MAX_RESULTS = 500;
|
|
15
|
+
// crt.sh's response size scales with how many certificates ever matched the
|
|
16
|
+
// keyword, not with MAX_RESULTS - a broad single-word keyword can have
|
|
17
|
+
// millions of matching certificates, and unlike the domain-homepage fetch in
|
|
18
|
+
// lib/availability.mts (fine to scan a truncated prefix for a text match),
|
|
19
|
+
// this response has to be complete, valid JSON to parse at all. Capped so a
|
|
20
|
+
// broad keyword fails with a clear "narrow your search" error instead of
|
|
21
|
+
// buffering an arbitrarily large body in memory first.
|
|
22
|
+
const CRT_SH_MAX_BYTES = 5 * 1024 * 1024;
|
|
23
|
+
// crt.sh is a free, volunteer-run service backed by a Postgres database
|
|
24
|
+
// that gets hammered constantly - broad search terms in particular can make
|
|
25
|
+
// it time out or briefly 502/503 under load, even though the same query
|
|
26
|
+
// often succeeds a few seconds later. Worth a couple of retries before
|
|
27
|
+
// giving up, rather than failing a whole search on a single transient blip.
|
|
28
|
+
const RETRYABLE_STATUSES = new Set([502, 503, 504]);
|
|
29
|
+
const MAX_RETRIES = 2;
|
|
30
|
+
const RETRY_DELAY_MS = 1500;
|
|
31
|
+
// Separate, smaller budget for timeouts specifically - each retry here
|
|
32
|
+
// already costs a full CRT_SH_TIMEOUT_MS wait (unlike the cheap ~1.5-3s
|
|
33
|
+
// delay before a status-code retry above), so this caps the worst case at
|
|
34
|
+
// two attempts (~40s) instead of stacking on top of MAX_RETRIES.
|
|
35
|
+
const MAX_TIMEOUT_RETRIES = 1;
|
|
36
|
+
// Row-count defense: a hostile or malformed response could fit a very large
|
|
37
|
+
// number of tiny rows into the 5 MB byte cap and impose excessive
|
|
38
|
+
// iteration/set overhead after parsing. Legitimate responses under the byte
|
|
39
|
+
// cap stay well within this limit.
|
|
40
|
+
const MAX_CT_ROWS = 50_000;
|
|
41
|
+
// Structured-match bounds.
|
|
42
|
+
const MAX_MATCHES = MAX_RESULTS; // 500
|
|
43
|
+
const MAX_HOSTNAMES_PER_MATCH = 50;
|
|
44
|
+
const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
|
45
|
+
// Maximum length for a digit-only certificate ID string before it is
|
|
46
|
+
// rejected as overlong. Bounds decimal crt.sh record identifiers before
|
|
47
|
+
// BigInt canonicalisation.
|
|
48
|
+
const MAX_ID_DIGITS = 32;
|
|
49
|
+
// Maximum length for a serial_number string before it is rejected.
|
|
50
|
+
const MAX_SERIAL_LENGTH = 128;
|
|
51
|
+
// Maximum length for an entry_timestamp string before it is rejected.
|
|
52
|
+
const MAX_TIMESTAMP_LENGTH = 64;
|
|
53
|
+
function normalizeHostname(raw) {
|
|
54
|
+
let h = raw.trim().toLowerCase();
|
|
55
|
+
if (h.startsWith('*.'))
|
|
56
|
+
h = h.slice(2);
|
|
57
|
+
return HOSTNAME_RE.test(h) ? h : null;
|
|
58
|
+
}
|
|
59
|
+
function delay(ms) {
|
|
60
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
61
|
+
}
|
|
62
|
+
async function fetchCrtSh(keyword, attempt = 0, dependencies = {}) {
|
|
63
|
+
const fetcher = dependencies.fetcher || safeFetch;
|
|
64
|
+
const wait = dependencies.delay || delay;
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
const timeout = setTimeout(() => controller.abort(), CRT_SH_TIMEOUT_MS);
|
|
67
|
+
try {
|
|
68
|
+
let res;
|
|
69
|
+
try {
|
|
70
|
+
res = await fetcher(`https://crt.sh/?q=${encodeURIComponent(keyword)}&output=json`, {
|
|
71
|
+
headers: { Accept: 'application/json', 'User-Agent': 'Mozilla/5.0 (compatible; DomainStatusChecker/1.0)' },
|
|
72
|
+
signal: controller.signal,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
if (!(err instanceof Error) || err.name !== 'AbortError')
|
|
77
|
+
throw err;
|
|
78
|
+
if (attempt < MAX_TIMEOUT_RETRIES)
|
|
79
|
+
return fetchCrtSh(keyword, attempt + 1, dependencies);
|
|
80
|
+
throw new Error(`crt.sh took too long to respond (>${CRT_SH_TIMEOUT_MS / 1000}s per attempt, ${attempt + 1} attempts) - ` +
|
|
81
|
+
"it can be slow for broad search terms or under heavy load; try a narrower keyword or try again shortly.");
|
|
82
|
+
}
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
// Not reading this body - release it explicitly instead of leaving an
|
|
85
|
+
// unconsumed stream (and the connection it's tied to) open until
|
|
86
|
+
// undici's own idle-timeout eventually notices.
|
|
87
|
+
await res.body?.cancel().catch(() => { });
|
|
88
|
+
if (RETRYABLE_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
|
|
89
|
+
await wait(RETRY_DELAY_MS * (attempt + 1));
|
|
90
|
+
return fetchCrtSh(keyword, attempt + 1, dependencies);
|
|
91
|
+
}
|
|
92
|
+
throw new Error(RETRYABLE_STATUSES.has(res.status)
|
|
93
|
+
? `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.`
|
|
94
|
+
: `crt.sh returned ${res.status}`);
|
|
95
|
+
}
|
|
96
|
+
const { text, truncated } = await readTextCapped(res, CRT_SH_MAX_BYTES);
|
|
97
|
+
if (truncated) {
|
|
98
|
+
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.`);
|
|
99
|
+
}
|
|
100
|
+
const data = JSON.parse(text);
|
|
101
|
+
if (!Array.isArray(data)) {
|
|
102
|
+
throw new Error('crt.sh returned an unexpected response format (expected a JSON array).');
|
|
103
|
+
}
|
|
104
|
+
if (data.length > MAX_CT_ROWS) {
|
|
105
|
+
throw new Error(`crt.sh returned too many rows (${data.length}) for "${keyword}" - try a narrower keyword.`);
|
|
106
|
+
}
|
|
107
|
+
return data;
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
clearTimeout(timeout);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Certificate identity
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
/**
|
|
117
|
+
* crt.sh `id` identifies a certificate record. Rows with the same usable ID
|
|
118
|
+
* are counted once within a registrable-domain group. When no stable
|
|
119
|
+
* certificate fields exist, the row is counted as a distinct observation
|
|
120
|
+
* based on its input position.
|
|
121
|
+
*
|
|
122
|
+
* Identity resolution (3-tier fallback):
|
|
123
|
+
* 1. Usable crt.sh `id` → `id:<canonical>`
|
|
124
|
+
* 2. Usable `issuer_ca_id` + `serial_number` → `issuer-serial:<ca>:<serial>`
|
|
125
|
+
* 3. Row index → `row:<index>`
|
|
126
|
+
*
|
|
127
|
+
* All identities are namespace-prefixed so fallback forms cannot collide.
|
|
128
|
+
*
|
|
129
|
+
* @param {object} row - a single crt.sh JSON row
|
|
130
|
+
* @param {number} index - row position in the input array
|
|
131
|
+
* @returns {string}
|
|
132
|
+
*/
|
|
133
|
+
function resolveCertId(row, index) {
|
|
134
|
+
const id = canonicalCertId(row.id);
|
|
135
|
+
if (id !== null)
|
|
136
|
+
return `id:${id}`;
|
|
137
|
+
const issuerId = canonicalCertId(row.issuer_ca_id);
|
|
138
|
+
const serial = normalizeSerial(row.serial_number);
|
|
139
|
+
if (issuerId !== null && serial !== null) {
|
|
140
|
+
return `issuer-serial:${issuerId}:${serial}`;
|
|
141
|
+
}
|
|
142
|
+
return `row:${index}`;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Canonicalises a crt.sh certificate ID field for use in identity comparison.
|
|
146
|
+
* Accepts only safe positive integers or digit-only strings that can be
|
|
147
|
+
* canonicalised without precision loss. Returns a normalised digit string
|
|
148
|
+
* or null when the value is unusable.
|
|
149
|
+
*
|
|
150
|
+
* @param {unknown} value
|
|
151
|
+
* @returns {string | null}
|
|
152
|
+
*/
|
|
153
|
+
function canonicalCertId(value) {
|
|
154
|
+
if (value === null || value === undefined)
|
|
155
|
+
return null;
|
|
156
|
+
if (typeof value === 'number') {
|
|
157
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
158
|
+
return null;
|
|
159
|
+
return String(value);
|
|
160
|
+
}
|
|
161
|
+
if (typeof value === 'string') {
|
|
162
|
+
if (value.length === 0 || value.length > MAX_ID_DIGITS)
|
|
163
|
+
return null;
|
|
164
|
+
const trimmed = value.trim();
|
|
165
|
+
if (trimmed.length === 0)
|
|
166
|
+
return null;
|
|
167
|
+
if (!/^[0-9]+$/.test(trimmed))
|
|
168
|
+
return null;
|
|
169
|
+
// Canonicalise leading zeros via BigInt (safe for up to MAX_ID_DIGITS).
|
|
170
|
+
let canonical;
|
|
171
|
+
try {
|
|
172
|
+
canonical = BigInt(trimmed).toString();
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
if (canonical === '0')
|
|
178
|
+
return null;
|
|
179
|
+
return canonical;
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Normalises a crt.sh serial_number for use in the issuer-serial composite
|
|
185
|
+
* identity. Returns null when the value is unusable.
|
|
186
|
+
*
|
|
187
|
+
* @param {unknown} value
|
|
188
|
+
* @returns {string | null}
|
|
189
|
+
*/
|
|
190
|
+
function normalizeSerial(value) {
|
|
191
|
+
if (typeof value !== 'string')
|
|
192
|
+
return null;
|
|
193
|
+
if (value.length === 0 || value.length > MAX_SERIAL_LENGTH)
|
|
194
|
+
return null;
|
|
195
|
+
// Reject control characters before trimming.
|
|
196
|
+
if (/[\x00-\x1f\x7f]/.test(value))
|
|
197
|
+
return null;
|
|
198
|
+
const trimmed = value.trim();
|
|
199
|
+
if (trimmed.length === 0)
|
|
200
|
+
return null;
|
|
201
|
+
// crt.sh serial numbers are hexadecimal.
|
|
202
|
+
if (!/^[0-9a-fA-F]+$/.test(trimmed))
|
|
203
|
+
return null;
|
|
204
|
+
return trimmed.toLowerCase();
|
|
205
|
+
}
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Timestamp validation
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
/**
|
|
210
|
+
* Validates and canonicalises a crt.sh `entry_timestamp`. Only accepts
|
|
211
|
+
* strings that parse to a finite timestamp. Returns an ISO-8601 string or
|
|
212
|
+
* null. CT observation timestamps are public-log metadata; they do not prove
|
|
213
|
+
* registration, site activation, exact issuance time, or maliciousness.
|
|
214
|
+
*
|
|
215
|
+
* @param {unknown} value
|
|
216
|
+
* @returns {string | null}
|
|
217
|
+
*/
|
|
218
|
+
function validateEntryTimestamp(value) {
|
|
219
|
+
if (typeof value !== 'string')
|
|
220
|
+
return null;
|
|
221
|
+
if (value.length === 0 || value.length > MAX_TIMESTAMP_LENGTH)
|
|
222
|
+
return null;
|
|
223
|
+
// Reject control characters and overlong strings before any parsing.
|
|
224
|
+
if (/[\x00-\x1f\x7f]/.test(value))
|
|
225
|
+
return null;
|
|
226
|
+
const trimmed = value.trim();
|
|
227
|
+
if (trimmed.length === 0)
|
|
228
|
+
return null;
|
|
229
|
+
const parsed = new Date(trimmed);
|
|
230
|
+
if (Number.isNaN(parsed.getTime()))
|
|
231
|
+
return null;
|
|
232
|
+
return parsed.toISOString();
|
|
233
|
+
}
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Pure summarizer
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
/**
|
|
238
|
+
* Transforms a parsed crt.sh JSON array into a structured summary with both
|
|
239
|
+
* legacy hostname list and registrable-domain-grouped provenance. Pure
|
|
240
|
+
* function — no network, no side effects.
|
|
241
|
+
*
|
|
242
|
+
* @param {Array<object>} rows - parsed crt.sh JSON rows
|
|
243
|
+
* @returns {{
|
|
244
|
+
* domains: string[],
|
|
245
|
+
* matches: Array<{
|
|
246
|
+
* domain: string,
|
|
247
|
+
* hostnames: string[],
|
|
248
|
+
* firstObservedAt: string | null,
|
|
249
|
+
* lastObservedAt: string | null,
|
|
250
|
+
* certificateCount: number
|
|
251
|
+
* }>,
|
|
252
|
+
* truncated: boolean
|
|
253
|
+
* }}
|
|
254
|
+
*/
|
|
255
|
+
function summarizeCtResults(rows) {
|
|
256
|
+
if (!Array.isArray(rows)) {
|
|
257
|
+
throw new Error('crt.sh returned an unexpected response format (expected a JSON array).');
|
|
258
|
+
}
|
|
259
|
+
if (rows.length > MAX_CT_ROWS) {
|
|
260
|
+
throw new Error(`crt.sh returned too many rows (${rows.length}) - try a narrower keyword.`);
|
|
261
|
+
}
|
|
262
|
+
// --- Legacy domains (HOSTNAME_RE only, no tldts dependency) ---
|
|
263
|
+
const legacyDomains = new Set();
|
|
264
|
+
// --- Structured matches ---
|
|
265
|
+
// Cache public-suffix resolution because the same SANs commonly recur
|
|
266
|
+
// across certificate rows.
|
|
267
|
+
const registrableDomainByHostname = new Map();
|
|
268
|
+
// Map<registrableDomain, { hostnames: Set, certIds: Set,
|
|
269
|
+
// firstObservedAt: string|null, lastObservedAt: string|null }>
|
|
270
|
+
const groupMap = new Map();
|
|
271
|
+
for (let i = 0; i < rows.length; i++) {
|
|
272
|
+
const row = rows[i];
|
|
273
|
+
if (!row || typeof row !== 'object')
|
|
274
|
+
continue;
|
|
275
|
+
const certId = resolveCertId(row, i);
|
|
276
|
+
// Collect hostnames from name_value and common_name.
|
|
277
|
+
const blob = `${row.name_value || ''}\n${row.common_name || ''}`;
|
|
278
|
+
const ts = validateEntryTimestamp(row.entry_timestamp);
|
|
279
|
+
for (const line of blob.split('\n')) {
|
|
280
|
+
const host = normalizeHostname(line);
|
|
281
|
+
if (!host)
|
|
282
|
+
continue;
|
|
283
|
+
// Legacy: always add if valid hostname.
|
|
284
|
+
legacyDomains.add(host);
|
|
285
|
+
// Structured: resolve registrable domain.
|
|
286
|
+
let regDomain;
|
|
287
|
+
if (registrableDomainByHostname.has(host)) {
|
|
288
|
+
regDomain = registrableDomainByHostname.get(host);
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
regDomain = parse(host).domain || null;
|
|
292
|
+
registrableDomainByHostname.set(host, regDomain);
|
|
293
|
+
}
|
|
294
|
+
if (!regDomain)
|
|
295
|
+
continue;
|
|
296
|
+
let group = groupMap.get(regDomain);
|
|
297
|
+
if (!group) {
|
|
298
|
+
group = {
|
|
299
|
+
hostnames: new Set(),
|
|
300
|
+
certIds: new Set(),
|
|
301
|
+
firstObservedAt: null,
|
|
302
|
+
lastObservedAt: null,
|
|
303
|
+
};
|
|
304
|
+
groupMap.set(regDomain, group);
|
|
305
|
+
}
|
|
306
|
+
group.hostnames.add(host);
|
|
307
|
+
group.certIds.add(certId);
|
|
308
|
+
if (ts !== null) {
|
|
309
|
+
if (group.firstObservedAt === null || ts < group.firstObservedAt) {
|
|
310
|
+
group.firstObservedAt = ts;
|
|
311
|
+
}
|
|
312
|
+
if (group.lastObservedAt === null || ts > group.lastObservedAt) {
|
|
313
|
+
group.lastObservedAt = ts;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// --- Build legacy sorted list ---
|
|
319
|
+
const sortedLegacy = [...legacyDomains].sort();
|
|
320
|
+
const legacyTruncated = sortedLegacy.length > MAX_RESULTS;
|
|
321
|
+
// --- Build structured matches ---
|
|
322
|
+
const matches = [];
|
|
323
|
+
for (const [domain, group] of groupMap) {
|
|
324
|
+
const sortedHostnames = [...group.hostnames].sort();
|
|
325
|
+
matches.push({
|
|
326
|
+
domain,
|
|
327
|
+
hostnames: sortedHostnames.slice(0, MAX_HOSTNAMES_PER_MATCH),
|
|
328
|
+
firstObservedAt: group.firstObservedAt,
|
|
329
|
+
lastObservedAt: group.lastObservedAt,
|
|
330
|
+
certificateCount: group.certIds.size,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
// Sort matches: newest lastObservedAt first, then null timestamps to end,
|
|
334
|
+
// then domain alphabetically.
|
|
335
|
+
matches.sort((a, b) => {
|
|
336
|
+
const aLast = a.lastObservedAt;
|
|
337
|
+
const bLast = b.lastObservedAt;
|
|
338
|
+
if (aLast && bLast) {
|
|
339
|
+
const cmp = bLast.localeCompare(aLast);
|
|
340
|
+
if (cmp !== 0)
|
|
341
|
+
return cmp;
|
|
342
|
+
}
|
|
343
|
+
else if (aLast) {
|
|
344
|
+
return -1;
|
|
345
|
+
}
|
|
346
|
+
else if (bLast) {
|
|
347
|
+
return 1;
|
|
348
|
+
}
|
|
349
|
+
return a.domain.localeCompare(b.domain);
|
|
350
|
+
});
|
|
351
|
+
const matchTruncated = matches.length > MAX_MATCHES;
|
|
352
|
+
const perMatchTruncated = matches.some((m) => (groupMap.get(m.domain)?.hostnames.size || 0) > MAX_HOSTNAMES_PER_MATCH);
|
|
353
|
+
return {
|
|
354
|
+
domains: sortedLegacy.slice(0, MAX_RESULTS),
|
|
355
|
+
matches: matches.slice(0, MAX_MATCHES),
|
|
356
|
+
truncated: legacyTruncated || matchTruncated || perMatchTruncated,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
// Production search
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
async function searchCertificateTransparency(keyword, dependencies = {}) {
|
|
363
|
+
const startedAt = Date.now();
|
|
364
|
+
const trimmed = normalizeCtQuery(keyword);
|
|
365
|
+
if (!trimmed) {
|
|
366
|
+
return {
|
|
367
|
+
domains: [], certCount: 0, truncated: false, matches: [],
|
|
368
|
+
observation: createObservation({
|
|
369
|
+
status: 'success', observedAt: new Date().toISOString(), source: 'certificate_transparency',
|
|
370
|
+
durationMs: Date.now() - startedAt, complete: true, truncated: false,
|
|
371
|
+
limitations: ['Certificate Transparency observations indicate public certificate logging, not current site activity or maliciousness.'],
|
|
372
|
+
diagnostics: { certificateRows: 0, matches: 0 },
|
|
373
|
+
}),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
const data = await fetchCrtSh(trimmed, 0, dependencies);
|
|
377
|
+
const summary = summarizeCtResults(data);
|
|
378
|
+
return {
|
|
379
|
+
certCount: data.length,
|
|
380
|
+
...summary,
|
|
381
|
+
observation: createObservation({
|
|
382
|
+
status: summary.truncated ? 'partial' : 'success',
|
|
383
|
+
observedAt: new Date().toISOString(),
|
|
384
|
+
source: 'certificate_transparency',
|
|
385
|
+
durationMs: Date.now() - startedAt,
|
|
386
|
+
complete: !summary.truncated,
|
|
387
|
+
truncated: summary.truncated,
|
|
388
|
+
limitations: ['Certificate Transparency observations indicate public certificate logging, not current site activity or maliciousness.'],
|
|
389
|
+
diagnostics: { certificateRows: data.length, matches: summary.matches.length },
|
|
390
|
+
}),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
export { searchCertificateTransparency, summarizeCtResults };
|