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.
Files changed (74) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/LICENSE +21 -0
  3. package/README.md +427 -0
  4. package/RELEASING.md +37 -0
  5. package/SECURITY.md +27 -0
  6. package/dist/certificate.d.ts +5 -0
  7. package/dist/certificate.js +92 -0
  8. package/dist/cli.d.ts +1 -0
  9. package/dist/cli.js +674 -0
  10. package/dist/compromiseSignals.d.ts +10 -0
  11. package/dist/compromiseSignals.js +183 -0
  12. package/dist/cookie-analysis.d.ts +2 -0
  13. package/dist/cookie-analysis.js +41 -0
  14. package/dist/cookieAnalysis.d.ts +2 -0
  15. package/dist/cookieAnalysis.js +82 -0
  16. package/dist/ctDiscovery.d.ts +19 -0
  17. package/dist/ctDiscovery.js +357 -0
  18. package/dist/domain-security.d.ts +10 -0
  19. package/dist/domain-security.js +416 -0
  20. package/dist/header-analysis.d.ts +14 -0
  21. package/dist/header-analysis.js +165 -0
  22. package/dist/historyDiff.d.ts +4 -0
  23. package/dist/historyDiff.js +117 -0
  24. package/dist/html-extraction.d.ts +12 -0
  25. package/dist/html-extraction.js +279 -0
  26. package/dist/html-page-analysis.d.ts +38 -0
  27. package/dist/html-page-analysis.js +459 -0
  28. package/dist/htmlInsights.d.ts +23 -0
  29. package/dist/htmlInsights.js +460 -0
  30. package/dist/identityProvider.d.ts +14 -0
  31. package/dist/identityProvider.js +259 -0
  32. package/dist/index.d.ts +17 -0
  33. package/dist/index.js +1008 -0
  34. package/dist/infrastructure.d.ts +9 -0
  35. package/dist/infrastructure.js +149 -0
  36. package/dist/libraryRisk.d.ts +3 -0
  37. package/dist/libraryRisk.js +164 -0
  38. package/dist/network-validation.d.ts +30 -0
  39. package/dist/network-validation.js +161 -0
  40. package/dist/network.d.ts +34 -0
  41. package/dist/network.js +139 -0
  42. package/dist/passive-intelligence.d.ts +21 -0
  43. package/dist/passive-intelligence.js +247 -0
  44. package/dist/path-discovery.d.ts +4 -0
  45. package/dist/path-discovery.js +50 -0
  46. package/dist/postureDigest.d.ts +142 -0
  47. package/dist/postureDigest.js +159 -0
  48. package/dist/postureDrift.d.ts +4 -0
  49. package/dist/postureDrift.js +118 -0
  50. package/dist/postureRemediation.d.ts +6 -0
  51. package/dist/postureRemediation.js +286 -0
  52. package/dist/redirectChain.d.ts +2 -0
  53. package/dist/redirectChain.js +39 -0
  54. package/dist/riskEvents.d.ts +3 -0
  55. package/dist/riskEvents.js +187 -0
  56. package/dist/scannerConfig.d.ts +49 -0
  57. package/dist/scannerConfig.js +79 -0
  58. package/dist/scoring.d.ts +32 -0
  59. package/dist/scoring.js +367 -0
  60. package/dist/security-txt.d.ts +4 -0
  61. package/dist/security-txt.js +123 -0
  62. package/dist/surfaceEnrichment.d.ts +44 -0
  63. package/dist/surfaceEnrichment.js +377 -0
  64. package/dist/technology-detection.d.ts +4 -0
  65. package/dist/technology-detection.js +93 -0
  66. package/dist/types.d.ts +730 -0
  67. package/dist/types.js +1 -0
  68. package/dist/utils.d.ts +7 -0
  69. package/dist/utils.js +66 -0
  70. package/dist/wafFingerprint.d.ts +5 -0
  71. package/dist/wafFingerprint.js +156 -0
  72. package/examples/risk-events.mjs +27 -0
  73. package/examples/scan-url.mjs +17 -0
  74. package/package.json +102 -0
@@ -0,0 +1,377 @@
1
+ function sanitiseErrorDetail(msg) {
2
+ // Remove raw IP addresses to avoid leaking internal network topology
3
+ return msg.replace(/\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b/g, "<host>");
4
+ }
5
+ const parseCsvHeader = (value) => value
6
+ ? value
7
+ .split(",")
8
+ .map((part) => part.trim())
9
+ .filter(Boolean)
10
+ : [];
11
+ export const fetchPublicSignals = async (host, deps) => {
12
+ const apexHost = host.startsWith("www.") ? host.slice(4) : host;
13
+ const sourceUrl = `https://hstspreload.org/api/v2/status?domain=${encodeURIComponent(apexHost)}`;
14
+ const fallback = {
15
+ hstsPreload: {
16
+ status: "unknown",
17
+ summary: "Public HSTS preload status could not be determined.",
18
+ sourceUrl,
19
+ },
20
+ issues: [],
21
+ strengths: [],
22
+ };
23
+ try {
24
+ const response = await deps.requestText(new URL(sourceUrl), { Accept: "application/json" });
25
+ if (response.statusCode < 200 || response.statusCode >= 300) {
26
+ return fallback;
27
+ }
28
+ const payload = JSON.parse(response.body);
29
+ const statusText = String(payload.status || payload.result || "").toLowerCase();
30
+ const message = String(payload.message || payload.status || "").trim();
31
+ const errors = Array.isArray(payload.errors) ? payload.errors : [];
32
+ const errorText = errors
33
+ .map((entry) => (typeof entry === "string" ? entry : entry?.message || JSON.stringify(entry)))
34
+ .join(" ");
35
+ let status = "not_preloaded";
36
+ if (payload.preloaded === true || statusText.includes("preloaded")) {
37
+ status = "preloaded";
38
+ }
39
+ else if (statusText.includes("pending")) {
40
+ status = "pending";
41
+ }
42
+ else if (payload.preloadable === true || payload.eligible === true || statusText.includes("eligible")) {
43
+ status = "eligible";
44
+ }
45
+ else if (!statusText && !errorText) {
46
+ status = "unknown";
47
+ }
48
+ const summary = message && message.toLowerCase() !== "unknown"
49
+ ? message
50
+ : errorText ||
51
+ (status === "not_preloaded"
52
+ ? "The domain is not currently shown as preloaded in the public HSTS preload dataset."
53
+ : "HSTS preload status retrieved from the public preload dataset.");
54
+ const issues = [];
55
+ const strengths = [];
56
+ if (status === "preloaded") {
57
+ strengths.push("Domain appears in the public HSTS preload program.");
58
+ }
59
+ else if (status === "pending") {
60
+ strengths.push("Domain appears to have an HSTS preload submission pending.");
61
+ }
62
+ else if (status === "eligible") {
63
+ issues.push("Domain may be eligible for HSTS preload but is not currently shown as preloaded.");
64
+ }
65
+ else if (status === "not_preloaded") {
66
+ issues.push("Domain is not shown as preloaded in the public HSTS preload dataset.");
67
+ }
68
+ return {
69
+ hstsPreload: {
70
+ status,
71
+ summary,
72
+ sourceUrl,
73
+ },
74
+ issues,
75
+ strengths,
76
+ };
77
+ }
78
+ catch {
79
+ return fallback;
80
+ }
81
+ };
82
+ export const analyzeExposure = async (finalUrl, homepageContext, deps) => {
83
+ const probes = [];
84
+ const issues = [];
85
+ const strengths = [];
86
+ let sawErrorProbe = false;
87
+ let sawFrontendFallback = false;
88
+ const homepageSignature = homepageContext?.signature || "";
89
+ const homepageTitle = homepageContext?.pageTitle || null;
90
+ for (const probe of deps.exposureProbes) {
91
+ const probeUrl = new URL(probe.path, finalUrl.origin);
92
+ try {
93
+ let response;
94
+ let resolvedUrl = probeUrl;
95
+ if (probe.path === "/robots.txt" || probe.path === "/sitemap.xml") {
96
+ const redirectData = await deps.fetchWithRedirects(probeUrl, 3);
97
+ response = redirectData.response;
98
+ resolvedUrl = redirectData.finalUrl;
99
+ }
100
+ else {
101
+ response = await deps.requestOnce(probeUrl, "HEAD");
102
+ if (response.statusCode === 405) {
103
+ response = await deps.requestText(probeUrl);
104
+ }
105
+ else if (response.statusCode === 401 || response.statusCode === 403) {
106
+ response = await deps.requestText(probeUrl);
107
+ }
108
+ else if (response.statusCode >= 200 && response.statusCode < 300) {
109
+ response = await deps.requestText(probeUrl);
110
+ }
111
+ }
112
+ let finding = "safe";
113
+ let detail = "Not exposed.";
114
+ if (probe.path === "/robots.txt" || probe.path === "/sitemap.xml") {
115
+ if (response.statusCode >= 200 && response.statusCode < 300) {
116
+ finding = "interesting";
117
+ detail =
118
+ resolvedUrl.toString() === probeUrl.toString()
119
+ ? "Public discovery file is available."
120
+ : `Public discovery file is available after redirect to ${resolvedUrl.toString()}.`;
121
+ strengths.push(`${probe.label} is published.`);
122
+ }
123
+ else if (response.statusCode === 401 || response.statusCode === 403) {
124
+ finding = "interesting";
125
+ detail = "Discovery file exists but is access-controlled.";
126
+ }
127
+ else if (response.statusCode >= 500) {
128
+ finding = "error";
129
+ detail = "Discovery file path triggered a server-side error, so availability could not be determined cleanly.";
130
+ }
131
+ else {
132
+ detail = "Discovery file not found.";
133
+ }
134
+ }
135
+ else if (response.statusCode >= 200 && response.statusCode < 300) {
136
+ const contentType = deps.headerValue(response.headers, "content-type") || "";
137
+ const looksLikeFrontendFallback = "body" in response &&
138
+ typeof response.body === "string" &&
139
+ contentType.includes("text/html") &&
140
+ deps.classifyHtmlApiFallback(probe.path, finalUrl, resolvedUrl, response.body, homepageSignature, homepageTitle);
141
+ if (looksLikeFrontendFallback) {
142
+ finding = "interesting";
143
+ detail = "Path appears to return the site's standard frontend shell rather than sensitive file contents.";
144
+ sawFrontendFallback = true;
145
+ }
146
+ else {
147
+ finding = "exposed";
148
+ detail = "Sensitive path returned a successful response.";
149
+ issues.push(`${probe.label} may be exposed at ${probe.path}.`);
150
+ }
151
+ }
152
+ else if (response.statusCode === 401 || response.statusCode === 403) {
153
+ const contentType = deps.headerValue(response.headers, "content-type") || "";
154
+ const blockedByGenericRules = "body" in response &&
155
+ typeof response.body === "string" &&
156
+ contentType.includes("text/html") &&
157
+ deps.isAccessDeniedHtml(response.headers, response.body);
158
+ if (blockedByGenericRules) {
159
+ finding = "blocked";
160
+ detail = "Probe was blocked by generic server or edge protection rules. This does not confirm the sensitive file exists.";
161
+ strengths.push(`${probe.label} probe was blocked by generic protection.`);
162
+ }
163
+ else {
164
+ finding = "interesting";
165
+ detail = "Sensitive path may exist but is access-controlled.";
166
+ strengths.push(`${probe.label} appears access-controlled.`);
167
+ }
168
+ }
169
+ else if (response.statusCode >= 500) {
170
+ finding = "error";
171
+ detail = "Sensitive path triggered a server-side error, so the path may exist or be handled unexpectedly.";
172
+ sawErrorProbe = true;
173
+ }
174
+ probes.push({
175
+ label: probe.label,
176
+ path: probe.path,
177
+ statusCode: response.statusCode,
178
+ finalUrl: resolvedUrl.toString(),
179
+ finding,
180
+ detail,
181
+ });
182
+ }
183
+ catch (error) {
184
+ probes.push({
185
+ label: probe.label,
186
+ path: probe.path,
187
+ statusCode: 0,
188
+ finalUrl: probeUrl.toString(),
189
+ finding: "error",
190
+ detail: sanitiseErrorDetail(deps.formatErrorMessage(error) || "Probe failed unexpectedly."),
191
+ });
192
+ sawErrorProbe = true;
193
+ }
194
+ }
195
+ if (sawErrorProbe) {
196
+ issues.push("Some sensitive-path probes triggered server-side errors, so exposure could not be ruled out cleanly.");
197
+ }
198
+ if (!issues.length && !sawErrorProbe) {
199
+ strengths.push("No obvious high-signal sensitive files were openly exposed in the limited probe set.");
200
+ }
201
+ if (sawFrontendFallback) {
202
+ strengths.push("Some sensitive-looking paths appear to return the standard frontend shell rather than exposed file contents.");
203
+ }
204
+ return { probes, issues, strengths };
205
+ };
206
+ export const analyzeCorsSecurity = async (finalUrl, responseHeaders, deps) => {
207
+ let optionsResponse = { statusCode: 0, headers: {}, elapsedMs: 0 };
208
+ try {
209
+ optionsResponse = await deps.requestWithHeaders(finalUrl, "OPTIONS", {
210
+ Origin: "https://security-posture-insight.local",
211
+ "Access-Control-Request-Method": "POST",
212
+ "Access-Control-Request-Headers": "content-type,authorization",
213
+ });
214
+ }
215
+ catch {
216
+ // Keep the default empty response if OPTIONS is blocked or errors out.
217
+ }
218
+ const mergedHeaders = {
219
+ ...responseHeaders,
220
+ ...optionsResponse.headers,
221
+ };
222
+ const allowedOrigin = deps.headerValue(mergedHeaders, "access-control-allow-origin");
223
+ const allowCredentials = deps.headerValue(mergedHeaders, "access-control-allow-credentials");
224
+ const allowMethods = parseCsvHeader(deps.headerValue(mergedHeaders, "access-control-allow-methods"));
225
+ const allowHeaders = parseCsvHeader(deps.headerValue(mergedHeaders, "access-control-allow-headers"));
226
+ const allowPrivateNetwork = deps.headerValue(mergedHeaders, "access-control-allow-private-network");
227
+ const vary = deps.headerValue(mergedHeaders, "vary");
228
+ const issues = [];
229
+ const strengths = [];
230
+ if (allowedOrigin === "*") {
231
+ if (allowCredentials?.toLowerCase() === "true") {
232
+ issues.push("CORS allows any origin while also allowing credentials.");
233
+ }
234
+ else {
235
+ issues.push("CORS allows any origin.");
236
+ }
237
+ }
238
+ else if (allowedOrigin) {
239
+ strengths.push(`CORS is scoped to ${allowedOrigin}.`);
240
+ }
241
+ if (allowMethods.includes("PUT") || allowMethods.includes("DELETE") || allowMethods.includes("PATCH")) {
242
+ issues.push(`Preflight allows elevated methods: ${allowMethods.join(", ")}.`);
243
+ }
244
+ if (allowHeaders.includes("*")) {
245
+ issues.push("CORS allows any request header.");
246
+ }
247
+ if (allowPrivateNetwork?.toLowerCase() === "true") {
248
+ issues.push("CORS allows private network access.");
249
+ }
250
+ if (allowedOrigin && allowedOrigin !== "*" && !(vary || "").toLowerCase().includes("origin")) {
251
+ issues.push("CORS varies by origin but the response does not advertise Vary: Origin.");
252
+ }
253
+ if (!allowedOrigin) {
254
+ strengths.push("No permissive CORS policy detected on the scanned page.");
255
+ }
256
+ return {
257
+ allowedOrigin,
258
+ allowCredentials,
259
+ allowMethods,
260
+ allowHeaders,
261
+ allowPrivateNetwork,
262
+ vary,
263
+ optionsStatus: optionsResponse.statusCode,
264
+ issues,
265
+ strengths,
266
+ };
267
+ };
268
+ export const analyzeApiSurface = async (finalUrl, homepageContext, deps) => {
269
+ const probes = [];
270
+ const issues = [];
271
+ const strengths = [];
272
+ let sawErrorProbe = false;
273
+ const homepageSignature = homepageContext?.signature || "";
274
+ const homepageTitle = homepageContext?.pageTitle || null;
275
+ for (const probe of deps.apiSurfaceProbes) {
276
+ const targetUrl = new URL(probe.path, finalUrl.origin);
277
+ try {
278
+ let response = await deps.requestText(targetUrl, {
279
+ Accept: "application/json,text/plain;q=0.9,*/*;q=0.8",
280
+ });
281
+ let resolvedUrl = targetUrl;
282
+ if ([301, 302, 303, 307, 308].includes(response.statusCode) && deps.headerValue(response.headers, "location")) {
283
+ const redirectData = await deps.fetchWithRedirects(targetUrl, 2);
284
+ resolvedUrl = redirectData.finalUrl;
285
+ response = await deps.requestText(resolvedUrl, {
286
+ Accept: "application/json,text/plain;q=0.9,*/*;q=0.8",
287
+ });
288
+ }
289
+ const contentType = deps.headerValue(response.headers, "content-type");
290
+ let classification = "absent";
291
+ let detail = "Endpoint not found.";
292
+ if (response.statusCode === 401 || response.statusCode === 403) {
293
+ classification = "restricted";
294
+ detail = "Endpoint exists but requires authorization or is blocked.";
295
+ strengths.push(`${probe.label} appears access-controlled.`);
296
+ }
297
+ else if (response.statusCode === 405) {
298
+ classification = "interesting";
299
+ detail = "Endpoint appears to exist, but it does not allow the request method used by this probe.";
300
+ }
301
+ else if (response.statusCode === 429) {
302
+ classification = "restricted";
303
+ detail = "Endpoint appears rate-limited, so availability could not be assessed cleanly.";
304
+ }
305
+ else if (response.statusCode === 404) {
306
+ classification = "absent";
307
+ detail = "Endpoint not found.";
308
+ }
309
+ else if (response.statusCode >= 500) {
310
+ classification = "error";
311
+ detail = "Endpoint triggered a server-side error, so the path exists or is handled but did not respond cleanly.";
312
+ sawErrorProbe = true;
313
+ }
314
+ else if (response.statusCode >= 200 && response.statusCode < 300) {
315
+ if ((contentType || "").includes("application/json")) {
316
+ classification = "public";
317
+ detail = "Public JSON-style endpoint responded successfully.";
318
+ issues.push(`${probe.label} appears publicly reachable at ${probe.path}.`);
319
+ }
320
+ else if ((contentType || "").includes("text/html") && deps.isAccessDeniedHtml(response.headers, response.body)) {
321
+ classification = "restricted";
322
+ detail = "Endpoint response appears to be a web application firewall or access-denied page.";
323
+ strengths.push(`${probe.label} appears blocked by edge protection.`);
324
+ }
325
+ else if ((contentType || "").includes("text/html")) {
326
+ classification = "fallback";
327
+ detail = deps.classifyHtmlApiFallback(probe.path, finalUrl, resolvedUrl, response.body, homepageSignature, homepageTitle)
328
+ ? "Endpoint appears to return the site's standard HTML page rather than an API response."
329
+ : "Endpoint returns an HTML page rather than a machine-readable API response.";
330
+ }
331
+ else {
332
+ classification = "interesting";
333
+ detail = "Endpoint responded successfully but does not clearly look like JSON.";
334
+ }
335
+ }
336
+ else if (response.statusCode >= 300 && response.statusCode < 400) {
337
+ classification = "interesting";
338
+ detail = "Endpoint redirected.";
339
+ }
340
+ else if (response.statusCode > 0) {
341
+ classification = "interesting";
342
+ detail = "Endpoint returned a non-success response that may still indicate application handling on this path.";
343
+ }
344
+ probes.push({
345
+ label: probe.label,
346
+ path: probe.path,
347
+ statusCode: response.statusCode,
348
+ finalUrl: resolvedUrl.toString(),
349
+ classification,
350
+ contentType,
351
+ detail,
352
+ });
353
+ }
354
+ catch (error) {
355
+ probes.push({
356
+ label: probe.label,
357
+ path: probe.path,
358
+ statusCode: 0,
359
+ finalUrl: targetUrl.toString(),
360
+ classification: "error",
361
+ contentType: null,
362
+ detail: error instanceof Error ? error.message : "Probe failed.",
363
+ });
364
+ sawErrorProbe = true;
365
+ }
366
+ }
367
+ if (sawErrorProbe) {
368
+ issues.push("Some API-style probes triggered server-side errors, so application handling on those paths deserves review.");
369
+ }
370
+ if (!issues.length && !sawErrorProbe) {
371
+ strengths.push("No obviously public API endpoints were detected in the limited probe set.");
372
+ }
373
+ if (probes.some((probe) => probe.classification === "fallback")) {
374
+ strengths.push("Some API-style paths appear to be frontend route fallbacks rather than exposed APIs.");
375
+ }
376
+ return { probes, issues, strengths };
377
+ };
@@ -0,0 +1,4 @@
1
+ import type { TechnologyResult } from "./types.js";
2
+ type ResponseHeaders = Record<string, string | string[] | undefined>;
3
+ export declare const detectTechnologies: (headers: ResponseHeaders, finalUrl: URL) => TechnologyResult[];
4
+ export {};
@@ -0,0 +1,93 @@
1
+ import { headerValue } from "./utils.js";
2
+ export const detectTechnologies = (headers, finalUrl) => {
3
+ const technologies = [];
4
+ const seen = new Set();
5
+ const addTechnology = (name, category, evidence, version, confidence = "high", detection = "observed") => {
6
+ const key = `${name}:${category}`;
7
+ if (seen.has(key)) {
8
+ return;
9
+ }
10
+ seen.add(key);
11
+ technologies.push({
12
+ name,
13
+ category,
14
+ evidence,
15
+ version: version || null,
16
+ confidence,
17
+ detection,
18
+ });
19
+ };
20
+ const server = headerValue(headers, "server");
21
+ const poweredBy = headerValue(headers, "x-powered-by");
22
+ const cache = headerValue(headers, "cf-cache-status");
23
+ const via = headerValue(headers, "via");
24
+ const classifyServerHeader = (value) => {
25
+ const lower = value.toLowerCase();
26
+ if (lower.includes("cloudflare"))
27
+ return { name: "Cloudflare", category: "network", version: value };
28
+ if (lower.includes("sucuri"))
29
+ return { name: "Sucuri", category: "network", version: value };
30
+ if (lower.includes("akamai"))
31
+ return { name: "Akamai", category: "network", version: value };
32
+ if (lower.includes("fastly"))
33
+ return { name: "Fastly", category: "network", version: value };
34
+ if (lower.includes("nginx"))
35
+ return { name: "Nginx", category: "server", version: value };
36
+ if (lower.includes("apache"))
37
+ return { name: "Apache", category: "server", version: value };
38
+ if (lower.includes("caddy"))
39
+ return { name: "Caddy", category: "server", version: value };
40
+ if (/(gtm|gateway|proxy|edge|cache|router|traffic)/.test(lower)) {
41
+ return { name: value, category: "network", version: null };
42
+ }
43
+ return { name: value, category: "server", version: null };
44
+ };
45
+ const addViaSignals = (viaHeader) => {
46
+ const hops = viaHeader
47
+ .split(",")
48
+ .map((part) => part.trim())
49
+ .filter(Boolean)
50
+ .map((part) => part.replace(/^\d+(?:\.\d+)?\s+/i, "").trim());
51
+ for (const hop of hops) {
52
+ if (hop && /(bbc-gtm|gtm|gateway|proxy|edge|cache|belfrage|varnish)/.test(hop.toLowerCase())) {
53
+ addTechnology(hop, "network", "Observed in Via response chain", null, "high", "observed");
54
+ }
55
+ }
56
+ };
57
+ if (server) {
58
+ const classification = classifyServerHeader(server);
59
+ addTechnology(classification.name, classification.category, "Observed in Server header", classification.version, "high", "observed");
60
+ }
61
+ if (via)
62
+ addViaSignals(via);
63
+ if (poweredBy) {
64
+ addTechnology(poweredBy, "frontend", "Observed in X-Powered-By header", null, "high", "observed");
65
+ const poweredByLower = poweredBy.toLowerCase();
66
+ if (poweredByLower.includes("express"))
67
+ addTechnology("Express", "frontend", "Observed in X-Powered-By header", null, "high", "observed");
68
+ if (poweredByLower.includes("next"))
69
+ addTechnology("Next.js", "frontend", "Observed in X-Powered-By header", null, "high", "observed");
70
+ }
71
+ if (headerValue(headers, "x-vercel-id"))
72
+ addTechnology("Vercel", "hosting", "Observed in X-Vercel-Id header", null, "high", "observed");
73
+ if (headerValue(headers, "x-amz-cf-id"))
74
+ addTechnology("Amazon CloudFront", "network", "Observed in CloudFront response headers", null, "high", "observed");
75
+ if (headerValue(headers, "x-cache")?.toLowerCase().includes("fastly"))
76
+ addTechnology("Fastly", "network", "Observed in X-Cache header", null, "high", "observed");
77
+ if (headerValue(headers, "x-cdn"))
78
+ addTechnology(headerValue(headers, "x-cdn"), "network", "Observed in X-CDN header", null, "high", "observed");
79
+ if (headerValue(headers, "x-envoy-upstream-service-time"))
80
+ addTechnology("Envoy", "network", "Observed in Envoy upstream timing header", null, "high", "observed");
81
+ if (headerValue(headers, "cf-ray") || cache)
82
+ addTechnology("Cloudflare", "network", "Observed in Cloudflare response headers", null, "high", "observed");
83
+ if (headerValue(headers, "x-sucuri-id") || headerValue(headers, "x-sucuri-cache"))
84
+ addTechnology("Sucuri", "network", "Observed in Sucuri edge headers", null, "high", "observed");
85
+ if (headerValue(headers, "x-akamai-transformed") || headerValue(headers, "akamai-cache-status"))
86
+ addTechnology("Akamai", "network", "Observed in Akamai response headers", null, "high", "observed");
87
+ if (headerValue(headers, "x-served-by")?.toLowerCase().includes("cache-"))
88
+ addTechnology("Fastly", "network", "Observed in X-Served-By cache headers", null, "high", "observed");
89
+ if (headerValue(headers, "server-timing")?.toLowerCase().includes("cdn-cache"))
90
+ addTechnology("CDN", "network", "Observed in Server-Timing header", null, "medium", "observed");
91
+ addTechnology(finalUrl.protocol === "https:" ? "HTTPS" : "HTTP", "security", "Derived from final URL", null, "high", "observed");
92
+ return technologies;
93
+ };