securl 1.4.1
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/CHANGELOG.md +241 -0
- package/LICENSE +21 -0
- package/README.md +427 -0
- package/RELEASING.md +37 -0
- package/SECURITY.md +27 -0
- package/dist/certificate.d.ts +5 -0
- package/dist/certificate.js +92 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +674 -0
- package/dist/compromiseSignals.d.ts +10 -0
- package/dist/compromiseSignals.js +183 -0
- package/dist/cookie-analysis.d.ts +2 -0
- package/dist/cookie-analysis.js +41 -0
- package/dist/cookieAnalysis.d.ts +2 -0
- package/dist/cookieAnalysis.js +82 -0
- package/dist/ctDiscovery.d.ts +19 -0
- package/dist/ctDiscovery.js +357 -0
- package/dist/domain-security.d.ts +10 -0
- package/dist/domain-security.js +416 -0
- package/dist/header-analysis.d.ts +14 -0
- package/dist/header-analysis.js +165 -0
- package/dist/historyDiff.d.ts +4 -0
- package/dist/historyDiff.js +117 -0
- package/dist/html-extraction.d.ts +12 -0
- package/dist/html-extraction.js +279 -0
- package/dist/html-page-analysis.d.ts +38 -0
- package/dist/html-page-analysis.js +459 -0
- package/dist/htmlInsights.d.ts +23 -0
- package/dist/htmlInsights.js +460 -0
- package/dist/identityProvider.d.ts +14 -0
- package/dist/identityProvider.js +259 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +1008 -0
- package/dist/infrastructure.d.ts +9 -0
- package/dist/infrastructure.js +149 -0
- package/dist/libraryRisk.d.ts +3 -0
- package/dist/libraryRisk.js +164 -0
- package/dist/network-validation.d.ts +30 -0
- package/dist/network-validation.js +161 -0
- package/dist/network.d.ts +34 -0
- package/dist/network.js +139 -0
- package/dist/passive-intelligence.d.ts +21 -0
- package/dist/passive-intelligence.js +247 -0
- package/dist/path-discovery.d.ts +4 -0
- package/dist/path-discovery.js +50 -0
- package/dist/postureDigest.d.ts +142 -0
- package/dist/postureDigest.js +159 -0
- package/dist/postureDrift.d.ts +4 -0
- package/dist/postureDrift.js +118 -0
- package/dist/postureRemediation.d.ts +6 -0
- package/dist/postureRemediation.js +286 -0
- package/dist/redirectChain.d.ts +2 -0
- package/dist/redirectChain.js +39 -0
- package/dist/riskEvents.d.ts +3 -0
- package/dist/riskEvents.js +187 -0
- package/dist/scannerConfig.d.ts +49 -0
- package/dist/scannerConfig.js +79 -0
- package/dist/scoring.d.ts +32 -0
- package/dist/scoring.js +367 -0
- package/dist/security-txt.d.ts +4 -0
- package/dist/security-txt.js +123 -0
- package/dist/surfaceEnrichment.d.ts +44 -0
- package/dist/surfaceEnrichment.js +377 -0
- package/dist/technology-detection.d.ts +4 -0
- package/dist/technology-detection.js +93 -0
- package/dist/types.d.ts +730 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +7 -0
- package/dist/utils.js +66 -0
- package/dist/wafFingerprint.d.ts +5 -0
- package/dist/wafFingerprint.js +156 -0
- package/examples/risk-events.mjs +27 -0
- package/examples/scan-url.mjs +17 -0
- package/package.json +102 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import dns from "node:dns/promises";
|
|
2
|
+
import { CT_CACHE_TTL_MS, DNS_LOOKUP_TIMEOUT_MS, CT_LOOKUP_TIMEOUT_MS, CT_SAMPLE_CONCURRENCY_LIMIT, CT_SAMPLE_LIMIT, CT_SUBDOMAIN_LIMIT, CT_WILDCARD_LIMIT, } from "./scannerConfig.js";
|
|
3
|
+
import { detectIdentityProviderName } from "./identityProvider.js";
|
|
4
|
+
import { headerValue, mapWithConcurrency, safeResolveWithTimeout, unique, withTimeout } from "./utils.js";
|
|
5
|
+
const ctCache = new Map();
|
|
6
|
+
const formatCtLookupFailure = (error) => {
|
|
7
|
+
if (!(error instanceof Error)) {
|
|
8
|
+
return {
|
|
9
|
+
coverageSummary: "Public certificate-transparency coverage is temporarily unavailable for this domain.",
|
|
10
|
+
issue: "Public certificate-transparency coverage could not be retrieved cleanly.",
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
const message = error.message.toLowerCase();
|
|
14
|
+
if (message.includes("timed out")) {
|
|
15
|
+
return {
|
|
16
|
+
coverageSummary: "Public certificate-transparency coverage did not return in time for this domain.",
|
|
17
|
+
issue: "The public certificate-transparency source timed out before returning coverage data.",
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
if (message.includes("not valid json") ||
|
|
21
|
+
message.includes("unexpected token") ||
|
|
22
|
+
message.includes("<html")) {
|
|
23
|
+
return {
|
|
24
|
+
coverageSummary: "Public certificate-transparency coverage is temporarily unavailable for this domain.",
|
|
25
|
+
issue: "The public certificate-transparency source returned an unreadable response.",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
coverageSummary: "Public certificate-transparency coverage is temporarily unavailable for this domain.",
|
|
30
|
+
issue: "The public certificate-transparency source could not be queried cleanly.",
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
const toDiscoveryDomain = (host) => {
|
|
34
|
+
const normalized = host.replace(/\.$/, "").toLowerCase();
|
|
35
|
+
const labels = normalized.split(".").filter(Boolean);
|
|
36
|
+
if (labels.length <= 2) {
|
|
37
|
+
return normalized;
|
|
38
|
+
}
|
|
39
|
+
const secondLevelLabels = new Set(["co", "com", "org", "net", "gov", "ac", "edu"]);
|
|
40
|
+
const last = labels[labels.length - 1];
|
|
41
|
+
const secondLast = labels[labels.length - 2];
|
|
42
|
+
if (last.length === 2 && secondLevelLabels.has(secondLast)) {
|
|
43
|
+
return labels.slice(-3).join(".");
|
|
44
|
+
}
|
|
45
|
+
return labels.slice(-2).join(".");
|
|
46
|
+
};
|
|
47
|
+
const WAF_PATTERNS = [
|
|
48
|
+
{ name: "Cloudflare", test: (headers, body) => Boolean(headerValue(headers, "cf-ray") || /cloudflare/i.test(headerValue(headers, "server") || "") || /attention required|cloudflare/i.test(body)) },
|
|
49
|
+
{ name: "Akamai", test: (headers, body) => Boolean(headerValue(headers, "x-akamai-transformed") || headerValue(headers, "akamai-cache-status") || /akamai/i.test(headerValue(headers, "server") || "") || /reference #\d+\.[a-z0-9.]+\.akamai/i.test(body)) },
|
|
50
|
+
{ name: "Imperva", test: (headers, body) => Boolean(/imperva|incapsula/i.test(headerValue(headers, "server") || "") || headerValue(headers, "x-iinfo") || /incapsula incident id|imperva/i.test(body)) },
|
|
51
|
+
{ name: "Sucuri", test: (headers, body) => Boolean(headerValue(headers, "x-sucuri-id") || headerValue(headers, "x-sucuri-cache") || /sucuri/i.test(headerValue(headers, "server") || "") || /sucuri website firewall/i.test(body)) },
|
|
52
|
+
{ name: "Fastly", test: (headers) => Boolean((headerValue(headers, "x-served-by") || "").toLowerCase().includes("cache-") || (headerValue(headers, "x-cache") || "").toLowerCase().includes("fastly")) },
|
|
53
|
+
{ name: "AWS WAF / CloudFront", test: (headers) => Boolean(headerValue(headers, "x-amz-cf-id") || /cloudfront/i.test(headerValue(headers, "server") || "")) },
|
|
54
|
+
];
|
|
55
|
+
const TAKEOVER_SIGNATURES = [
|
|
56
|
+
{
|
|
57
|
+
provider: "GitHub Pages",
|
|
58
|
+
targetPattern: /\.github\.io\.?$/i,
|
|
59
|
+
bodyPattern: /there isn't a github pages site here/i,
|
|
60
|
+
evidence: "CNAME points at GitHub Pages and the response matches the unclaimed-site pattern.",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
provider: "Amazon S3",
|
|
64
|
+
targetPattern: /\.s3(?:[.-][a-z0-9-]+)?\.amazonaws\.com\.?$/i,
|
|
65
|
+
bodyPattern: /<code>nosuchbucket<\/code>|the specified bucket does not exist/i,
|
|
66
|
+
evidence: "CNAME points at Amazon S3 and the response matches a missing-bucket pattern.",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
provider: "Azure App Service",
|
|
70
|
+
targetPattern: /\.azurewebsites\.net\.?$/i,
|
|
71
|
+
bodyPattern: /404 web site not found|app service unavailable/i,
|
|
72
|
+
evidence: "CNAME points at Azure App Service and the response matches an unassigned-site pattern.",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
provider: "Heroku",
|
|
76
|
+
targetPattern: /\.herokudns\.com\.?$/i,
|
|
77
|
+
bodyPattern: /no such app/i,
|
|
78
|
+
evidence: "CNAME points at Heroku and the response matches a missing-app pattern.",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
provider: "Netlify",
|
|
82
|
+
targetPattern: /\.netlify\.app\.?$/i,
|
|
83
|
+
bodyPattern: /page not found|not found - request id/i,
|
|
84
|
+
evidence: "CNAME points at Netlify and the response looks like an unclaimed site.",
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
const categorizeHost = (host) => {
|
|
88
|
+
if (/^(?:auth|login|sso|signin|id|identity|oauth|accounts?)\./i.test(host) || /(?:^|\.)(?:auth|login|sso|signin|id|identity|oauth|accounts?)(?:[.-]|$)/i.test(host)) {
|
|
89
|
+
return "auth";
|
|
90
|
+
}
|
|
91
|
+
if (/(^|\.)(?:api|graphql|rpc|gateway)(?:[.-]|$)/i.test(host)) {
|
|
92
|
+
return "api";
|
|
93
|
+
}
|
|
94
|
+
if (/(^|\.)(?:admin|manage|console|portal)(?:[.-]|$)/i.test(host)) {
|
|
95
|
+
return "admin";
|
|
96
|
+
}
|
|
97
|
+
if (/(^|\.)(?:app|www2?|dashboard|client|members?)(?:[.-]|$)/i.test(host)) {
|
|
98
|
+
return "app";
|
|
99
|
+
}
|
|
100
|
+
if (/(^|\.)(?:cdn|assets?|static|media|img|images?)(?:[.-]|$)/i.test(host)) {
|
|
101
|
+
return "static";
|
|
102
|
+
}
|
|
103
|
+
if (/(^|\.)(?:edge|cache|waf|shield)(?:[.-]|$)/i.test(host)) {
|
|
104
|
+
return "cdn";
|
|
105
|
+
}
|
|
106
|
+
return "other";
|
|
107
|
+
};
|
|
108
|
+
const priorityForCategory = (category) => {
|
|
109
|
+
if (category === "auth" || category === "admin" || category === "api") {
|
|
110
|
+
return "high";
|
|
111
|
+
}
|
|
112
|
+
if (category === "app" || category === "cdn") {
|
|
113
|
+
return "medium";
|
|
114
|
+
}
|
|
115
|
+
return "low";
|
|
116
|
+
};
|
|
117
|
+
const evidenceForCategory = (category) => {
|
|
118
|
+
switch (category) {
|
|
119
|
+
case "auth":
|
|
120
|
+
return "Hostname suggests identity or SSO surface.";
|
|
121
|
+
case "api":
|
|
122
|
+
return "Hostname suggests a public API or gateway surface.";
|
|
123
|
+
case "admin":
|
|
124
|
+
return "Hostname suggests administration or management surface.";
|
|
125
|
+
case "app":
|
|
126
|
+
return "Hostname suggests an application or customer-facing surface.";
|
|
127
|
+
case "cdn":
|
|
128
|
+
return "Hostname suggests edge delivery or protection infrastructure.";
|
|
129
|
+
case "static":
|
|
130
|
+
return "Hostname suggests static asset delivery.";
|
|
131
|
+
default:
|
|
132
|
+
return "Hostname was surfaced by CT logs without a stronger passive category match.";
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const rankHosts = (hosts) => hosts
|
|
136
|
+
.map((host) => {
|
|
137
|
+
const category = categorizeHost(host);
|
|
138
|
+
return {
|
|
139
|
+
host,
|
|
140
|
+
category,
|
|
141
|
+
priority: priorityForCategory(category),
|
|
142
|
+
evidence: evidenceForCategory(category),
|
|
143
|
+
};
|
|
144
|
+
})
|
|
145
|
+
.sort((left, right) => {
|
|
146
|
+
const priorityWeight = { high: 0, medium: 1, low: 2 };
|
|
147
|
+
const categoryWeight = { auth: 0, admin: 1, api: 2, app: 3, cdn: 4, static: 5, other: 6 };
|
|
148
|
+
return (priorityWeight[left.priority] - priorityWeight[right.priority] ||
|
|
149
|
+
categoryWeight[left.category] - categoryWeight[right.category] ||
|
|
150
|
+
left.host.localeCompare(right.host));
|
|
151
|
+
});
|
|
152
|
+
const detectEdgeProvider = (headers, body) => {
|
|
153
|
+
for (const entry of WAF_PATTERNS) {
|
|
154
|
+
if (entry.test(headers, body)) {
|
|
155
|
+
return entry.name;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const server = headerValue(headers, "server");
|
|
159
|
+
if (server && /(proxy|gateway|edge|gtm|belfrage|varnish)/i.test(server)) {
|
|
160
|
+
return server;
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
};
|
|
164
|
+
const classifyResponseKind = (statusCode, headers, body) => {
|
|
165
|
+
if ([301, 302, 303, 307, 308].includes(statusCode)) {
|
|
166
|
+
return "redirect";
|
|
167
|
+
}
|
|
168
|
+
const contentType = (headerValue(headers, "content-type") || "").toLowerCase();
|
|
169
|
+
if (contentType.includes("text/html") || /<html[\s>]|<!doctype html/i.test(body)) {
|
|
170
|
+
return "html";
|
|
171
|
+
}
|
|
172
|
+
if (contentType.includes("application/json") || /^\s*[{[]/.test(body)) {
|
|
173
|
+
return "json";
|
|
174
|
+
}
|
|
175
|
+
if (contentType) {
|
|
176
|
+
return "other";
|
|
177
|
+
}
|
|
178
|
+
return "unknown";
|
|
179
|
+
};
|
|
180
|
+
const summarizeObservationNote = (statusCode, responseKind, location, identityProvider, edgeProvider, suspectedTakeover) => {
|
|
181
|
+
if (suspectedTakeover) {
|
|
182
|
+
return `Possible takeover signal via ${suspectedTakeover.provider}. ${suspectedTakeover.evidence}`;
|
|
183
|
+
}
|
|
184
|
+
if (location && identityProvider) {
|
|
185
|
+
return `Redirects toward ${identityProvider} identity infrastructure.`;
|
|
186
|
+
}
|
|
187
|
+
if (location) {
|
|
188
|
+
return `Responded with a redirect to ${location}.`;
|
|
189
|
+
}
|
|
190
|
+
if (identityProvider) {
|
|
191
|
+
return `Returned content with ${identityProvider} identity signals.`;
|
|
192
|
+
}
|
|
193
|
+
if (edgeProvider) {
|
|
194
|
+
return `Returned through ${edgeProvider}.`;
|
|
195
|
+
}
|
|
196
|
+
if (statusCode >= 200 && statusCode < 300) {
|
|
197
|
+
return `Responded normally with ${responseKind} content.`;
|
|
198
|
+
}
|
|
199
|
+
if (statusCode === 401 || statusCode === 403) {
|
|
200
|
+
return "Host exists but is access controlled.";
|
|
201
|
+
}
|
|
202
|
+
if (statusCode === 404) {
|
|
203
|
+
return "Host resolved but did not expose a default page.";
|
|
204
|
+
}
|
|
205
|
+
return `Observed HTTP ${statusCode}.`;
|
|
206
|
+
};
|
|
207
|
+
const detectTakeoverSignal = (cnameTargets, body) => {
|
|
208
|
+
const normalizedTargets = cnameTargets.map((value) => value.toLowerCase());
|
|
209
|
+
const bodyLower = body.toLowerCase();
|
|
210
|
+
for (const signature of TAKEOVER_SIGNATURES) {
|
|
211
|
+
if (normalizedTargets.some((target) => signature.targetPattern.test(target)) && signature.bodyPattern.test(bodyLower)) {
|
|
212
|
+
return {
|
|
213
|
+
provider: signature.provider,
|
|
214
|
+
confidence: "medium",
|
|
215
|
+
evidence: signature.evidence,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
};
|
|
221
|
+
const observeSampledHosts = async (prioritizedHosts, requestText, sampleLimit = CT_SAMPLE_LIMIT) => {
|
|
222
|
+
const samples = prioritizedHosts.slice(0, sampleLimit);
|
|
223
|
+
const observations = await mapWithConcurrency(samples, CT_SAMPLE_CONCURRENCY_LIMIT, async (hostInfo) => {
|
|
224
|
+
const target = new URL(`https://${hostInfo.host}/`);
|
|
225
|
+
const cnameTargets = (await safeResolveWithTimeout(() => dns.resolveCname(hostInfo.host), DNS_LOOKUP_TIMEOUT_MS)) || [];
|
|
226
|
+
try {
|
|
227
|
+
const response = await withTimeout(requestText(target), CT_LOOKUP_TIMEOUT_MS, "CT sample request timed out.");
|
|
228
|
+
const location = headerValue(response.headers, "location");
|
|
229
|
+
const redirectTarget = location ? new URL(location, target).hostname : null;
|
|
230
|
+
const identityProvider = detectIdentityProviderName([
|
|
231
|
+
hostInfo.host,
|
|
232
|
+
redirectTarget,
|
|
233
|
+
response.body,
|
|
234
|
+
location,
|
|
235
|
+
].filter((value) => Boolean(value)));
|
|
236
|
+
const edgeProvider = detectEdgeProvider(response.headers, response.body);
|
|
237
|
+
const responseKind = classifyResponseKind(response.statusCode, response.headers, response.body);
|
|
238
|
+
const suspectedTakeover = detectTakeoverSignal(cnameTargets, response.body);
|
|
239
|
+
return {
|
|
240
|
+
host: hostInfo.host,
|
|
241
|
+
category: hostInfo.category,
|
|
242
|
+
priority: hostInfo.priority,
|
|
243
|
+
reachable: true,
|
|
244
|
+
finalUrl: target.toString(),
|
|
245
|
+
statusCode: response.statusCode,
|
|
246
|
+
responseKind,
|
|
247
|
+
identityProvider,
|
|
248
|
+
edgeProvider,
|
|
249
|
+
cnameTargets,
|
|
250
|
+
suspectedTakeover,
|
|
251
|
+
note: summarizeObservationNote(response.statusCode, responseKind, location, identityProvider, edgeProvider, suspectedTakeover),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
return {
|
|
256
|
+
host: hostInfo.host,
|
|
257
|
+
category: hostInfo.category,
|
|
258
|
+
priority: hostInfo.priority,
|
|
259
|
+
reachable: false,
|
|
260
|
+
finalUrl: target.toString(),
|
|
261
|
+
statusCode: 0,
|
|
262
|
+
responseKind: "unknown",
|
|
263
|
+
identityProvider: null,
|
|
264
|
+
edgeProvider: null,
|
|
265
|
+
cnameTargets,
|
|
266
|
+
suspectedTakeover: null,
|
|
267
|
+
note: error instanceof Error ? error.message : "CT sample request failed.",
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
return observations;
|
|
272
|
+
};
|
|
273
|
+
export const fetchCtDiscovery = async (host, requestJson, requestText, options = {}) => {
|
|
274
|
+
const { sampleHosts = true, subdomainLimit = CT_SUBDOMAIN_LIMIT, wildcardLimit = CT_WILDCARD_LIMIT, sampleLimit = CT_SAMPLE_LIMIT, } = options;
|
|
275
|
+
const queriedDomain = toDiscoveryDomain(host);
|
|
276
|
+
const cached = ctCache.get(queriedDomain);
|
|
277
|
+
if (cached) {
|
|
278
|
+
if (cached.expiresAt > Date.now())
|
|
279
|
+
return cached.value; // still valid
|
|
280
|
+
ctCache.delete(queriedDomain); // expired — evict before re-fetch
|
|
281
|
+
}
|
|
282
|
+
if (ctCache.size > 2000)
|
|
283
|
+
ctCache.clear();
|
|
284
|
+
const sourceUrl = `https://crt.sh/?q=%25.${queriedDomain}&output=json`;
|
|
285
|
+
try {
|
|
286
|
+
const response = await withTimeout(requestJson(new URL(sourceUrl)), CT_LOOKUP_TIMEOUT_MS, "Certificate transparency lookup timed out.");
|
|
287
|
+
const rows = Array.isArray(response.json) ? response.json : [];
|
|
288
|
+
const rawNames = rows.flatMap((entry) => String(entry?.name_value || "")
|
|
289
|
+
.split(/\r?\n/)
|
|
290
|
+
.map((value) => value.trim().toLowerCase())
|
|
291
|
+
.filter(Boolean));
|
|
292
|
+
const wildcardEntries = unique(rawNames
|
|
293
|
+
.filter((value) => value.startsWith("*."))
|
|
294
|
+
.map((value) => value.slice(2))
|
|
295
|
+
.filter((value) => value === queriedDomain || value.endsWith(`.${queriedDomain}`))).slice(0, wildcardLimit);
|
|
296
|
+
const subdomains = unique(rawNames.filter((value) => !value.startsWith("*.") && value !== queriedDomain && value.endsWith(`.${queriedDomain}`))).slice(0, subdomainLimit);
|
|
297
|
+
const prioritizedHosts = rankHosts(subdomains);
|
|
298
|
+
const sampledHosts = sampleHosts ? await observeSampledHosts(prioritizedHosts, requestText, sampleLimit) : [];
|
|
299
|
+
const authCount = prioritizedHosts.filter((entry) => entry.category === "auth").length;
|
|
300
|
+
const edgeHits = sampledHosts.filter((entry) => entry.edgeProvider).length;
|
|
301
|
+
const takeoverHits = sampledHosts.filter((entry) => entry.suspectedTakeover);
|
|
302
|
+
const coverageSummary = subdomains.length
|
|
303
|
+
? `CT logs surfaced ${subdomains.length} subdomain${subdomains.length === 1 ? "" : "s"} for ${queriedDomain}; ${prioritizedHosts.filter((entry) => entry.priority === "high").length} look high-priority${sampleHosts ? ` and ${sampledHosts.length} were lightly sampled` : ""}.`
|
|
304
|
+
: `CT logs did not surface distinct subdomains for ${queriedDomain}.`;
|
|
305
|
+
const value = {
|
|
306
|
+
queriedDomain,
|
|
307
|
+
sourceUrl,
|
|
308
|
+
subdomains,
|
|
309
|
+
wildcardEntries,
|
|
310
|
+
prioritizedHosts,
|
|
311
|
+
sampledHosts,
|
|
312
|
+
coverageSummary,
|
|
313
|
+
issues: [
|
|
314
|
+
...(subdomains.length
|
|
315
|
+
? []
|
|
316
|
+
: ["Certificate transparency search did not return any distinct subdomains for this domain."]),
|
|
317
|
+
...(authCount
|
|
318
|
+
? [`CT logs surfaced ${authCount} auth- or login-like host${authCount === 1 ? "" : "s"} worth reviewing.`]
|
|
319
|
+
: []),
|
|
320
|
+
...takeoverHits.map((entry) => `Possible subdomain takeover signal on ${entry.host} via ${entry.suspectedTakeover?.provider}. Review the DNS target and service ownership.`),
|
|
321
|
+
],
|
|
322
|
+
strengths: [
|
|
323
|
+
...(subdomains.length
|
|
324
|
+
? [`Certificate transparency surfaced ${subdomains.length} subdomain${subdomains.length === 1 ? "" : "s"} without touching the target.`]
|
|
325
|
+
: []),
|
|
326
|
+
...(sampledHosts.length
|
|
327
|
+
? [`Best-effort coverage sampled ${sampledHosts.length} discovered host${sampledHosts.length === 1 ? "" : "s"} to estimate exposed footprint.`]
|
|
328
|
+
: []),
|
|
329
|
+
...(edgeHits
|
|
330
|
+
? [`${edgeHits} sampled host${edgeHits === 1 ? "" : "s"} showed edge or protection-provider signals.`]
|
|
331
|
+
: []),
|
|
332
|
+
...(!takeoverHits.length && sampledHosts.some((entry) => entry.cnameTargets.length)
|
|
333
|
+
? ["No obvious takeover-style signatures were observed among the sampled CT hosts."]
|
|
334
|
+
: []),
|
|
335
|
+
],
|
|
336
|
+
};
|
|
337
|
+
ctCache.set(queriedDomain, {
|
|
338
|
+
expiresAt: Date.now() + CT_CACHE_TTL_MS,
|
|
339
|
+
value,
|
|
340
|
+
});
|
|
341
|
+
return value;
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
const fallback = formatCtLookupFailure(error);
|
|
345
|
+
return {
|
|
346
|
+
queriedDomain,
|
|
347
|
+
sourceUrl,
|
|
348
|
+
subdomains: [],
|
|
349
|
+
wildcardEntries: [],
|
|
350
|
+
prioritizedHosts: [],
|
|
351
|
+
sampledHosts: [],
|
|
352
|
+
coverageSummary: fallback.coverageSummary,
|
|
353
|
+
issues: [fallback.issue],
|
|
354
|
+
strengths: [],
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { DomainSecurityInfo } from "./types.js";
|
|
2
|
+
import type { RequestTextFn } from "./network.js";
|
|
3
|
+
type SpfPolicyEvaluation = DomainSecurityInfo["emailPolicy"]["spf"];
|
|
4
|
+
type DmarcPolicyEvaluation = DomainSecurityInfo["emailPolicy"]["dmarc"];
|
|
5
|
+
type SpfDetail = NonNullable<DomainSecurityInfo["spfDetail"]>;
|
|
6
|
+
export declare const evaluateSpfPolicy: (spf: string | null) => SpfPolicyEvaluation;
|
|
7
|
+
export declare const evaluateSpfDetail: (spf: string | null) => SpfDetail;
|
|
8
|
+
export declare const evaluateDmarcPolicy: (dmarc: string | null) => DmarcPolicyEvaluation;
|
|
9
|
+
export declare function analyzeDomainSecurity(host: string, requestText: RequestTextFn): Promise<DomainSecurityInfo>;
|
|
10
|
+
export {};
|