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
package/dist/index.js
ADDED
|
@@ -0,0 +1,1008 @@
|
|
|
1
|
+
import { URL } from "node:url";
|
|
2
|
+
import { scanTls } from "./certificate.js";
|
|
3
|
+
import { buildCompromiseSignals, emptyCompromiseSignals } from "./compromiseSignals.js";
|
|
4
|
+
import { parseSetCookie } from "./cookie-analysis.js";
|
|
5
|
+
import { analyzeCookieHeaders } from "./cookieAnalysis.js";
|
|
6
|
+
import { fetchCtDiscovery } from "./ctDiscovery.js";
|
|
7
|
+
import { analyzeDomainSecurity } from "./domain-security.js";
|
|
8
|
+
import { API_SURFACE_PROBES, CRAWL_CONCURRENCY_LIMIT, CRAWL_CANDIDATES, CRAWL_PAGE_LIMIT, CT_SAMPLE_LIMIT, CT_SUBDOMAIN_LIMIT, CT_WILDCARD_LIMIT, DEEP_PASSIVE_API_SURFACE_PROBES, DEEP_PASSIVE_CRAWL_PAGE_LIMIT, DEEP_PASSIVE_CT_SAMPLE_LIMIT, DEEP_PASSIVE_CT_SUBDOMAIN_LIMIT, DEEP_PASSIVE_CT_WILDCARD_LIMIT, DEEP_PASSIVE_EXPOSURE_PROBES, DEEP_PASSIVE_SCAN_TIMEOUT_MS, EXPOSURE_PROBES, MAX_SCAN_DURATION_MS, REQUEST_TIMEOUT_MS, SECONDARY_REQUEST_TIMEOUT_MS, } from "./scannerConfig.js";
|
|
9
|
+
import { analyzeHeaders, buildLibraryRiskIssues, buildRawHeaders, buildRemediation, classifyIssueTaxonomy, SECURITY_HEADERS, } from "./header-analysis.js";
|
|
10
|
+
import { analyzeThirdPartyTrust, buildExecutiveSummary, mergeTechnologies } from "./htmlInsights.js";
|
|
11
|
+
import { analyzeHtmlDocument as analyzeHtmlDocumentFromModule, analyzeHtmlSecurity, detectAssessmentLimitation, fetchHtmlDocument } from "./html-page-analysis.js";
|
|
12
|
+
import { classifyHtmlApiFallback, isAccessDeniedHtml, } from "./html-extraction.js";
|
|
13
|
+
import { analyzeIdentityProvider } from "./identityProvider.js";
|
|
14
|
+
import { analyzeInfrastructure } from "./infrastructure.js";
|
|
15
|
+
import { analyzeApiSurface, analyzeCorsSecurity, analyzeExposure, fetchPublicSignals, } from "./surfaceEnrichment.js";
|
|
16
|
+
import { fetchLibraryRiskSignals } from "./libraryRisk.js";
|
|
17
|
+
import { fetchWithRedirects, requestJson, requestOnce, requestText, requestWithHeaders, } from "./network.js";
|
|
18
|
+
import { normalizeDiscoveredPath, rankDiscoveredPaths } from "./path-discovery.js";
|
|
19
|
+
import { buildPassiveIntelligence, emptyPassiveIntelligence } from "./passive-intelligence.js";
|
|
20
|
+
import { analyzeRedirectChain } from "./redirectChain.js";
|
|
21
|
+
import { attachIssueEvidence, buildPostureRemediationPlan } from "./postureRemediation.js";
|
|
22
|
+
import { scoreAnalysis, scorePostureAnalysis, summarizePostureGrade } from "./scoring.js";
|
|
23
|
+
import { fetchSecurityTxt } from "./security-txt.js";
|
|
24
|
+
import { detectTechnologies } from "./technology-detection.js";
|
|
25
|
+
import { headerValue, mapWithConcurrency, unique, withTimeout } from "./utils.js";
|
|
26
|
+
import { analyzeWafFingerprint } from "./wafFingerprint.js";
|
|
27
|
+
export { buildPostureRiskEventsFromDiff, buildPostureRiskEventsFromSnapshots } from "./riskEvents.js";
|
|
28
|
+
export { buildPostureDigest } from "./postureDigest.js";
|
|
29
|
+
export { buildPostureDriftReport, buildPostureDriftReportFromDiff, buildPostureDriftReportFromSnapshots, } from "./postureDrift.js";
|
|
30
|
+
export { attachIssueEvidence, buildIssueEvidence, buildPostureRemediationPlan, } from "./postureRemediation.js";
|
|
31
|
+
function buildScanProfile(mode, requestedTimeoutMs) {
|
|
32
|
+
const deepPassive = mode === "deep-passive";
|
|
33
|
+
const scanTimeoutMs = requestedTimeoutMs ?? (deepPassive ? DEEP_PASSIVE_SCAN_TIMEOUT_MS : MAX_SCAN_DURATION_MS);
|
|
34
|
+
return {
|
|
35
|
+
mode,
|
|
36
|
+
pageAnalysisEnabled: mode !== "quiet",
|
|
37
|
+
scanTimeoutMs,
|
|
38
|
+
crawlPageLimit: deepPassive ? DEEP_PASSIVE_CRAWL_PAGE_LIMIT : CRAWL_PAGE_LIMIT,
|
|
39
|
+
exposureProbes: deepPassive ? DEEP_PASSIVE_EXPOSURE_PROBES : EXPOSURE_PROBES,
|
|
40
|
+
apiSurfaceProbes: deepPassive ? DEEP_PASSIVE_API_SURFACE_PROBES : API_SURFACE_PROBES,
|
|
41
|
+
ctSubdomainLimit: deepPassive ? DEEP_PASSIVE_CT_SUBDOMAIN_LIMIT : CT_SUBDOMAIN_LIMIT,
|
|
42
|
+
ctWildcardLimit: deepPassive ? DEEP_PASSIVE_CT_WILDCARD_LIMIT : CT_WILDCARD_LIMIT,
|
|
43
|
+
ctSampleLimit: deepPassive ? DEEP_PASSIVE_CT_SAMPLE_LIMIT : CT_SAMPLE_LIMIT,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const emptyCertificate = () => ({
|
|
47
|
+
available: false,
|
|
48
|
+
valid: false,
|
|
49
|
+
authorized: false,
|
|
50
|
+
issuer: null,
|
|
51
|
+
subject: null,
|
|
52
|
+
validFrom: null,
|
|
53
|
+
validTo: null,
|
|
54
|
+
daysRemaining: null,
|
|
55
|
+
protocol: null,
|
|
56
|
+
cipher: null,
|
|
57
|
+
fingerprint: null,
|
|
58
|
+
subjectAltName: [],
|
|
59
|
+
issues: [],
|
|
60
|
+
});
|
|
61
|
+
function normalizeUrl(input) {
|
|
62
|
+
let candidate = input.trim();
|
|
63
|
+
if (!candidate) {
|
|
64
|
+
throw new Error("Enter a URL to scan.");
|
|
65
|
+
}
|
|
66
|
+
if (!/^https?:\/\//i.test(candidate)) {
|
|
67
|
+
candidate = `https://${candidate}`;
|
|
68
|
+
}
|
|
69
|
+
const normalized = new URL(candidate);
|
|
70
|
+
if (!["http:", "https:"].includes(normalized.protocol)) {
|
|
71
|
+
throw new Error("Only http and https URLs are supported.");
|
|
72
|
+
}
|
|
73
|
+
return normalized;
|
|
74
|
+
}
|
|
75
|
+
function shouldRetryOverHttp(error) {
|
|
76
|
+
if (!(error instanceof Error)) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const message = error.message.toLowerCase();
|
|
80
|
+
return (message.includes("socket hang up") ||
|
|
81
|
+
message.includes("econnreset") ||
|
|
82
|
+
message.includes("tls") ||
|
|
83
|
+
message.includes("ssl") ||
|
|
84
|
+
message.includes("wrong version number") ||
|
|
85
|
+
message.includes("alert handshake failure"));
|
|
86
|
+
}
|
|
87
|
+
function formatErrorMessage(error) {
|
|
88
|
+
if (error instanceof AggregateError && Array.isArray(error.errors) && error.errors.length) {
|
|
89
|
+
const messages = error.errors
|
|
90
|
+
.map((item) => (item instanceof Error ? item.message : String(item)))
|
|
91
|
+
.filter(Boolean);
|
|
92
|
+
if (messages.length) {
|
|
93
|
+
return messages.join("; ");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (error instanceof Error && error.message) {
|
|
97
|
+
return error.message;
|
|
98
|
+
}
|
|
99
|
+
return "Unable to analyze URL.";
|
|
100
|
+
}
|
|
101
|
+
function sanitiseErrorDetail(msg) {
|
|
102
|
+
// Remove raw IP addresses to avoid leaking internal network topology
|
|
103
|
+
return msg.replace(/\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b/g, "<host>");
|
|
104
|
+
}
|
|
105
|
+
function classifyAssessmentFailure(error) {
|
|
106
|
+
const detail = formatErrorMessage(error);
|
|
107
|
+
const message = detail.toLowerCase();
|
|
108
|
+
if (message.includes("timed out")) {
|
|
109
|
+
return {
|
|
110
|
+
kind: "service_unavailable",
|
|
111
|
+
title: "The target did not respond in time.",
|
|
112
|
+
detail: "The scanner could not complete a trusted response fetch before timing out, so this is only a limited assessment.",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (message.includes("certificate") ||
|
|
116
|
+
message.includes("self-signed") ||
|
|
117
|
+
message.includes("unable to verify") ||
|
|
118
|
+
message.includes("hostname/ip does not match") ||
|
|
119
|
+
message.includes("expired")) {
|
|
120
|
+
return {
|
|
121
|
+
kind: "other",
|
|
122
|
+
title: "TLS certificate validation failed.",
|
|
123
|
+
detail: sanitiseErrorDetail(`The scanner could not establish a trusted HTTPS connection: ${detail}`),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (message.includes("econnrefused") ||
|
|
127
|
+
message.includes("enotfound") ||
|
|
128
|
+
message.includes("socket hang up") ||
|
|
129
|
+
message.includes("ehostunreach")) {
|
|
130
|
+
return {
|
|
131
|
+
kind: "service_unavailable",
|
|
132
|
+
title: "The target could not be reached cleanly.",
|
|
133
|
+
detail: `The scanner could not obtain a stable response from the target: ${detail}`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
kind: "other",
|
|
138
|
+
title: "The target could not be assessed cleanly.",
|
|
139
|
+
detail: `The scan did not complete normally: ${detail}`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
export function analyzeHtmlDocument(input, html) {
|
|
143
|
+
return analyzeHtmlDocumentFromModule(input, html);
|
|
144
|
+
}
|
|
145
|
+
function parseRobotsSitemaps(body) {
|
|
146
|
+
return unique(body
|
|
147
|
+
.split(/\r?\n/)
|
|
148
|
+
.map((line) => line.trim())
|
|
149
|
+
.filter((line) => /^sitemap:/i.test(line))
|
|
150
|
+
.map((line) => line.replace(/^sitemap:\s*/i, "").trim()));
|
|
151
|
+
}
|
|
152
|
+
function parseSitemapPaths(xml, finalUrl) {
|
|
153
|
+
return rankDiscoveredPaths([...xml.matchAll(/<loc>([\s\S]*?)<\/loc>/gi)].map((match) => normalizeDiscoveredPath(match[1].trim(), finalUrl)));
|
|
154
|
+
}
|
|
155
|
+
async function collectDiscoveryPaths(finalUrl, htmlSecurity, requestTextFn = requestText) {
|
|
156
|
+
const discoverySources = [];
|
|
157
|
+
const discoveredPaths = [...(htmlSecurity.firstPartyPaths || [])];
|
|
158
|
+
if (htmlSecurity.firstPartyPaths?.length) {
|
|
159
|
+
discoverySources.push("page links");
|
|
160
|
+
}
|
|
161
|
+
const sitemapCandidates = [new URL("/sitemap.xml", finalUrl.origin).toString()];
|
|
162
|
+
try {
|
|
163
|
+
const robotsResponse = await requestTextFn(new URL("/robots.txt", finalUrl.origin));
|
|
164
|
+
if (robotsResponse.statusCode >= 200 && robotsResponse.statusCode < 300 && robotsResponse.body.trim()) {
|
|
165
|
+
discoverySources.push("robots.txt");
|
|
166
|
+
sitemapCandidates.push(...parseRobotsSitemaps(robotsResponse.body));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// Ignore robots fetch failures.
|
|
171
|
+
}
|
|
172
|
+
for (const sitemapCandidate of unique(sitemapCandidates).slice(0, 2)) {
|
|
173
|
+
try {
|
|
174
|
+
const sitemapUrl = new URL(sitemapCandidate, finalUrl);
|
|
175
|
+
const response = await requestTextFn(sitemapUrl);
|
|
176
|
+
if (response.statusCode >= 200 && response.statusCode < 300 && response.body.includes("<loc>")) {
|
|
177
|
+
discoveredPaths.push(...parseSitemapPaths(response.body, finalUrl));
|
|
178
|
+
discoverySources.push(sitemapUrl.pathname === "/sitemap.xml" ? "sitemap.xml" : "robots.txt sitemap");
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// Ignore sitemap fetch failures.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
paths: rankDiscoveredPaths(discoveredPaths),
|
|
188
|
+
sources: unique(discoverySources),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
async function analyzeUrlCore(input, options = {}) {
|
|
192
|
+
const { includeCertificate = true } = options;
|
|
193
|
+
const requestOptions = options.requestTimeoutMs ? { timeoutMs: options.requestTimeoutMs } : {};
|
|
194
|
+
let normalizedUrl = input instanceof URL ? input : normalizeUrl(input);
|
|
195
|
+
let requestData;
|
|
196
|
+
try {
|
|
197
|
+
requestData = await fetchWithRedirects(normalizedUrl, undefined, requestOptions);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (normalizedUrl.protocol === "https:" && shouldRetryOverHttp(error)) {
|
|
201
|
+
const fallbackUrl = new URL(normalizedUrl);
|
|
202
|
+
fallbackUrl.protocol = "http:";
|
|
203
|
+
normalizedUrl = fallbackUrl;
|
|
204
|
+
try {
|
|
205
|
+
requestData = await fetchWithRedirects(normalizedUrl, undefined, requestOptions);
|
|
206
|
+
}
|
|
207
|
+
catch (fallbackError) {
|
|
208
|
+
throw new Error(`HTTPS failed and the site did not respond cleanly over HTTP either: ${formatErrorMessage(fallbackError)}`, { cause: fallbackError });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const certificate = includeCertificate ? await scanTls(requestData.finalUrl) : emptyCertificate();
|
|
216
|
+
const rawHeaders = buildRawHeaders(requestData.response.headers);
|
|
217
|
+
const { headers: headerResults, issues: headerIssues, strengths } = analyzeHeaders(requestData.response.headers, requestData.finalUrl.protocol === "https:");
|
|
218
|
+
const cookies = parseSetCookie(requestData.response.headers["set-cookie"]);
|
|
219
|
+
const cookieAnalysis = analyzeCookieHeaders(requestData.response.headers["set-cookie"]);
|
|
220
|
+
const redirectChain = analyzeRedirectChain(normalizedUrl, requestData.finalUrl, requestData.redirects);
|
|
221
|
+
const technologies = detectTechnologies(requestData.response.headers, requestData.finalUrl);
|
|
222
|
+
const { score, grade } = scoreAnalysis({
|
|
223
|
+
isHttps: requestData.finalUrl.protocol === "https:",
|
|
224
|
+
headerResults,
|
|
225
|
+
certificate,
|
|
226
|
+
cookies,
|
|
227
|
+
redirects: requestData.redirects,
|
|
228
|
+
});
|
|
229
|
+
const cookieIssues = cookies.flatMap((cookie) => cookie.issues.map((detail) => ({
|
|
230
|
+
severity: cookie.risk === "high" ? "warning" : "info",
|
|
231
|
+
area: "cookies",
|
|
232
|
+
title: `Cookie ${cookie.name} needs attention`,
|
|
233
|
+
detail,
|
|
234
|
+
confidence: "high",
|
|
235
|
+
source: "observed",
|
|
236
|
+
owasp: [],
|
|
237
|
+
mitre: [],
|
|
238
|
+
})));
|
|
239
|
+
const redirectIssues = requestData.redirects.length > 1
|
|
240
|
+
? [
|
|
241
|
+
{
|
|
242
|
+
severity: "info",
|
|
243
|
+
area: "transport",
|
|
244
|
+
title: "Redirect chain detected",
|
|
245
|
+
detail: `This scan followed ${requestData.redirects.length - 1} redirect${requestData.redirects.length > 2 ? "s" : ""} before reaching the final URL.`,
|
|
246
|
+
confidence: "high",
|
|
247
|
+
source: "observed",
|
|
248
|
+
owasp: [],
|
|
249
|
+
mitre: [],
|
|
250
|
+
},
|
|
251
|
+
]
|
|
252
|
+
: [];
|
|
253
|
+
const issues = [...headerIssues, ...cookieIssues, ...redirectIssues];
|
|
254
|
+
if (certificate.issues.length) {
|
|
255
|
+
issues.push(...certificate.issues.map((detail) => ({
|
|
256
|
+
severity: /outdated|not trusted|expires/i.test(detail) ? "warning" : "info",
|
|
257
|
+
area: "certificate",
|
|
258
|
+
title: "TLS certificate needs attention",
|
|
259
|
+
detail,
|
|
260
|
+
confidence: /expires/i.test(detail) ? "high" : "medium",
|
|
261
|
+
source: "observed",
|
|
262
|
+
owasp: [],
|
|
263
|
+
mitre: [],
|
|
264
|
+
})));
|
|
265
|
+
}
|
|
266
|
+
const normalizedIssues = issues.map(classifyIssueTaxonomy);
|
|
267
|
+
const summary = grade === "A+"
|
|
268
|
+
? "Excellent baseline hardening."
|
|
269
|
+
: grade === "A"
|
|
270
|
+
? "Strong setup with a few remaining improvements."
|
|
271
|
+
: grade === "B"
|
|
272
|
+
? "Reasonably protected, but several headers or cookie controls can be improved."
|
|
273
|
+
: "Security posture needs work before this would count as well hardened.";
|
|
274
|
+
return {
|
|
275
|
+
inputUrl: input instanceof URL ? input.toString() : input,
|
|
276
|
+
normalizedUrl: normalizedUrl.toString(),
|
|
277
|
+
finalUrl: requestData.finalUrl.toString(),
|
|
278
|
+
host: requestData.finalUrl.hostname,
|
|
279
|
+
scannedAt: new Date().toISOString(),
|
|
280
|
+
responseTimeMs: requestData.response.elapsedMs,
|
|
281
|
+
statusCode: requestData.response.statusCode,
|
|
282
|
+
score,
|
|
283
|
+
grade,
|
|
284
|
+
summary,
|
|
285
|
+
headers: headerResults,
|
|
286
|
+
rawHeaders,
|
|
287
|
+
cookies,
|
|
288
|
+
cookieAnalysis,
|
|
289
|
+
technologies,
|
|
290
|
+
certificate,
|
|
291
|
+
redirects: requestData.redirects,
|
|
292
|
+
redirectChain,
|
|
293
|
+
issues: normalizedIssues,
|
|
294
|
+
strengths,
|
|
295
|
+
remediation: buildRemediation(headerResults),
|
|
296
|
+
assessmentLimitation: {
|
|
297
|
+
limited: false,
|
|
298
|
+
kind: null,
|
|
299
|
+
title: null,
|
|
300
|
+
detail: null,
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
async function analyzeHtmlSecuritySignals(finalUrl, pageAnalysisEnabled) {
|
|
305
|
+
let htmlDocument = null;
|
|
306
|
+
if (pageAnalysisEnabled) {
|
|
307
|
+
try {
|
|
308
|
+
htmlDocument = await fetchHtmlDocument(finalUrl);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
htmlDocument = null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const emptyHtmlSecurity = analyzeHtmlSecurity(finalUrl, null);
|
|
315
|
+
const baseHtmlSecurity = pageAnalysisEnabled
|
|
316
|
+
? analyzeHtmlSecurity(finalUrl, htmlDocument)
|
|
317
|
+
: {
|
|
318
|
+
...emptyHtmlSecurity,
|
|
319
|
+
issues: [],
|
|
320
|
+
aiSurface: {
|
|
321
|
+
...emptyHtmlSecurity.aiSurface,
|
|
322
|
+
issues: [],
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
const libraryRiskSignals = pageAnalysisEnabled
|
|
326
|
+
? await fetchLibraryRiskSignals(baseHtmlSecurity.libraryFingerprints)
|
|
327
|
+
: [];
|
|
328
|
+
const htmlSecurity = {
|
|
329
|
+
...baseHtmlSecurity,
|
|
330
|
+
libraryRiskSignals,
|
|
331
|
+
issues: [
|
|
332
|
+
...baseHtmlSecurity.issues,
|
|
333
|
+
...libraryRiskSignals.map((signal) => `${signal.packageName} ${signal.version} matched ${signal.vulnerabilities.length} OSV advisor${signal.vulnerabilities.length === 1 ? "y" : "ies"} from public script references.`),
|
|
334
|
+
],
|
|
335
|
+
strengths: baseHtmlSecurity.libraryFingerprints.length > 0 && libraryRiskSignals.length === 0
|
|
336
|
+
? [...baseHtmlSecurity.strengths, "No OSV advisory matches were found for the explicitly versioned client libraries detected on the fetched page."]
|
|
337
|
+
: baseHtmlSecurity.strengths,
|
|
338
|
+
};
|
|
339
|
+
return { htmlDocument, htmlSecurity };
|
|
340
|
+
}
|
|
341
|
+
async function buildLimitedResult(input, normalizedInput, failure, scanTiming) {
|
|
342
|
+
const publicSignals = await fetchPublicSignals(normalizedInput.host, { requestText }).catch(() => ({
|
|
343
|
+
hstsPreload: {
|
|
344
|
+
status: "unknown",
|
|
345
|
+
summary: "Public HSTS preload status could not be determined.",
|
|
346
|
+
sourceUrl: `https://hstspreload.org/api/v2/status?domain=${encodeURIComponent(normalizedInput.host)}`,
|
|
347
|
+
},
|
|
348
|
+
issues: [],
|
|
349
|
+
strengths: [],
|
|
350
|
+
}));
|
|
351
|
+
const fallbackHtmlSecurity = analyzeHtmlSecurity(normalizedInput, null);
|
|
352
|
+
const fallbackIssue = classifyIssueTaxonomy({
|
|
353
|
+
severity: "warning",
|
|
354
|
+
area: "transport",
|
|
355
|
+
title: failure.title,
|
|
356
|
+
detail: failure.detail,
|
|
357
|
+
confidence: "high",
|
|
358
|
+
source: "observed",
|
|
359
|
+
owasp: ["A02 Cryptographic Failures"],
|
|
360
|
+
mitre: ["Reconnaissance"],
|
|
361
|
+
});
|
|
362
|
+
const domainSecurity = await analyzeDomainSecurity(normalizedInput.host, requestText).catch(() => ({
|
|
363
|
+
host: normalizedInput.host,
|
|
364
|
+
mxRecords: [],
|
|
365
|
+
nsRecords: [],
|
|
366
|
+
caaRecords: [],
|
|
367
|
+
dnssec: { enabled: false, dsRecords: [], status: "unknown" },
|
|
368
|
+
spf: null,
|
|
369
|
+
dmarc: null,
|
|
370
|
+
emailPolicy: {
|
|
371
|
+
spf: {
|
|
372
|
+
status: "missing",
|
|
373
|
+
allMechanism: null,
|
|
374
|
+
dnsLookupMechanisms: 0,
|
|
375
|
+
summary: "SPF could not be evaluated during the limited fallback checks.",
|
|
376
|
+
},
|
|
377
|
+
dmarc: {
|
|
378
|
+
status: "missing",
|
|
379
|
+
policy: null,
|
|
380
|
+
subdomainPolicy: null,
|
|
381
|
+
pct: null,
|
|
382
|
+
reporting: false,
|
|
383
|
+
summary: "DMARC could not be evaluated during the limited fallback checks.",
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
mtaSts: { dns: null, policyUrl: null, policy: null },
|
|
387
|
+
issues: [],
|
|
388
|
+
strengths: [],
|
|
389
|
+
}));
|
|
390
|
+
const limitedResult = {
|
|
391
|
+
inputUrl: input,
|
|
392
|
+
normalizedUrl: normalizedInput.toString(),
|
|
393
|
+
finalUrl: normalizedInput.toString(),
|
|
394
|
+
host: normalizedInput.host,
|
|
395
|
+
scannedAt: new Date().toISOString(),
|
|
396
|
+
responseTimeMs: 0,
|
|
397
|
+
statusCode: 0,
|
|
398
|
+
score: 0,
|
|
399
|
+
grade: "U",
|
|
400
|
+
summary: "Assessment is limited because the target could not be reached or trusted cleanly.",
|
|
401
|
+
headers: [],
|
|
402
|
+
rawHeaders: {},
|
|
403
|
+
cookies: [],
|
|
404
|
+
cookieAnalysis: null,
|
|
405
|
+
technologies: [],
|
|
406
|
+
certificate: {
|
|
407
|
+
...emptyCertificate(),
|
|
408
|
+
available: normalizedInput.protocol === "https:",
|
|
409
|
+
subject: normalizedInput.hostname,
|
|
410
|
+
issues: [failure.detail],
|
|
411
|
+
},
|
|
412
|
+
redirects: [],
|
|
413
|
+
redirectChain: analyzeRedirectChain(normalizedInput, normalizedInput, []),
|
|
414
|
+
issues: [fallbackIssue],
|
|
415
|
+
strengths: [],
|
|
416
|
+
remediation: [],
|
|
417
|
+
crawl: emptyCrawlSummary(["limited assessment"]),
|
|
418
|
+
securityTxt: emptySecurityTxt(),
|
|
419
|
+
domainSecurity,
|
|
420
|
+
identityProvider: emptyIdentityProvider(),
|
|
421
|
+
ctDiscovery: {
|
|
422
|
+
queriedDomain: normalizedInput.hostname,
|
|
423
|
+
sourceUrl: `https://crt.sh/?q=%25.${normalizedInput.hostname}&output=json`,
|
|
424
|
+
subdomains: [],
|
|
425
|
+
wildcardEntries: [],
|
|
426
|
+
prioritizedHosts: [],
|
|
427
|
+
sampledHosts: [],
|
|
428
|
+
coverageSummary: "Certificate transparency discovery was skipped because the primary assessment could not complete cleanly.",
|
|
429
|
+
issues: [],
|
|
430
|
+
strengths: [],
|
|
431
|
+
},
|
|
432
|
+
htmlSecurity: fallbackHtmlSecurity,
|
|
433
|
+
aiSurface: fallbackHtmlSecurity.aiSurface,
|
|
434
|
+
thirdPartyTrust: {
|
|
435
|
+
totalProviders: 0,
|
|
436
|
+
highRiskProviders: 0,
|
|
437
|
+
providers: [],
|
|
438
|
+
issues: [],
|
|
439
|
+
strengths: [],
|
|
440
|
+
summary: "Third-party trust could not be assessed from a limited scan.",
|
|
441
|
+
},
|
|
442
|
+
infrastructure: {
|
|
443
|
+
host: normalizedInput.hostname,
|
|
444
|
+
addresses: [],
|
|
445
|
+
cnameTargets: [],
|
|
446
|
+
reverseDns: [],
|
|
447
|
+
providers: [],
|
|
448
|
+
issues: [],
|
|
449
|
+
strengths: [],
|
|
450
|
+
summary: "Infrastructure attribution was not completed because the primary response could not be fetched cleanly.",
|
|
451
|
+
},
|
|
452
|
+
passiveIntelligence: emptyPassiveIntelligence("Passive intelligence was limited because the primary response could not be fetched cleanly."),
|
|
453
|
+
compromiseSignals: emptyCompromiseSignals("Compromise and abuse indicators were limited because the primary response could not be fetched cleanly."),
|
|
454
|
+
executiveSummary: {
|
|
455
|
+
overview: failure.kind === "service_unavailable"
|
|
456
|
+
? "The scanner could not obtain a stable response from the target, so this is only a limited availability read."
|
|
457
|
+
: "The scanner could not establish a trusted connection to the target, so this is only a limited transport read.",
|
|
458
|
+
mainRisk: failure.kind === "service_unavailable"
|
|
459
|
+
? "Availability or reachability issues prevented a normal posture assessment."
|
|
460
|
+
: "TLS trust or certificate issues prevented a normal posture assessment.",
|
|
461
|
+
posture: "weak",
|
|
462
|
+
takeaways: [
|
|
463
|
+
failure.detail,
|
|
464
|
+
domainSecurity.issues.length > 0
|
|
465
|
+
? `${domainSecurity.issues.length} domain or mail-hygiene issue${domainSecurity.issues.length === 1 ? "" : "s"} were still detectable without a full page read.`
|
|
466
|
+
: "No additional DNS or mail-hygiene issues were inferred from the limited fallback checks.",
|
|
467
|
+
publicSignals.issues.length > 0
|
|
468
|
+
? `${publicSignals.issues.length} public trust signal${publicSignals.issues.length === 1 ? " was" : "s were"} still observable.`
|
|
469
|
+
: "Public preload and trust signals could not materially improve this limited read.",
|
|
470
|
+
],
|
|
471
|
+
},
|
|
472
|
+
scoreDrivers: [
|
|
473
|
+
{
|
|
474
|
+
areaKey: "overall",
|
|
475
|
+
areaLabel: "Overall posture",
|
|
476
|
+
impact: 100,
|
|
477
|
+
label: "Limited assessment",
|
|
478
|
+
detail: failure.detail,
|
|
479
|
+
source: "assessment_limit",
|
|
480
|
+
},
|
|
481
|
+
],
|
|
482
|
+
assessmentLimitation: {
|
|
483
|
+
limited: true,
|
|
484
|
+
kind: failure.kind,
|
|
485
|
+
title: failure.title,
|
|
486
|
+
detail: failure.detail,
|
|
487
|
+
},
|
|
488
|
+
scanTiming,
|
|
489
|
+
exposure: emptyExposure(),
|
|
490
|
+
corsSecurity: emptyCorsSecurity(),
|
|
491
|
+
apiSurface: emptyApiSurface(),
|
|
492
|
+
publicSignals,
|
|
493
|
+
wafFingerprint: {
|
|
494
|
+
detected: false,
|
|
495
|
+
providers: [],
|
|
496
|
+
edgeSignals: [],
|
|
497
|
+
issues: [],
|
|
498
|
+
strengths: [],
|
|
499
|
+
summary: "Edge-protection fingerprinting was not completed because the primary response could not be fetched cleanly.",
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
const evidenceResult = attachIssueEvidence(limitedResult);
|
|
503
|
+
return {
|
|
504
|
+
...evidenceResult,
|
|
505
|
+
remediationPlan: buildPostureRemediationPlan(evidenceResult),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
async function enrichCoreResult(result, profile) {
|
|
509
|
+
const finalUrl = new URL(result.finalUrl);
|
|
510
|
+
const ctDiscoveryPromise = fetchCtDiscovery(result.host, requestJson, requestText, {
|
|
511
|
+
sampleHosts: profile.pageAnalysisEnabled,
|
|
512
|
+
subdomainLimit: profile.ctSubdomainLimit,
|
|
513
|
+
wildcardLimit: profile.ctWildcardLimit,
|
|
514
|
+
sampleLimit: profile.ctSampleLimit,
|
|
515
|
+
});
|
|
516
|
+
const { htmlDocument, htmlSecurity } = await analyzeHtmlSecuritySignals(finalUrl, profile.pageAnalysisEnabled);
|
|
517
|
+
const thirdPartyTrust = analyzeThirdPartyTrust(finalUrl, htmlSecurity, htmlSecurity.aiSurface);
|
|
518
|
+
const technologies = mergeTechnologies(result.technologies, htmlSecurity.detectedTechnologies);
|
|
519
|
+
const secondaryRequestText = (targetUrl, extraHeaders = {}) => requestText(targetUrl, extraHeaders, { timeoutMs: SECONDARY_REQUEST_TIMEOUT_MS });
|
|
520
|
+
const secondaryRequestOnce = (targetUrl, method = "HEAD") => requestOnce(targetUrl, method, { timeoutMs: SECONDARY_REQUEST_TIMEOUT_MS });
|
|
521
|
+
const secondaryRequestWithHeaders = (targetUrl, method = "HEAD", extraHeaders = {}) => requestWithHeaders(targetUrl, method, extraHeaders, { timeoutMs: SECONDARY_REQUEST_TIMEOUT_MS });
|
|
522
|
+
const secondaryFetchWithRedirects = (targetUrl, redirectLimit) => fetchWithRedirects(targetUrl, redirectLimit, { timeoutMs: SECONDARY_REQUEST_TIMEOUT_MS });
|
|
523
|
+
// Batch 1: fast/non-network tasks plus the two heaviest long-running ones
|
|
524
|
+
const [discovery, publicSignals, infrastructure, ctDiscovery, domainSecurity, securityTxt,] = await Promise.all([
|
|
525
|
+
profile.pageAnalysisEnabled
|
|
526
|
+
? collectDiscoveryPaths(finalUrl, htmlSecurity, secondaryRequestText)
|
|
527
|
+
: Promise.resolve({ paths: [], sources: ["quiet mode"] }),
|
|
528
|
+
fetchPublicSignals(result.host, { requestText }),
|
|
529
|
+
analyzeInfrastructure(finalUrl, result.rawHeaders, technologies),
|
|
530
|
+
ctDiscoveryPromise,
|
|
531
|
+
analyzeDomainSecurity(result.host, secondaryRequestText),
|
|
532
|
+
profile.pageAnalysisEnabled ? fetchSecurityTxt(finalUrl, secondaryRequestText) : Promise.resolve(emptySecurityTxt()),
|
|
533
|
+
]);
|
|
534
|
+
// Batch 2: tasks that depend on batch-1 results or do additional network probing
|
|
535
|
+
const [identityProvider, crawl, exposure, corsSecurity, apiSurface,] = await Promise.all([
|
|
536
|
+
profile.pageAnalysisEnabled
|
|
537
|
+
? analyzeIdentityProvider(finalUrl, result.redirects, htmlSecurity, htmlDocument?.html || null, requestJson, ctDiscovery)
|
|
538
|
+
: Promise.resolve(emptyIdentityProvider()),
|
|
539
|
+
profile.pageAnalysisEnabled ? crawlRelatedPages(result, discovery, profile.crawlPageLimit) : Promise.resolve(emptyCrawlSummary(discovery.sources)),
|
|
540
|
+
profile.pageAnalysisEnabled
|
|
541
|
+
? analyzeExposure(finalUrl, htmlDocument, {
|
|
542
|
+
exposureProbes: profile.exposureProbes,
|
|
543
|
+
requestOnce: secondaryRequestOnce,
|
|
544
|
+
requestText: secondaryRequestText,
|
|
545
|
+
fetchWithRedirects: secondaryFetchWithRedirects,
|
|
546
|
+
headerValue,
|
|
547
|
+
formatErrorMessage,
|
|
548
|
+
isAccessDeniedHtml,
|
|
549
|
+
classifyHtmlApiFallback,
|
|
550
|
+
})
|
|
551
|
+
: Promise.resolve(emptyExposure()),
|
|
552
|
+
profile.pageAnalysisEnabled
|
|
553
|
+
? analyzeCorsSecurity(finalUrl, result.rawHeaders, {
|
|
554
|
+
requestWithHeaders: secondaryRequestWithHeaders,
|
|
555
|
+
headerValue,
|
|
556
|
+
})
|
|
557
|
+
: Promise.resolve(emptyCorsSecurity()),
|
|
558
|
+
profile.pageAnalysisEnabled
|
|
559
|
+
? analyzeApiSurface(finalUrl, htmlDocument, {
|
|
560
|
+
apiSurfaceProbes: profile.apiSurfaceProbes,
|
|
561
|
+
requestText: secondaryRequestText,
|
|
562
|
+
fetchWithRedirects: secondaryFetchWithRedirects,
|
|
563
|
+
headerValue,
|
|
564
|
+
isAccessDeniedHtml,
|
|
565
|
+
classifyHtmlApiFallback,
|
|
566
|
+
})
|
|
567
|
+
: Promise.resolve(emptyApiSurface()),
|
|
568
|
+
]);
|
|
569
|
+
const wafFingerprint = analyzeWafFingerprint(finalUrl, result.rawHeaders, htmlDocument?.html || null, result.redirects);
|
|
570
|
+
const assessmentLimitation = detectAssessmentLimitation(result.statusCode, result.rawHeaders, htmlDocument?.html || null);
|
|
571
|
+
const passiveIntelligence = buildPassiveIntelligence({
|
|
572
|
+
technologies,
|
|
573
|
+
infrastructure,
|
|
574
|
+
thirdPartyTrust,
|
|
575
|
+
htmlSecurity,
|
|
576
|
+
aiSurface: htmlSecurity.aiSurface,
|
|
577
|
+
domainSecurity,
|
|
578
|
+
securityTxt,
|
|
579
|
+
publicSignals,
|
|
580
|
+
identityProvider,
|
|
581
|
+
wafFingerprint,
|
|
582
|
+
apiSurface,
|
|
583
|
+
assessmentLimitation,
|
|
584
|
+
});
|
|
585
|
+
const compromiseSignals = buildCompromiseSignals({
|
|
586
|
+
finalUrl,
|
|
587
|
+
htmlSecurity,
|
|
588
|
+
ctDiscovery,
|
|
589
|
+
exposure,
|
|
590
|
+
});
|
|
591
|
+
return {
|
|
592
|
+
...result,
|
|
593
|
+
issues: [...result.issues, ...buildLibraryRiskIssues(htmlSecurity.libraryRiskSignals).map(classifyIssueTaxonomy)],
|
|
594
|
+
technologies,
|
|
595
|
+
crawl,
|
|
596
|
+
securityTxt,
|
|
597
|
+
domainSecurity,
|
|
598
|
+
identityProvider,
|
|
599
|
+
ctDiscovery,
|
|
600
|
+
htmlSecurity,
|
|
601
|
+
aiSurface: htmlSecurity.aiSurface,
|
|
602
|
+
thirdPartyTrust,
|
|
603
|
+
infrastructure,
|
|
604
|
+
passiveIntelligence,
|
|
605
|
+
compromiseSignals,
|
|
606
|
+
wafFingerprint,
|
|
607
|
+
exposure,
|
|
608
|
+
corsSecurity,
|
|
609
|
+
apiSurface,
|
|
610
|
+
publicSignals,
|
|
611
|
+
assessmentLimitation,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
function toCandidateLabel(pathname) {
|
|
615
|
+
if (pathname === "/") {
|
|
616
|
+
return "Homepage";
|
|
617
|
+
}
|
|
618
|
+
const segments = pathname
|
|
619
|
+
.split("?")[0]
|
|
620
|
+
.split("/")
|
|
621
|
+
.filter(Boolean)
|
|
622
|
+
.map((segment) => decodeURIComponent(segment).replace(/[-_]+/g, " ").trim())
|
|
623
|
+
.filter(Boolean);
|
|
624
|
+
const uniqueSegments = segments.filter((segment, index) => {
|
|
625
|
+
return index === 0 || segment.toLowerCase() !== segments[index - 1].toLowerCase();
|
|
626
|
+
});
|
|
627
|
+
const preferredSegments = uniqueSegments.length <= 2
|
|
628
|
+
? uniqueSegments
|
|
629
|
+
: [uniqueSegments[0], uniqueSegments[uniqueSegments.length - 1]];
|
|
630
|
+
const label = preferredSegments
|
|
631
|
+
.map((segment) => segment
|
|
632
|
+
.split(/\s+/)
|
|
633
|
+
.slice(0, 3)
|
|
634
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
635
|
+
.join(" "))
|
|
636
|
+
.join(" / ");
|
|
637
|
+
return label.length > 42 ? `${label.slice(0, 39).trimEnd()}...` : label;
|
|
638
|
+
}
|
|
639
|
+
function buildCrawlCandidates(result, discoveryPaths = [], limit = CRAWL_PAGE_LIMIT) {
|
|
640
|
+
const finalUrl = new URL(result.finalUrl);
|
|
641
|
+
const userPath = new URL(result.normalizedUrl).pathname || "/";
|
|
642
|
+
const seen = new Set();
|
|
643
|
+
return [
|
|
644
|
+
{ label: userPath === "/" ? "Homepage" : "Requested page", path: userPath },
|
|
645
|
+
...discoveryPaths.map((path) => ({ label: toCandidateLabel(path), path })),
|
|
646
|
+
...CRAWL_CANDIDATES,
|
|
647
|
+
]
|
|
648
|
+
.map((candidate) => {
|
|
649
|
+
const url = new URL(candidate.path, finalUrl.origin);
|
|
650
|
+
return {
|
|
651
|
+
label: candidate.label,
|
|
652
|
+
path: url.pathname,
|
|
653
|
+
url,
|
|
654
|
+
};
|
|
655
|
+
})
|
|
656
|
+
.filter((candidate) => {
|
|
657
|
+
const key = candidate.path;
|
|
658
|
+
if (seen.has(key)) {
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
seen.add(key);
|
|
662
|
+
return true;
|
|
663
|
+
})
|
|
664
|
+
.slice(0, limit);
|
|
665
|
+
}
|
|
666
|
+
function summarizePageAnalysis(label, path, pageResult, rootHost) {
|
|
667
|
+
const sameOrigin = new URL(pageResult.finalUrl).hostname === rootHost;
|
|
668
|
+
return {
|
|
669
|
+
label,
|
|
670
|
+
path,
|
|
671
|
+
finalUrl: pageResult.finalUrl,
|
|
672
|
+
sameOrigin,
|
|
673
|
+
statusCode: pageResult.statusCode,
|
|
674
|
+
responseTimeMs: pageResult.responseTimeMs,
|
|
675
|
+
score: sameOrigin ? pageResult.score : 0,
|
|
676
|
+
grade: sameOrigin ? pageResult.grade : "Redirected",
|
|
677
|
+
missingHeaders: sameOrigin ? pageResult.headers
|
|
678
|
+
.filter((header) => header.status === "missing")
|
|
679
|
+
.map((header) => header.label) : [],
|
|
680
|
+
warningHeaders: sameOrigin ? pageResult.headers
|
|
681
|
+
.filter((header) => header.status === "warning")
|
|
682
|
+
.map((header) => header.label) : [],
|
|
683
|
+
issueCount: sameOrigin ? pageResult.issues.length : 1,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
async function crawlRelatedPages(rootResult, discovery, pageLimit = CRAWL_PAGE_LIMIT) {
|
|
687
|
+
const candidates = buildCrawlCandidates(rootResult, discovery.paths, pageLimit);
|
|
688
|
+
const rootHost = new URL(rootResult.finalUrl).hostname;
|
|
689
|
+
const pages = await mapWithConcurrency(candidates, CRAWL_CONCURRENCY_LIMIT, async (candidate) => {
|
|
690
|
+
try {
|
|
691
|
+
const pageResult = await analyzeUrlCore(candidate.url, {
|
|
692
|
+
includeCertificate: false,
|
|
693
|
+
requestTimeoutMs: SECONDARY_REQUEST_TIMEOUT_MS,
|
|
694
|
+
});
|
|
695
|
+
return summarizePageAnalysis(candidate.label, candidate.path, pageResult, rootHost);
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
return {
|
|
699
|
+
label: candidate.label,
|
|
700
|
+
path: candidate.path,
|
|
701
|
+
finalUrl: candidate.url.toString(),
|
|
702
|
+
sameOrigin: true,
|
|
703
|
+
statusCode: 0,
|
|
704
|
+
responseTimeMs: 0,
|
|
705
|
+
score: 0,
|
|
706
|
+
grade: "F",
|
|
707
|
+
missingHeaders: SECURITY_HEADERS.map((header) => header.label),
|
|
708
|
+
warningHeaders: [],
|
|
709
|
+
issueCount: 1,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
const comparablePages = pages.filter((page) => page.sameOrigin);
|
|
714
|
+
const strongestPage = comparablePages.length
|
|
715
|
+
? comparablePages.reduce((best, page) => (page.score > best.score ? page : best), comparablePages[0]).label
|
|
716
|
+
: null;
|
|
717
|
+
const weakestPage = comparablePages.length
|
|
718
|
+
? comparablePages.reduce((worst, page) => (page.score < worst.score ? page : worst), comparablePages[0]).label
|
|
719
|
+
: null;
|
|
720
|
+
const headerMap = new Map();
|
|
721
|
+
for (const page of comparablePages) {
|
|
722
|
+
for (const header of SECURITY_HEADERS) {
|
|
723
|
+
const status = page.missingHeaders.includes(header.label)
|
|
724
|
+
? "missing"
|
|
725
|
+
: page.warningHeaders.includes(header.label)
|
|
726
|
+
? "warning"
|
|
727
|
+
: "present";
|
|
728
|
+
const existing = headerMap.get(header.label) || new Set();
|
|
729
|
+
existing.add(status);
|
|
730
|
+
headerMap.set(header.label, existing);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
const inconsistentHeaders = [...headerMap.entries()]
|
|
734
|
+
.filter(([, states]) => states.size > 1)
|
|
735
|
+
.map(([label]) => label);
|
|
736
|
+
return {
|
|
737
|
+
pages,
|
|
738
|
+
strongestPage,
|
|
739
|
+
weakestPage,
|
|
740
|
+
inconsistentHeaders,
|
|
741
|
+
discoverySources: discovery.sources,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
const emptyCrawlSummary = (sources = []) => ({
|
|
745
|
+
pages: [],
|
|
746
|
+
weakestPage: null,
|
|
747
|
+
strongestPage: null,
|
|
748
|
+
inconsistentHeaders: [],
|
|
749
|
+
discoverySources: sources,
|
|
750
|
+
});
|
|
751
|
+
const emptySecurityTxt = () => ({
|
|
752
|
+
status: "missing",
|
|
753
|
+
url: null,
|
|
754
|
+
contact: [],
|
|
755
|
+
expires: null,
|
|
756
|
+
isExpired: false,
|
|
757
|
+
policy: null,
|
|
758
|
+
acknowledgments: null,
|
|
759
|
+
encryption: [],
|
|
760
|
+
hiring: [],
|
|
761
|
+
preferredLanguages: null,
|
|
762
|
+
canonical: [],
|
|
763
|
+
raw: null,
|
|
764
|
+
issues: [],
|
|
765
|
+
strengths: [],
|
|
766
|
+
});
|
|
767
|
+
const emptyIdentityProvider = () => ({
|
|
768
|
+
detected: false,
|
|
769
|
+
provider: null,
|
|
770
|
+
protocol: null,
|
|
771
|
+
redirectOrigins: [],
|
|
772
|
+
authHostCandidates: [],
|
|
773
|
+
loginPaths: [],
|
|
774
|
+
openIdConfigurationUrl: null,
|
|
775
|
+
wellKnownEndpoints: [],
|
|
776
|
+
issuer: null,
|
|
777
|
+
authorizationEndpoint: null,
|
|
778
|
+
tokenEndpoint: null,
|
|
779
|
+
endSessionEndpoint: null,
|
|
780
|
+
redirectUriSignals: [],
|
|
781
|
+
tenantBrand: null,
|
|
782
|
+
tenantRegion: null,
|
|
783
|
+
tenantSignals: [],
|
|
784
|
+
issues: [],
|
|
785
|
+
strengths: [],
|
|
786
|
+
});
|
|
787
|
+
const emptyExposure = () => ({
|
|
788
|
+
probes: [],
|
|
789
|
+
issues: [],
|
|
790
|
+
strengths: [],
|
|
791
|
+
});
|
|
792
|
+
const emptyCorsSecurity = () => ({
|
|
793
|
+
allowedOrigin: null,
|
|
794
|
+
allowCredentials: null,
|
|
795
|
+
allowMethods: [],
|
|
796
|
+
allowHeaders: [],
|
|
797
|
+
allowPrivateNetwork: null,
|
|
798
|
+
vary: null,
|
|
799
|
+
optionsStatus: 0,
|
|
800
|
+
issues: [],
|
|
801
|
+
strengths: [],
|
|
802
|
+
});
|
|
803
|
+
const emptyApiSurface = () => ({
|
|
804
|
+
probes: [],
|
|
805
|
+
issues: [],
|
|
806
|
+
strengths: [],
|
|
807
|
+
});
|
|
808
|
+
const emptyPublicSignals = (host) => ({
|
|
809
|
+
hstsPreload: {
|
|
810
|
+
status: "unknown",
|
|
811
|
+
summary: "Public HSTS preload status could not be determined before the scan timeout.",
|
|
812
|
+
sourceUrl: `https://hstspreload.org/api/v2/status?domain=${encodeURIComponent(host)}`,
|
|
813
|
+
},
|
|
814
|
+
issues: [],
|
|
815
|
+
strengths: [],
|
|
816
|
+
});
|
|
817
|
+
function buildTimedOutEnrichmentResult(result, pageAnalysisEnabled, timeoutMs, coreMs) {
|
|
818
|
+
const finalUrl = new URL(result.finalUrl);
|
|
819
|
+
const fallbackHtmlSecurity = analyzeHtmlSecurity(finalUrl, null);
|
|
820
|
+
const timeoutIssue = classifyIssueTaxonomy({
|
|
821
|
+
severity: "info",
|
|
822
|
+
area: "transport",
|
|
823
|
+
title: "Secondary evidence collection timed out",
|
|
824
|
+
detail: `The primary response was assessed, but secondary enrichment exceeded the ${Math.round(timeoutMs / 1000)} second scan budget. Treat crawl, discovery, and passive enrichment sections as partial for this run.`,
|
|
825
|
+
confidence: "high",
|
|
826
|
+
source: "observed",
|
|
827
|
+
owasp: [],
|
|
828
|
+
mitre: [],
|
|
829
|
+
});
|
|
830
|
+
const timedOutResult = {
|
|
831
|
+
...result,
|
|
832
|
+
issues: [...result.issues, timeoutIssue],
|
|
833
|
+
crawl: emptyCrawlSummary(pageAnalysisEnabled ? ["scan timeout"] : ["quiet mode"]),
|
|
834
|
+
securityTxt: emptySecurityTxt(),
|
|
835
|
+
domainSecurity: {
|
|
836
|
+
host: result.host,
|
|
837
|
+
mxRecords: [],
|
|
838
|
+
nsRecords: [],
|
|
839
|
+
caaRecords: [],
|
|
840
|
+
dnssec: { enabled: false, dsRecords: [], status: "unknown" },
|
|
841
|
+
spf: null,
|
|
842
|
+
dmarc: null,
|
|
843
|
+
emailPolicy: {
|
|
844
|
+
spf: {
|
|
845
|
+
status: "missing",
|
|
846
|
+
allMechanism: null,
|
|
847
|
+
dnsLookupMechanisms: 0,
|
|
848
|
+
summary: "SPF was not evaluated before the scan timeout.",
|
|
849
|
+
},
|
|
850
|
+
dmarc: {
|
|
851
|
+
status: "missing",
|
|
852
|
+
policy: null,
|
|
853
|
+
subdomainPolicy: null,
|
|
854
|
+
pct: null,
|
|
855
|
+
reporting: false,
|
|
856
|
+
summary: "DMARC was not evaluated before the scan timeout.",
|
|
857
|
+
},
|
|
858
|
+
},
|
|
859
|
+
mtaSts: { dns: null, policyUrl: null, policy: null },
|
|
860
|
+
issues: [],
|
|
861
|
+
strengths: [],
|
|
862
|
+
},
|
|
863
|
+
identityProvider: emptyIdentityProvider(),
|
|
864
|
+
ctDiscovery: {
|
|
865
|
+
queriedDomain: result.host,
|
|
866
|
+
sourceUrl: `https://crt.sh/?q=%25.${result.host}&output=json`,
|
|
867
|
+
subdomains: [],
|
|
868
|
+
wildcardEntries: [],
|
|
869
|
+
prioritizedHosts: [],
|
|
870
|
+
sampledHosts: [],
|
|
871
|
+
coverageSummary: "Certificate transparency discovery did not complete before the scan timeout.",
|
|
872
|
+
issues: [],
|
|
873
|
+
strengths: [],
|
|
874
|
+
},
|
|
875
|
+
htmlSecurity: fallbackHtmlSecurity,
|
|
876
|
+
aiSurface: fallbackHtmlSecurity.aiSurface,
|
|
877
|
+
thirdPartyTrust: {
|
|
878
|
+
totalProviders: 0,
|
|
879
|
+
highRiskProviders: 0,
|
|
880
|
+
providers: [],
|
|
881
|
+
issues: [],
|
|
882
|
+
strengths: [],
|
|
883
|
+
summary: "Third-party trust could not be fully assessed before the scan timeout.",
|
|
884
|
+
},
|
|
885
|
+
infrastructure: {
|
|
886
|
+
host: result.host,
|
|
887
|
+
addresses: [],
|
|
888
|
+
cnameTargets: [],
|
|
889
|
+
reverseDns: [],
|
|
890
|
+
providers: [],
|
|
891
|
+
issues: [],
|
|
892
|
+
strengths: [],
|
|
893
|
+
summary: "Infrastructure attribution did not complete before the scan timeout.",
|
|
894
|
+
},
|
|
895
|
+
passiveIntelligence: emptyPassiveIntelligence("Passive intelligence did not complete before the scan timeout."),
|
|
896
|
+
compromiseSignals: emptyCompromiseSignals("Compromise and abuse indicators did not complete before the scan timeout."),
|
|
897
|
+
exposure: emptyExposure(),
|
|
898
|
+
corsSecurity: emptyCorsSecurity(),
|
|
899
|
+
apiSurface: emptyApiSurface(),
|
|
900
|
+
publicSignals: emptyPublicSignals(result.host),
|
|
901
|
+
wafFingerprint: analyzeWafFingerprint(finalUrl, result.rawHeaders, null, result.redirects),
|
|
902
|
+
scoreDrivers: [
|
|
903
|
+
{
|
|
904
|
+
areaKey: "overall",
|
|
905
|
+
areaLabel: "Overall posture",
|
|
906
|
+
impact: 20,
|
|
907
|
+
label: "Secondary enrichment timeout",
|
|
908
|
+
detail: "The primary response was scored, but secondary enrichment did not complete within the scan budget.",
|
|
909
|
+
source: "assessment_limit",
|
|
910
|
+
},
|
|
911
|
+
],
|
|
912
|
+
assessmentLimitation: {
|
|
913
|
+
limited: true,
|
|
914
|
+
kind: "other",
|
|
915
|
+
title: "Assessment limited by scan timeout",
|
|
916
|
+
detail: "The primary page response was assessed, but secondary enrichment did not complete within the scan budget.",
|
|
917
|
+
},
|
|
918
|
+
scanTiming: {
|
|
919
|
+
totalMs: timeoutMs,
|
|
920
|
+
coreMs,
|
|
921
|
+
enrichmentMs: Math.max(0, timeoutMs - coreMs),
|
|
922
|
+
timedOut: true,
|
|
923
|
+
timeoutMs,
|
|
924
|
+
},
|
|
925
|
+
};
|
|
926
|
+
const timedOutResultWithSummary = {
|
|
927
|
+
...timedOutResult,
|
|
928
|
+
executiveSummary: buildExecutiveSummary(timedOutResult),
|
|
929
|
+
};
|
|
930
|
+
const evidenceResult = attachIssueEvidence(timedOutResultWithSummary);
|
|
931
|
+
return {
|
|
932
|
+
...evidenceResult,
|
|
933
|
+
remediationPlan: buildPostureRemediationPlan(evidenceResult),
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
export async function analyzeUrl(input, options = {}) {
|
|
937
|
+
const scanStartedAt = Date.now();
|
|
938
|
+
const scanMode = options.scanMode || "standard";
|
|
939
|
+
const profile = buildScanProfile(scanMode, options.maxScanDurationMs);
|
|
940
|
+
const normalizedInput = normalizeUrl(input);
|
|
941
|
+
const maxScanDurationMs = profile.scanTimeoutMs;
|
|
942
|
+
const requestTimeoutMs = Math.min(options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS, maxScanDurationMs);
|
|
943
|
+
let result;
|
|
944
|
+
try {
|
|
945
|
+
result = await withTimeout(analyzeUrlCore(normalizedInput, {
|
|
946
|
+
...options,
|
|
947
|
+
includeCertificate: true,
|
|
948
|
+
requestTimeoutMs,
|
|
949
|
+
}), maxScanDurationMs, "Primary scan timed out.");
|
|
950
|
+
}
|
|
951
|
+
catch (error) {
|
|
952
|
+
const failure = classifyAssessmentFailure(error);
|
|
953
|
+
const elapsedMs = Date.now() - scanStartedAt;
|
|
954
|
+
return buildLimitedResult(input, normalizedInput, failure, {
|
|
955
|
+
totalMs: elapsedMs,
|
|
956
|
+
coreMs: elapsedMs,
|
|
957
|
+
enrichmentMs: 0,
|
|
958
|
+
timedOut: error instanceof Error && error.message === "Primary scan timed out.",
|
|
959
|
+
timeoutMs: maxScanDurationMs,
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
const coreMs = Date.now() - scanStartedAt;
|
|
963
|
+
const remainingMs = Math.max(1, maxScanDurationMs - coreMs);
|
|
964
|
+
let enrichedResult;
|
|
965
|
+
try {
|
|
966
|
+
enrichedResult = await withTimeout(enrichCoreResult(result, profile), remainingMs, "Secondary scan enrichment timed out.");
|
|
967
|
+
}
|
|
968
|
+
catch (error) {
|
|
969
|
+
if (error instanceof Error && error.message === "Secondary scan enrichment timed out.") {
|
|
970
|
+
return buildTimedOutEnrichmentResult(result, profile.pageAnalysisEnabled, maxScanDurationMs, coreMs);
|
|
971
|
+
}
|
|
972
|
+
throw error;
|
|
973
|
+
}
|
|
974
|
+
const assessmentLimitation = enrichedResult.assessmentLimitation;
|
|
975
|
+
const postureScore = scorePostureAnalysis(enrichedResult);
|
|
976
|
+
const totalMs = Date.now() - scanStartedAt;
|
|
977
|
+
const scoredResult = {
|
|
978
|
+
...enrichedResult,
|
|
979
|
+
score: postureScore.score,
|
|
980
|
+
grade: postureScore.grade,
|
|
981
|
+
scoreDrivers: postureScore.scoreDrivers,
|
|
982
|
+
summary: assessmentLimitation.limited
|
|
983
|
+
? "Assessment is limited because the target returned a blocked or restricted response."
|
|
984
|
+
: summarizePostureGrade(postureScore.grade),
|
|
985
|
+
scanTiming: {
|
|
986
|
+
totalMs,
|
|
987
|
+
coreMs,
|
|
988
|
+
enrichmentMs: Math.max(0, totalMs - coreMs),
|
|
989
|
+
timedOut: false,
|
|
990
|
+
timeoutMs: maxScanDurationMs,
|
|
991
|
+
},
|
|
992
|
+
};
|
|
993
|
+
const resultWithSummary = {
|
|
994
|
+
...scoredResult,
|
|
995
|
+
executiveSummary: buildExecutiveSummary(scoredResult),
|
|
996
|
+
};
|
|
997
|
+
const resultWithEvidence = attachIssueEvidence(resultWithSummary);
|
|
998
|
+
return {
|
|
999
|
+
...resultWithEvidence,
|
|
1000
|
+
remediationPlan: buildPostureRemediationPlan(resultWithEvidence),
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
export const analyzeTarget = analyzeUrl;
|
|
1004
|
+
export { formatErrorMessage };
|
|
1005
|
+
export { buildCompromiseSignals, emptyCompromiseSignals } from "./compromiseSignals.js";
|
|
1006
|
+
export { analyzeInfrastructure } from "./infrastructure.js";
|
|
1007
|
+
export { buildHistoryDiff, buildHistoryDiffFromSnapshots, snapshotFromAnalysis } from "./historyDiff.js";
|
|
1008
|
+
export { assertPublicRequestTarget, isLocalHostname, isPrivateAddress, } from "./network-validation.js";
|