fingerprint-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +663 -0
- package/README.ar.md +767 -0
- package/README.bn.md +767 -0
- package/README.bs.md +767 -0
- package/README.da.md +767 -0
- package/README.de.md +770 -0
- package/README.el.md +767 -0
- package/README.es.md +769 -0
- package/README.fr.md +769 -0
- package/README.hi.md +767 -0
- package/README.it.md +767 -0
- package/README.ja.md +767 -0
- package/README.ko.md +772 -0
- package/README.md +767 -0
- package/README.no.md +767 -0
- package/README.pl.md +767 -0
- package/README.pt-BR.md +767 -0
- package/README.ru.md +767 -0
- package/README.th.md +767 -0
- package/README.tr.md +767 -0
- package/README.uk.md +767 -0
- package/README.vi.md +767 -0
- package/README.zh-TW.md +768 -0
- package/README.zh.md +768 -0
- package/dist/app/index.d.ts +3 -0
- package/dist/app/index.d.ts.map +1 -0
- package/dist/app/index.js +614 -0
- package/dist/app/index.js.map +1 -0
- package/dist/composite/analyze.d.ts +3 -0
- package/dist/composite/analyze.d.ts.map +1 -0
- package/dist/composite/analyze.js +59 -0
- package/dist/composite/analyze.js.map +1 -0
- package/dist/composite/correlate.d.ts +3 -0
- package/dist/composite/correlate.d.ts.map +1 -0
- package/dist/composite/correlate.js +184 -0
- package/dist/composite/correlate.js.map +1 -0
- package/dist/composite/enumerate.d.ts +3 -0
- package/dist/composite/enumerate.d.ts.map +1 -0
- package/dist/composite/enumerate.js +94 -0
- package/dist/composite/enumerate.js.map +1 -0
- package/dist/composite/helpers.d.ts +22 -0
- package/dist/composite/helpers.d.ts.map +1 -0
- package/dist/composite/helpers.js +111 -0
- package/dist/composite/helpers.js.map +1 -0
- package/dist/composite/index.d.ts +3 -0
- package/dist/composite/index.d.ts.map +1 -0
- package/dist/composite/index.js +29 -0
- package/dist/composite/index.js.map +1 -0
- package/dist/composite/meta.d.ts +3 -0
- package/dist/composite/meta.d.ts.map +1 -0
- package/dist/composite/meta.js +23 -0
- package/dist/composite/meta.js.map +1 -0
- package/dist/composite/osint.d.ts +3 -0
- package/dist/composite/osint.d.ts.map +1 -0
- package/dist/composite/osint.js +40 -0
- package/dist/composite/osint.js.map +1 -0
- package/dist/composite/recon.d.ts +3 -0
- package/dist/composite/recon.d.ts.map +1 -0
- package/dist/composite/recon.js +131 -0
- package/dist/composite/recon.js.map +1 -0
- package/dist/composite/scan-dns.d.ts +3 -0
- package/dist/composite/scan-dns.d.ts.map +1 -0
- package/dist/composite/scan-dns.js +44 -0
- package/dist/composite/scan-dns.js.map +1 -0
- package/dist/composite/scan-http.d.ts +3 -0
- package/dist/composite/scan-http.d.ts.map +1 -0
- package/dist/composite/scan-http.js +68 -0
- package/dist/composite/scan-http.js.map +1 -0
- package/dist/composite/scan-paths.d.ts +3 -0
- package/dist/composite/scan-paths.d.ts.map +1 -0
- package/dist/composite/scan-paths.js +32 -0
- package/dist/composite/scan-paths.js.map +1 -0
- package/dist/composite/scan-ports.d.ts +3 -0
- package/dist/composite/scan-ports.d.ts.map +1 -0
- package/dist/composite/scan-ports.js +79 -0
- package/dist/composite/scan-ports.js.map +1 -0
- package/dist/composite/scan-services.d.ts +3 -0
- package/dist/composite/scan-services.d.ts.map +1 -0
- package/dist/composite/scan-services.js +111 -0
- package/dist/composite/scan-services.js.map +1 -0
- package/dist/composite/scan-tls.d.ts +3 -0
- package/dist/composite/scan-tls.d.ts.map +1 -0
- package/dist/composite/scan-tls.js +32 -0
- package/dist/composite/scan-tls.js.map +1 -0
- package/dist/composite/scan-waf.d.ts +3 -0
- package/dist/composite/scan-waf.d.ts.map +1 -0
- package/dist/composite/scan-waf.js +35 -0
- package/dist/composite/scan-waf.js.map +1 -0
- package/dist/correlation/index.d.ts +3 -0
- package/dist/correlation/index.d.ts.map +1 -0
- package/dist/correlation/index.js +1044 -0
- package/dist/correlation/index.js.map +1 -0
- package/dist/data/analytics-patterns.d.ts +21 -0
- package/dist/data/analytics-patterns.d.ts.map +1 -0
- package/dist/data/analytics-patterns.js +449 -0
- package/dist/data/analytics-patterns.js.map +1 -0
- package/dist/data/app-signatures.d.ts +52 -0
- package/dist/data/app-signatures.d.ts.map +1 -0
- package/dist/data/app-signatures.js +1143 -0
- package/dist/data/app-signatures.js.map +1 -0
- package/dist/data/c2-signatures.d.ts +54 -0
- package/dist/data/c2-signatures.d.ts.map +1 -0
- package/dist/data/c2-signatures.js +256 -0
- package/dist/data/c2-signatures.js.map +1 -0
- package/dist/data/cloud-ranges.d.ts +34 -0
- package/dist/data/cloud-ranges.d.ts.map +1 -0
- package/dist/data/cloud-ranges.js +628 -0
- package/dist/data/cloud-ranges.js.map +1 -0
- package/dist/data/cookie-patterns.d.ts +28 -0
- package/dist/data/cookie-patterns.d.ts.map +1 -0
- package/dist/data/cookie-patterns.js +225 -0
- package/dist/data/cookie-patterns.js.map +1 -0
- package/dist/data/error-signatures.d.ts +19 -0
- package/dist/data/error-signatures.d.ts.map +1 -0
- package/dist/data/error-signatures.js +321 -0
- package/dist/data/error-signatures.js.map +1 -0
- package/dist/data/favicon-hashes.d.ts +25 -0
- package/dist/data/favicon-hashes.d.ts.map +1 -0
- package/dist/data/favicon-hashes.js +249 -0
- package/dist/data/favicon-hashes.js.map +1 -0
- package/dist/data/h2-signatures.d.ts +50 -0
- package/dist/data/h2-signatures.d.ts.map +1 -0
- package/dist/data/h2-signatures.js +211 -0
- package/dist/data/h2-signatures.js.map +1 -0
- package/dist/data/header-order.d.ts +38 -0
- package/dist/data/header-order.d.ts.map +1 -0
- package/dist/data/header-order.js +185 -0
- package/dist/data/header-order.js.map +1 -0
- package/dist/data/jarm-signatures.d.ts +25 -0
- package/dist/data/jarm-signatures.d.ts.map +1 -0
- package/dist/data/jarm-signatures.js +213 -0
- package/dist/data/jarm-signatures.js.map +1 -0
- package/dist/data/saas-patterns.d.ts +23 -0
- package/dist/data/saas-patterns.d.ts.map +1 -0
- package/dist/data/saas-patterns.js +139 -0
- package/dist/data/saas-patterns.js.map +1 -0
- package/dist/data/sensitive-paths.d.ts +12 -0
- package/dist/data/sensitive-paths.d.ts.map +1 -0
- package/dist/data/sensitive-paths.js +1539 -0
- package/dist/data/sensitive-paths.js.map +1 -0
- package/dist/data/service-banners.d.ts +59 -0
- package/dist/data/service-banners.d.ts.map +1 -0
- package/dist/data/service-banners.js +323 -0
- package/dist/data/service-banners.js.map +1 -0
- package/dist/data/smtp-signatures.d.ts +12 -0
- package/dist/data/smtp-signatures.d.ts.map +1 -0
- package/dist/data/smtp-signatures.js +350 -0
- package/dist/data/smtp-signatures.js.map +1 -0
- package/dist/data/takeover-patterns.d.ts +37 -0
- package/dist/data/takeover-patterns.d.ts.map +1 -0
- package/dist/data/takeover-patterns.js +249 -0
- package/dist/data/takeover-patterns.js.map +1 -0
- package/dist/data/tech-patterns.d.ts +44 -0
- package/dist/data/tech-patterns.d.ts.map +1 -0
- package/dist/data/tech-patterns.js +690 -0
- package/dist/data/tech-patterns.js.map +1 -0
- package/dist/data/waf-signatures.d.ts +21 -0
- package/dist/data/waf-signatures.d.ts.map +1 -0
- package/dist/data/waf-signatures.js +318 -0
- package/dist/data/waf-signatures.js.map +1 -0
- package/dist/dns/index.d.ts +3 -0
- package/dist/dns/index.d.ts.map +1 -0
- package/dist/dns/index.js +1067 -0
- package/dist/dns/index.js.map +1 -0
- package/dist/enum/index.d.ts +3 -0
- package/dist/enum/index.d.ts.map +1 -0
- package/dist/enum/index.js +818 -0
- package/dist/enum/index.js.map +1 -0
- package/dist/h2/index.d.ts +3 -0
- package/dist/h2/index.d.ts.map +1 -0
- package/dist/h2/index.js +414 -0
- package/dist/h2/index.js.map +1 -0
- package/dist/http/index.d.ts +3 -0
- package/dist/http/index.d.ts.map +1 -0
- package/dist/http/index.js +2444 -0
- package/dist/http/index.js.map +1 -0
- package/dist/identify/index.d.ts +3 -0
- package/dist/identify/index.d.ts.map +1 -0
- package/dist/identify/index.js +447 -0
- package/dist/identify/index.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +165 -0
- package/dist/index.js.map +1 -0
- package/dist/infra/index.d.ts +3 -0
- package/dist/infra/index.d.ts.map +1 -0
- package/dist/infra/index.js +989 -0
- package/dist/infra/index.js.map +1 -0
- package/dist/iot/index.d.ts +3 -0
- package/dist/iot/index.d.ts.map +1 -0
- package/dist/iot/index.js +610 -0
- package/dist/iot/index.js.map +1 -0
- package/dist/meta/index.d.ts +3 -0
- package/dist/meta/index.d.ts.map +1 -0
- package/dist/meta/index.js +442 -0
- package/dist/meta/index.js.map +1 -0
- package/dist/osint/index.d.ts +3 -0
- package/dist/osint/index.d.ts.map +1 -0
- package/dist/osint/index.js +687 -0
- package/dist/osint/index.js.map +1 -0
- package/dist/passive/index.d.ts +3 -0
- package/dist/passive/index.d.ts.map +1 -0
- package/dist/passive/index.js +944 -0
- package/dist/passive/index.js.map +1 -0
- package/dist/path/index.d.ts +3 -0
- package/dist/path/index.d.ts.map +1 -0
- package/dist/path/index.js +878 -0
- package/dist/path/index.js.map +1 -0
- package/dist/protocol/mcp-server.d.ts +4 -0
- package/dist/protocol/mcp-server.d.ts.map +1 -0
- package/dist/protocol/mcp-server.js +32 -0
- package/dist/protocol/mcp-server.js.map +1 -0
- package/dist/protocol/tools.d.ts +3 -0
- package/dist/protocol/tools.d.ts.map +1 -0
- package/dist/protocol/tools.js +5 -0
- package/dist/protocol/tools.js.map +1 -0
- package/dist/service/index.d.ts +3 -0
- package/dist/service/index.d.ts.map +1 -0
- package/dist/service/index.js +1053 -0
- package/dist/service/index.js.map +1 -0
- package/dist/smtp/index.d.ts +3 -0
- package/dist/smtp/index.d.ts.map +1 -0
- package/dist/smtp/index.js +437 -0
- package/dist/smtp/index.js.map +1 -0
- package/dist/ssh/index.d.ts +3 -0
- package/dist/ssh/index.d.ts.map +1 -0
- package/dist/ssh/index.js +676 -0
- package/dist/ssh/index.js.map +1 -0
- package/dist/tcp/index.d.ts +3 -0
- package/dist/tcp/index.d.ts.map +1 -0
- package/dist/tcp/index.js +369 -0
- package/dist/tcp/index.js.map +1 -0
- package/dist/timing/index.d.ts +3 -0
- package/dist/timing/index.d.ts.map +1 -0
- package/dist/timing/index.js +425 -0
- package/dist/timing/index.js.map +1 -0
- package/dist/tls/index.d.ts +3 -0
- package/dist/tls/index.d.ts.map +1 -0
- package/dist/tls/index.js +1332 -0
- package/dist/tls/index.js.map +1 -0
- package/dist/types/index.d.ts +26 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +8 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/cache.d.ts +11 -0
- package/dist/utils/cache.d.ts.map +1 -0
- package/dist/utils/cache.js +35 -0
- package/dist/utils/cache.js.map +1 -0
- package/dist/utils/murmurhash3.d.ts +3 -0
- package/dist/utils/murmurhash3.d.ts.map +1 -0
- package/dist/utils/murmurhash3.js +57 -0
- package/dist/utils/murmurhash3.js.map +1 -0
- package/dist/utils/rate-limiter.d.ts +10 -0
- package/dist/utils/rate-limiter.d.ts.map +1 -0
- package/dist/utils/rate-limiter.js +35 -0
- package/dist/utils/rate-limiter.js.map +1 -0
- package/dist/utils/require-key.d.ts +2 -0
- package/dist/utils/require-key.d.ts.map +1 -0
- package/dist/utils/require-key.js +8 -0
- package/dist/utils/require-key.js.map +1 -0
- package/dist/waf/index.d.ts +3 -0
- package/dist/waf/index.d.ts.map +1 -0
- package/dist/waf/index.js +767 -0
- package/dist/waf/index.js.map +1 -0
- package/dist/web/index.d.ts +3 -0
- package/dist/web/index.d.ts.map +1 -0
- package/dist/web/index.js +1366 -0
- package/dist/web/index.js.map +1 -0
- package/package.json +66 -0
|
@@ -0,0 +1,2444 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import * as cheerio from "cheerio";
|
|
4
|
+
import { json } from "../types/index.js";
|
|
5
|
+
import { RateLimiter } from "../utils/rate-limiter.js";
|
|
6
|
+
import { TTLCache } from "../utils/cache.js";
|
|
7
|
+
import { shodanFaviconHash } from "../utils/murmurhash3.js";
|
|
8
|
+
import { computeHeaderOrderHash, HEADER_ORDER_SIGNATURES, } from "../data/header-order.js";
|
|
9
|
+
// ─── Module-Level Setup ───
|
|
10
|
+
const limiter = new RateLimiter(200); // 200ms between requests
|
|
11
|
+
const cache = new TTLCache(300_000); // 5min cache
|
|
12
|
+
const USER_AGENT = "fingerprint-mcp/0.1";
|
|
13
|
+
const DEFAULT_TIMEOUT = 10_000; // 10 seconds
|
|
14
|
+
// ─── Known Header Order Patterns (inline for spoofing detection) ───
|
|
15
|
+
const KNOWN_SERVER_HEADER_ORDERS = {
|
|
16
|
+
apache: [
|
|
17
|
+
["date", "server", "last-modified", "etag", "accept-ranges", "content-length", "content-type"],
|
|
18
|
+
["date", "server", "content-type", "content-length", "connection"],
|
|
19
|
+
["date", "server", "last-modified", "etag", "accept-ranges", "content-length", "vary", "content-type"],
|
|
20
|
+
],
|
|
21
|
+
nginx: [
|
|
22
|
+
["server", "date", "content-type", "content-length", "connection", "last-modified", "etag", "accept-ranges"],
|
|
23
|
+
["server", "date", "content-type", "transfer-encoding", "connection"],
|
|
24
|
+
["server", "date", "content-type", "content-length", "connection"],
|
|
25
|
+
],
|
|
26
|
+
iis: [
|
|
27
|
+
["content-type", "server", "x-powered-by", "date", "content-length"],
|
|
28
|
+
["content-type", "server", "date", "content-length"],
|
|
29
|
+
],
|
|
30
|
+
express: [
|
|
31
|
+
["x-powered-by", "content-type", "content-length", "etag", "date", "connection"],
|
|
32
|
+
["x-powered-by", "content-type", "date", "connection", "transfer-encoding"],
|
|
33
|
+
],
|
|
34
|
+
litespeed: [
|
|
35
|
+
["date", "content-type", "transfer-encoding", "connection", "x-powered-by"],
|
|
36
|
+
["date", "server", "content-type", "content-length", "connection"],
|
|
37
|
+
],
|
|
38
|
+
caddy: [
|
|
39
|
+
["server", "content-type", "date"],
|
|
40
|
+
["server", "content-type", "content-length", "date"],
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
const ETAG_PATTERNS = [
|
|
44
|
+
{
|
|
45
|
+
server: "Apache",
|
|
46
|
+
regex: /^"([0-9a-f]+)-([0-9a-f]+)-([0-9a-f]+)"$/i,
|
|
47
|
+
description: "Apache inode-size-timestamp format (hex triplet)",
|
|
48
|
+
extract: (m) => ({
|
|
49
|
+
inode: parseInt(m[1], 16),
|
|
50
|
+
fileSize: parseInt(m[2], 16),
|
|
51
|
+
timestamp: parseInt(m[3], 16),
|
|
52
|
+
timestampDate: new Date(parseInt(m[3], 16) * 1000).toISOString(),
|
|
53
|
+
}),
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
server: "Nginx",
|
|
57
|
+
regex: /^"([0-9a-f]+)-([0-9a-f]+)"$/i,
|
|
58
|
+
description: "Nginx timestamp-size format (hex pair)",
|
|
59
|
+
extract: (m) => ({
|
|
60
|
+
timestamp: parseInt(m[1], 16),
|
|
61
|
+
timestampDate: new Date(parseInt(m[1], 16) * 1000).toISOString(),
|
|
62
|
+
fileSize: parseInt(m[2], 16),
|
|
63
|
+
}),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
server: "IIS",
|
|
67
|
+
regex: /^"[0-9a-f]+:[0-9a-f]+"$/i,
|
|
68
|
+
description: "IIS opaque hash format (colon-separated)",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
server: "LiteSpeed",
|
|
72
|
+
regex: /^"([0-9a-f]+)-([0-9a-f]+)-([0-9a-f]+)"$/i,
|
|
73
|
+
description: "LiteSpeed size-timestamp-gzip format",
|
|
74
|
+
extract: (m) => ({
|
|
75
|
+
fileSize: parseInt(m[1], 16),
|
|
76
|
+
timestamp: parseInt(m[2], 16),
|
|
77
|
+
gzipFlag: parseInt(m[3], 16),
|
|
78
|
+
}),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
server: "Cloudflare",
|
|
82
|
+
regex: /^W\/"[0-9a-f]+-[0-9a-f]+"$/i,
|
|
83
|
+
description: "Cloudflare weak ETag (W/ prefix)",
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
server: "Cloudflare",
|
|
87
|
+
regex: /^W\//,
|
|
88
|
+
description: "Weak ETag (generic, possibly Cloudflare or CDN)",
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
// ─── Session Cookie Patterns (inline) ───
|
|
92
|
+
const SESSION_COOKIE_MAP = {
|
|
93
|
+
PHPSESSID: "PHP",
|
|
94
|
+
JSESSIONID: "Java (Servlet/JSP)",
|
|
95
|
+
"ASP.NET_SessionId": "ASP.NET",
|
|
96
|
+
"connect.sid": "Express.js (Node.js)",
|
|
97
|
+
laravel_session: "Laravel (PHP)",
|
|
98
|
+
"rack.session": "Ruby on Rails / Sinatra",
|
|
99
|
+
_session_id: "Ruby on Rails",
|
|
100
|
+
csrftoken: "Django (Python)",
|
|
101
|
+
sessionid: "Django (Python)",
|
|
102
|
+
ci_session: "CodeIgniter (PHP)",
|
|
103
|
+
_gorilla_csrf: "Go (Gorilla toolkit)",
|
|
104
|
+
};
|
|
105
|
+
// ─── Known Services for Resource Hints ───
|
|
106
|
+
const KNOWN_SERVICES = {
|
|
107
|
+
"fonts.googleapis.com": "Google Fonts",
|
|
108
|
+
"fonts.gstatic.com": "Google Fonts (static)",
|
|
109
|
+
"www.googletagmanager.com": "Google Tag Manager",
|
|
110
|
+
"www.google-analytics.com": "Google Analytics",
|
|
111
|
+
"analytics.google.com": "Google Analytics",
|
|
112
|
+
"cdn.segment.com": "Segment Analytics",
|
|
113
|
+
"js.stripe.com": "Stripe Payments",
|
|
114
|
+
"m.stripe.com": "Stripe Mobile",
|
|
115
|
+
"api.stripe.com": "Stripe API",
|
|
116
|
+
"js.braintreegateway.com": "Braintree Payments",
|
|
117
|
+
"cdn.auth0.com": "Auth0 Authentication",
|
|
118
|
+
"browser.sentry-cdn.com": "Sentry Error Tracking",
|
|
119
|
+
"js.sentry-cdn.com": "Sentry Error Tracking",
|
|
120
|
+
"cdn.heapanalytics.com": "Heap Analytics",
|
|
121
|
+
"cdn.amplitude.com": "Amplitude Analytics",
|
|
122
|
+
"cdn.mxpnl.com": "Mixpanel Analytics",
|
|
123
|
+
"static.hotjar.com": "Hotjar Heatmaps",
|
|
124
|
+
"script.hotjar.com": "Hotjar Heatmaps",
|
|
125
|
+
"connect.facebook.net": "Facebook SDK",
|
|
126
|
+
"platform.twitter.com": "Twitter/X Platform",
|
|
127
|
+
"cdn.jsdelivr.net": "jsDelivr CDN",
|
|
128
|
+
"cdnjs.cloudflare.com": "Cloudflare cdnjs",
|
|
129
|
+
"unpkg.com": "unpkg CDN",
|
|
130
|
+
"ajax.googleapis.com": "Google Hosted Libraries",
|
|
131
|
+
"cdn.shopify.com": "Shopify CDN",
|
|
132
|
+
"static.cloudflareinsights.com": "Cloudflare Web Analytics",
|
|
133
|
+
"widget.intercom.io": "Intercom",
|
|
134
|
+
"js.intercomcdn.com": "Intercom CDN",
|
|
135
|
+
"fast.wistia.com": "Wistia Video",
|
|
136
|
+
"player.vimeo.com": "Vimeo Player",
|
|
137
|
+
"www.youtube.com": "YouTube",
|
|
138
|
+
"maps.googleapis.com": "Google Maps",
|
|
139
|
+
"api.mapbox.com": "Mapbox Maps",
|
|
140
|
+
"kit.fontawesome.com": "Font Awesome",
|
|
141
|
+
"use.typekit.net": "Adobe Fonts (Typekit)",
|
|
142
|
+
"snap.licdn.com": "LinkedIn Insight Tag",
|
|
143
|
+
"bat.bing.com": "Bing Ads UET",
|
|
144
|
+
"sc-static.net": "Snapchat Pixel",
|
|
145
|
+
"px.ads.linkedin.com": "LinkedIn Ads",
|
|
146
|
+
"cdn.cookielaw.org": "OneTrust Cookie Consent",
|
|
147
|
+
};
|
|
148
|
+
// ─── Shared Helpers ───
|
|
149
|
+
async function safeFetch(url, init) {
|
|
150
|
+
await limiter.acquire();
|
|
151
|
+
return fetch(url, {
|
|
152
|
+
signal: AbortSignal.timeout(DEFAULT_TIMEOUT),
|
|
153
|
+
headers: { "User-Agent": USER_AGENT },
|
|
154
|
+
...init,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
function getHeaderOrder(response) {
|
|
158
|
+
const order = [];
|
|
159
|
+
response.headers.forEach((_value, key) => {
|
|
160
|
+
order.push(key);
|
|
161
|
+
});
|
|
162
|
+
return order;
|
|
163
|
+
}
|
|
164
|
+
function matchServerFromOrder(headerOrder) {
|
|
165
|
+
const lowerOrder = headerOrder.map((h) => h.toLowerCase());
|
|
166
|
+
const hash = computeHeaderOrderHash(headerOrder);
|
|
167
|
+
// Check against known signatures from data file
|
|
168
|
+
for (const sig of HEADER_ORDER_SIGNATURES) {
|
|
169
|
+
if (sig.hash === hash) {
|
|
170
|
+
return { detected: sig.server, confidence: 0.9 };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Fuzzy match against inline patterns
|
|
174
|
+
let bestMatch = null;
|
|
175
|
+
let bestScore = 0;
|
|
176
|
+
for (const [server, patterns] of Object.entries(KNOWN_SERVER_HEADER_ORDERS)) {
|
|
177
|
+
for (const pattern of patterns) {
|
|
178
|
+
// Count how many headers appear in the same relative order
|
|
179
|
+
let matchCount = 0;
|
|
180
|
+
let lastIdx = -1;
|
|
181
|
+
for (const hdr of pattern) {
|
|
182
|
+
const idx = lowerOrder.indexOf(hdr);
|
|
183
|
+
if (idx > lastIdx) {
|
|
184
|
+
matchCount++;
|
|
185
|
+
lastIdx = idx;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const score = matchCount / pattern.length;
|
|
189
|
+
if (score > bestScore) {
|
|
190
|
+
bestScore = score;
|
|
191
|
+
bestMatch = server;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (bestScore >= 0.6) {
|
|
196
|
+
return { detected: bestMatch, confidence: bestScore };
|
|
197
|
+
}
|
|
198
|
+
return { detected: null, confidence: 0 };
|
|
199
|
+
}
|
|
200
|
+
function detectSpoofing(serverHeader, orderDetection) {
|
|
201
|
+
if (!serverHeader || !orderDetection) {
|
|
202
|
+
return { spoofed: false, details: "Insufficient data for spoofing detection" };
|
|
203
|
+
}
|
|
204
|
+
const serverLower = serverHeader.toLowerCase();
|
|
205
|
+
const orderLower = orderDetection.toLowerCase();
|
|
206
|
+
// Map server header values to canonical names
|
|
207
|
+
const serverCanonical = serverLower.includes("apache") ? "apache"
|
|
208
|
+
: serverLower.includes("nginx") ? "nginx"
|
|
209
|
+
: serverLower.includes("iis") || serverLower.includes("microsoft") ? "iis"
|
|
210
|
+
: serverLower.includes("express") ? "express"
|
|
211
|
+
: serverLower.includes("litespeed") ? "litespeed"
|
|
212
|
+
: serverLower.includes("caddy") ? "caddy"
|
|
213
|
+
: serverLower.includes("cloudflare") ? "cloudflare"
|
|
214
|
+
: null;
|
|
215
|
+
if (!serverCanonical) {
|
|
216
|
+
return { spoofed: false, details: "Server header value not in spoofing detection database" };
|
|
217
|
+
}
|
|
218
|
+
if (!orderLower.includes(serverCanonical)) {
|
|
219
|
+
return {
|
|
220
|
+
spoofed: true,
|
|
221
|
+
details: `Server header claims "${serverHeader}" but header ordering pattern matches "${orderDetection}"`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return { spoofed: false, details: "Server header consistent with header ordering pattern" };
|
|
225
|
+
}
|
|
226
|
+
function parseCookieString(setCookieHeader) {
|
|
227
|
+
const parts = setCookieHeader.split(";").map((p) => p.trim());
|
|
228
|
+
const [nameValue, ...attrs] = parts;
|
|
229
|
+
const eqIdx = nameValue.indexOf("=");
|
|
230
|
+
const name = eqIdx > 0 ? nameValue.slice(0, eqIdx) : nameValue;
|
|
231
|
+
const value = eqIdx > 0 ? nameValue.slice(eqIdx + 1) : "";
|
|
232
|
+
const attributes = {};
|
|
233
|
+
for (const attr of attrs) {
|
|
234
|
+
const aEqIdx = attr.indexOf("=");
|
|
235
|
+
if (aEqIdx > 0) {
|
|
236
|
+
attributes[attr.slice(0, aEqIdx).toLowerCase()] = attr.slice(aEqIdx + 1);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
attributes[attr.toLowerCase()] = true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { name, value, attributes };
|
|
243
|
+
}
|
|
244
|
+
function decodeBigIPCookie(cookieValue) {
|
|
245
|
+
try {
|
|
246
|
+
// Simple format: NNNNN.NNNNN.0000
|
|
247
|
+
const simpleParts = cookieValue.split(".");
|
|
248
|
+
if (simpleParts.length >= 2) {
|
|
249
|
+
const ipEncoded = parseInt(simpleParts[0], 10);
|
|
250
|
+
const portEncoded = parseInt(simpleParts[1], 10);
|
|
251
|
+
if (!isNaN(ipEncoded) && !isNaN(portEncoded)) {
|
|
252
|
+
// Decode IP: convert to hex, reverse byte pairs, convert each pair to decimal
|
|
253
|
+
const ipHex = (ipEncoded >>> 0).toString(16).padStart(8, "0");
|
|
254
|
+
const ip = [
|
|
255
|
+
parseInt(ipHex.slice(6, 8), 16),
|
|
256
|
+
parseInt(ipHex.slice(4, 6), 16),
|
|
257
|
+
parseInt(ipHex.slice(2, 4), 16),
|
|
258
|
+
parseInt(ipHex.slice(0, 2), 16),
|
|
259
|
+
].join(".");
|
|
260
|
+
// Decode port: convert to hex, reverse byte pair, convert to decimal
|
|
261
|
+
const portHex = (portEncoded >>> 0).toString(16).padStart(4, "0");
|
|
262
|
+
const port = parseInt(portHex.slice(2, 4) + portHex.slice(0, 2), 16);
|
|
263
|
+
return { ip, port };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// Extended format: rd<N>o00000000000000000000ffff<hex-ip>o<port>
|
|
267
|
+
const extMatch = cookieValue.match(/rd\d+o0+ffff([0-9a-f]{8})o(\d+)/i);
|
|
268
|
+
if (extMatch) {
|
|
269
|
+
const hexIp = extMatch[1];
|
|
270
|
+
const ip = [
|
|
271
|
+
parseInt(hexIp.slice(0, 2), 16),
|
|
272
|
+
parseInt(hexIp.slice(2, 4), 16),
|
|
273
|
+
parseInt(hexIp.slice(4, 6), 16),
|
|
274
|
+
parseInt(hexIp.slice(6, 8), 16),
|
|
275
|
+
].join(".");
|
|
276
|
+
const port = parseInt(extMatch[2], 10);
|
|
277
|
+
return { ip, port };
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function decodeNetScalerCookie(cookieName, cookieValue) {
|
|
286
|
+
// Cookie name after NSC_ is the vserver name with char substitution
|
|
287
|
+
const encodedName = cookieName.replace(/^NSC_/, "");
|
|
288
|
+
// Reverse the character mapping (lowercase letters shifted by a fixed offset)
|
|
289
|
+
const vserverName = encodedName
|
|
290
|
+
.split("")
|
|
291
|
+
.map((c) => {
|
|
292
|
+
const code = c.charCodeAt(0);
|
|
293
|
+
if (code >= 97 && code <= 122) {
|
|
294
|
+
// a-z: reverse rotate
|
|
295
|
+
return String.fromCharCode(((code - 97 + 13) % 26) + 97);
|
|
296
|
+
}
|
|
297
|
+
if (code >= 65 && code <= 90) {
|
|
298
|
+
return String.fromCharCode(((code - 65 + 13) % 26) + 65);
|
|
299
|
+
}
|
|
300
|
+
return c;
|
|
301
|
+
})
|
|
302
|
+
.join("");
|
|
303
|
+
// Cookie value: hex-encoded, first 8 hex = XOR-decoded VIP, next 4 = XOR-decoded port
|
|
304
|
+
let ip = null;
|
|
305
|
+
let port = null;
|
|
306
|
+
try {
|
|
307
|
+
const hexMatch = cookieValue.match(/^([0-9a-f]{8})([0-9a-f]{4})/i);
|
|
308
|
+
if (hexMatch) {
|
|
309
|
+
const ipEncoded = parseInt(hexMatch[1], 16);
|
|
310
|
+
const portEncoded = parseInt(hexMatch[2], 16);
|
|
311
|
+
// XOR with 0x03030303 for IP
|
|
312
|
+
const ipDecoded = ipEncoded ^ 0x03030303;
|
|
313
|
+
ip = [
|
|
314
|
+
(ipDecoded >>> 24) & 0xff,
|
|
315
|
+
(ipDecoded >>> 16) & 0xff,
|
|
316
|
+
(ipDecoded >>> 8) & 0xff,
|
|
317
|
+
ipDecoded & 0xff,
|
|
318
|
+
].join(".");
|
|
319
|
+
// XOR with 0x0303 for port
|
|
320
|
+
port = portEncoded ^ 0x0303;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
// Decoding failed — return what we have
|
|
325
|
+
}
|
|
326
|
+
return { vserverName, ip, port };
|
|
327
|
+
}
|
|
328
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
329
|
+
// Tool 1: http_headers
|
|
330
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
331
|
+
const httpHeaders = {
|
|
332
|
+
name: "http_headers",
|
|
333
|
+
description: "HTTP header ordering fingerprint. Sends a GET request, captures response headers in order, " +
|
|
334
|
+
"compares ordering against known server patterns (Apache, Nginx, IIS, Express, LiteSpeed, Caddy). " +
|
|
335
|
+
"Detects spoofing when Server header claims one server but header ordering follows another pattern. " +
|
|
336
|
+
"Computes header order hash for correlation.",
|
|
337
|
+
schema: {
|
|
338
|
+
url: z.string().url().describe("Target URL to fingerprint via header ordering"),
|
|
339
|
+
},
|
|
340
|
+
async execute(args) {
|
|
341
|
+
const url = args.url;
|
|
342
|
+
const cacheKey = `http_headers:${url}`;
|
|
343
|
+
const cached = cache.get(cacheKey);
|
|
344
|
+
if (cached)
|
|
345
|
+
return json(cached);
|
|
346
|
+
try {
|
|
347
|
+
const res = await safeFetch(url, { redirect: "follow" });
|
|
348
|
+
const headerOrder = getHeaderOrder(res);
|
|
349
|
+
const hash = computeHeaderOrderHash(headerOrder);
|
|
350
|
+
const serverHeader = res.headers.get("server");
|
|
351
|
+
const orderMatch = matchServerFromOrder(headerOrder);
|
|
352
|
+
const spoofCheck = detectSpoofing(serverHeader, orderMatch.detected);
|
|
353
|
+
const result = {
|
|
354
|
+
url,
|
|
355
|
+
status: res.status,
|
|
356
|
+
serverHeader,
|
|
357
|
+
headerOrder,
|
|
358
|
+
headerOrderHash: hash,
|
|
359
|
+
headerCount: headerOrder.length,
|
|
360
|
+
detection: {
|
|
361
|
+
serverFromOrder: orderMatch.detected,
|
|
362
|
+
confidence: orderMatch.confidence,
|
|
363
|
+
},
|
|
364
|
+
spoofing: spoofCheck,
|
|
365
|
+
rawHeaders: Object.fromEntries(headerOrder.map((h) => [h, res.headers.get(h)])),
|
|
366
|
+
};
|
|
367
|
+
cache.set(cacheKey, result);
|
|
368
|
+
return json(result);
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
return json({ url, error: err.message });
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
376
|
+
// Tool 2: http_multi_fingerprint
|
|
377
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
378
|
+
const httpMultiFingerprint = {
|
|
379
|
+
name: "http_multi_fingerprint",
|
|
380
|
+
description: "Multi-request header ordering comparison. Sends GET /, GET /nonexistent404, POST /, HEAD / and compares " +
|
|
381
|
+
"header ordering across request types. Ordering differences between methods provide additional fingerprint " +
|
|
382
|
+
"signal for server identification.",
|
|
383
|
+
schema: {
|
|
384
|
+
url: z.string().url().describe("Target URL for multi-method header fingerprinting"),
|
|
385
|
+
},
|
|
386
|
+
async execute(args) {
|
|
387
|
+
const url = args.url;
|
|
388
|
+
const cacheKey = `http_multi_fingerprint:${url}`;
|
|
389
|
+
const cached = cache.get(cacheKey);
|
|
390
|
+
if (cached)
|
|
391
|
+
return json(cached);
|
|
392
|
+
const methods = [
|
|
393
|
+
{ label: "GET /", init: { method: "GET" }, path: "" },
|
|
394
|
+
{
|
|
395
|
+
label: "GET /nonexistent404",
|
|
396
|
+
init: { method: "GET" },
|
|
397
|
+
path: "/nonexistent-path-fingerprint-mcp-404",
|
|
398
|
+
},
|
|
399
|
+
{ label: "POST /", init: { method: "POST", body: "" }, path: "" },
|
|
400
|
+
{ label: "HEAD /", init: { method: "HEAD" }, path: "" },
|
|
401
|
+
];
|
|
402
|
+
const results = {};
|
|
403
|
+
const baseUrl = new URL(url);
|
|
404
|
+
for (const m of methods) {
|
|
405
|
+
try {
|
|
406
|
+
const targetUrl = new URL(m.path, baseUrl).toString();
|
|
407
|
+
const res = await safeFetch(targetUrl, {
|
|
408
|
+
...m.init,
|
|
409
|
+
redirect: "follow",
|
|
410
|
+
});
|
|
411
|
+
const headerOrder = getHeaderOrder(res);
|
|
412
|
+
results[m.label] = {
|
|
413
|
+
status: res.status,
|
|
414
|
+
headerOrder,
|
|
415
|
+
hash: computeHeaderOrderHash(headerOrder),
|
|
416
|
+
serverHeader: res.headers.get("server"),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
results[m.label] = {
|
|
421
|
+
status: 0,
|
|
422
|
+
headerOrder: [],
|
|
423
|
+
hash: "",
|
|
424
|
+
serverHeader: null,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// Analyze ordering differences
|
|
429
|
+
const hashes = Object.values(results).map((r) => r.hash).filter(Boolean);
|
|
430
|
+
const uniqueHashes = [...new Set(hashes)];
|
|
431
|
+
const orderingConsistent = uniqueHashes.length <= 1;
|
|
432
|
+
// Detect server from the GET / response
|
|
433
|
+
const getResult = results["GET /"];
|
|
434
|
+
const detection = getResult
|
|
435
|
+
? matchServerFromOrder(getResult.headerOrder)
|
|
436
|
+
: { detected: null, confidence: 0 };
|
|
437
|
+
const result = {
|
|
438
|
+
url,
|
|
439
|
+
requests: results,
|
|
440
|
+
analysis: {
|
|
441
|
+
uniqueOrderHashes: uniqueHashes.length,
|
|
442
|
+
orderingConsistent,
|
|
443
|
+
serverDetection: detection.detected,
|
|
444
|
+
confidence: detection.confidence,
|
|
445
|
+
insight: orderingConsistent
|
|
446
|
+
? "Header ordering is consistent across methods — typical for single-server deployments"
|
|
447
|
+
: "Header ordering varies across methods — may indicate reverse proxy, load balancer, or WAF in front of origin",
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
cache.set(cacheKey, result);
|
|
451
|
+
return json(result);
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
455
|
+
// Tool 3: http_favicon
|
|
456
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
457
|
+
const httpFavicon = {
|
|
458
|
+
name: "http_favicon",
|
|
459
|
+
description: "Favicon detection and hashing. Checks 4 sources: /favicon.ico, HTML <link rel=\"icon\">, " +
|
|
460
|
+
"/apple-touch-icon.png, /apple-touch-icon-precomposed.png. Calculates Shodan MurmurHash3, MD5, " +
|
|
461
|
+
"and SHA256. Matches against known application favicon hashes (Jenkins, Grafana, Kibana, etc.).",
|
|
462
|
+
schema: {
|
|
463
|
+
url: z.string().url().describe("Target URL for favicon detection and hash fingerprinting"),
|
|
464
|
+
},
|
|
465
|
+
async execute(args) {
|
|
466
|
+
const url = args.url;
|
|
467
|
+
const cacheKey = `http_favicon:${url}`;
|
|
468
|
+
const cached = cache.get(cacheKey);
|
|
469
|
+
if (cached)
|
|
470
|
+
return json(cached);
|
|
471
|
+
const baseUrl = new URL(url);
|
|
472
|
+
const faviconSources = [];
|
|
473
|
+
// Helper to fetch and hash a favicon URL
|
|
474
|
+
async function fetchAndHash(faviconUrl, source) {
|
|
475
|
+
try {
|
|
476
|
+
const res = await safeFetch(faviconUrl, { redirect: "follow" });
|
|
477
|
+
if (!res.ok) {
|
|
478
|
+
faviconSources.push({ source, url: faviconUrl, error: `HTTP ${res.status}` });
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const contentType = res.headers.get("content-type") || "";
|
|
482
|
+
// Skip HTML responses (error pages returned as 200)
|
|
483
|
+
if (contentType.includes("text/html") && source !== "html-link") {
|
|
484
|
+
faviconSources.push({ source, url: faviconUrl, error: "Returned HTML instead of icon" });
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
488
|
+
if (buffer.length === 0) {
|
|
489
|
+
faviconSources.push({ source, url: faviconUrl, error: "Empty response" });
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const mmh3 = shodanFaviconHash(buffer);
|
|
493
|
+
const md5 = createHash("md5").update(buffer).digest("hex");
|
|
494
|
+
const sha256 = createHash("sha256").update(buffer).digest("hex");
|
|
495
|
+
// Match against known favicon hashes
|
|
496
|
+
const { FAVICON_HASHES } = await import("../data/favicon-hashes.js");
|
|
497
|
+
const knownMatch = FAVICON_HASHES.find((f) => f.hash === mmh3);
|
|
498
|
+
faviconSources.push({
|
|
499
|
+
source,
|
|
500
|
+
url: faviconUrl,
|
|
501
|
+
hash: mmh3,
|
|
502
|
+
md5,
|
|
503
|
+
sha256,
|
|
504
|
+
size: buffer.length,
|
|
505
|
+
contentType,
|
|
506
|
+
match: knownMatch?.name ?? undefined,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
catch (err) {
|
|
510
|
+
faviconSources.push({ source, url: faviconUrl, error: err.message });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
// Source 1: /favicon.ico
|
|
514
|
+
await fetchAndHash(new URL("/favicon.ico", baseUrl).toString(), "favicon.ico");
|
|
515
|
+
// Source 2: HTML <link rel="icon">
|
|
516
|
+
try {
|
|
517
|
+
const htmlRes = await safeFetch(url, { redirect: "follow" });
|
|
518
|
+
if (htmlRes.ok) {
|
|
519
|
+
const html = await htmlRes.text();
|
|
520
|
+
const $ = cheerio.load(html);
|
|
521
|
+
const iconLink = $('link[rel="icon"], link[rel="shortcut icon"]').first();
|
|
522
|
+
if (iconLink.length) {
|
|
523
|
+
const href = iconLink.attr("href");
|
|
524
|
+
if (href) {
|
|
525
|
+
const iconUrl = new URL(href, url).toString();
|
|
526
|
+
await fetchAndHash(iconUrl, "html-link");
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
// HTML parsing failed — skip
|
|
533
|
+
}
|
|
534
|
+
// Source 3: /apple-touch-icon.png
|
|
535
|
+
await fetchAndHash(new URL("/apple-touch-icon.png", baseUrl).toString(), "apple-touch-icon");
|
|
536
|
+
// Source 4: /apple-touch-icon-precomposed.png
|
|
537
|
+
await fetchAndHash(new URL("/apple-touch-icon-precomposed.png", baseUrl).toString(), "apple-touch-icon-precomposed");
|
|
538
|
+
const foundIcons = faviconSources.filter((f) => f.hash !== undefined);
|
|
539
|
+
const matches = faviconSources.filter((f) => f.match);
|
|
540
|
+
const result = {
|
|
541
|
+
url,
|
|
542
|
+
sources: faviconSources,
|
|
543
|
+
summary: {
|
|
544
|
+
iconsFound: foundIcons.length,
|
|
545
|
+
knownApplicationMatches: matches.map((m) => ({
|
|
546
|
+
source: m.source,
|
|
547
|
+
application: m.match,
|
|
548
|
+
shodanHash: m.hash,
|
|
549
|
+
})),
|
|
550
|
+
shodanSearchQuery: foundIcons.length > 0
|
|
551
|
+
? `http.favicon.hash:${foundIcons[0].hash}`
|
|
552
|
+
: null,
|
|
553
|
+
},
|
|
554
|
+
};
|
|
555
|
+
cache.set(cacheKey, result);
|
|
556
|
+
return json(result);
|
|
557
|
+
},
|
|
558
|
+
};
|
|
559
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
560
|
+
// Tool 4: http_etag
|
|
561
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
562
|
+
const httpEtag = {
|
|
563
|
+
name: "http_etag",
|
|
564
|
+
description: "ETag format analysis for server fingerprinting. Detects server type from ETag format patterns: " +
|
|
565
|
+
"Apache (inode-size-timestamp hex), Nginx (timestamp-size hex), IIS (opaque), LiteSpeed (size-timestamp-gzip), " +
|
|
566
|
+
"Cloudflare (weak ETag W/). Extracts Apache inode number, file size, and timestamp from ETag values.",
|
|
567
|
+
schema: {
|
|
568
|
+
url: z.string().url().describe("Target URL for ETag format analysis"),
|
|
569
|
+
},
|
|
570
|
+
async execute(args) {
|
|
571
|
+
const url = args.url;
|
|
572
|
+
const cacheKey = `http_etag:${url}`;
|
|
573
|
+
const cached = cache.get(cacheKey);
|
|
574
|
+
if (cached)
|
|
575
|
+
return json(cached);
|
|
576
|
+
try {
|
|
577
|
+
const res = await safeFetch(url, { redirect: "follow" });
|
|
578
|
+
const etag = res.headers.get("etag");
|
|
579
|
+
if (!etag) {
|
|
580
|
+
const result = {
|
|
581
|
+
url,
|
|
582
|
+
etag: null,
|
|
583
|
+
serverDetected: null,
|
|
584
|
+
analysis: "No ETag header present — server may disable ETags or use different caching strategy",
|
|
585
|
+
};
|
|
586
|
+
cache.set(cacheKey, result);
|
|
587
|
+
return json(result);
|
|
588
|
+
}
|
|
589
|
+
let serverDetected = null;
|
|
590
|
+
let formatDescription = null;
|
|
591
|
+
let extractedData = null;
|
|
592
|
+
const isWeak = etag.startsWith("W/");
|
|
593
|
+
for (const pattern of ETAG_PATTERNS) {
|
|
594
|
+
const match = etag.match(pattern.regex);
|
|
595
|
+
if (match) {
|
|
596
|
+
serverDetected = pattern.server;
|
|
597
|
+
formatDescription = pattern.description;
|
|
598
|
+
if (pattern.extract) {
|
|
599
|
+
extractedData = pattern.extract(match);
|
|
600
|
+
}
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
// Check for Apache inode leak specifically
|
|
605
|
+
let inodeLeak = false;
|
|
606
|
+
if (serverDetected === "Apache" && extractedData && "inode" in extractedData) {
|
|
607
|
+
inodeLeak = true;
|
|
608
|
+
}
|
|
609
|
+
const result = {
|
|
610
|
+
url,
|
|
611
|
+
etag,
|
|
612
|
+
isWeakETag: isWeak,
|
|
613
|
+
serverDetected,
|
|
614
|
+
formatDescription,
|
|
615
|
+
extractedData,
|
|
616
|
+
securityFindings: {
|
|
617
|
+
inodeLeak,
|
|
618
|
+
inodeLeakDetails: inodeLeak
|
|
619
|
+
? `Apache inode ${extractedData?.inode} leaked — may aid in identifying specific files or filesystem layout`
|
|
620
|
+
: null,
|
|
621
|
+
},
|
|
622
|
+
serverHeader: res.headers.get("server"),
|
|
623
|
+
};
|
|
624
|
+
cache.set(cacheKey, result);
|
|
625
|
+
return json(result);
|
|
626
|
+
}
|
|
627
|
+
catch (err) {
|
|
628
|
+
return json({ url, error: err.message });
|
|
629
|
+
}
|
|
630
|
+
},
|
|
631
|
+
};
|
|
632
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
633
|
+
// Tool 5: http_errors
|
|
634
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
635
|
+
const httpErrors = {
|
|
636
|
+
name: "http_errors",
|
|
637
|
+
description: "Error page fingerprinting. Triggers 400, 404, 405, 500 errors plus special triggers " +
|
|
638
|
+
"(null byte, oversized header, invalid method). Each error response is matched against " +
|
|
639
|
+
"framework signatures (Apache, Nginx, IIS, Express, Django, Laravel, Spring Boot, etc.). " +
|
|
640
|
+
"Reveals server software, version, and potentially debug information.",
|
|
641
|
+
schema: {
|
|
642
|
+
url: z.string().url().describe("Target URL for error page fingerprinting"),
|
|
643
|
+
},
|
|
644
|
+
async execute(args) {
|
|
645
|
+
const url = args.url;
|
|
646
|
+
const cacheKey = `http_errors:${url}`;
|
|
647
|
+
const cached = cache.get(cacheKey);
|
|
648
|
+
if (cached)
|
|
649
|
+
return json(cached);
|
|
650
|
+
const baseUrl = new URL(url);
|
|
651
|
+
const errorTests = [
|
|
652
|
+
{
|
|
653
|
+
name: "404 — Non-existent path",
|
|
654
|
+
expectedStatus: 404,
|
|
655
|
+
fetch: () => safeFetch(new URL("/nonexistent-path-fpmcp-" + Date.now(), baseUrl).toString(), { redirect: "follow" }),
|
|
656
|
+
},
|
|
657
|
+
{
|
|
658
|
+
name: "405 — Method Not Allowed (DELETE on root)",
|
|
659
|
+
expectedStatus: 405,
|
|
660
|
+
fetch: () => safeFetch(url, { method: "DELETE", redirect: "follow" }),
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
name: "400 — Null byte in URL",
|
|
664
|
+
expectedStatus: 400,
|
|
665
|
+
fetch: () => safeFetch(new URL("/test%00null", baseUrl).toString(), { redirect: "follow" }),
|
|
666
|
+
},
|
|
667
|
+
{
|
|
668
|
+
name: "400 — Oversized header",
|
|
669
|
+
expectedStatus: 400,
|
|
670
|
+
fetch: () => safeFetch(url, {
|
|
671
|
+
redirect: "follow",
|
|
672
|
+
headers: {
|
|
673
|
+
"User-Agent": USER_AGENT,
|
|
674
|
+
"X-Oversized-Test": "A".repeat(16384),
|
|
675
|
+
},
|
|
676
|
+
}),
|
|
677
|
+
},
|
|
678
|
+
{
|
|
679
|
+
name: "405 — Invalid method (FAKEVERB)",
|
|
680
|
+
expectedStatus: 405,
|
|
681
|
+
fetch: () => safeFetch(url, { method: "FAKEVERB", redirect: "follow" }),
|
|
682
|
+
},
|
|
683
|
+
];
|
|
684
|
+
const { ERROR_SIGNATURES } = await import("../data/error-signatures.js");
|
|
685
|
+
const errorResults = [];
|
|
686
|
+
for (const test of errorTests) {
|
|
687
|
+
try {
|
|
688
|
+
const res = await test.fetch();
|
|
689
|
+
const body = await res.text();
|
|
690
|
+
const bodySnippet = body.slice(0, 2000);
|
|
691
|
+
const serverHeader = res.headers.get("server");
|
|
692
|
+
const matchedSignatures = [];
|
|
693
|
+
for (const sig of ERROR_SIGNATURES) {
|
|
694
|
+
const matchedPatterns = [];
|
|
695
|
+
// Check body patterns
|
|
696
|
+
if (sig.patterns.body) {
|
|
697
|
+
for (const pattern of sig.patterns.body) {
|
|
698
|
+
try {
|
|
699
|
+
if (new RegExp(pattern, "is").test(body)) {
|
|
700
|
+
matchedPatterns.push(`body: ${pattern}`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
catch {
|
|
704
|
+
// Invalid regex — skip
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
// Check header patterns
|
|
709
|
+
if (sig.patterns.headers) {
|
|
710
|
+
for (const [headerName, headerPattern] of Object.entries(sig.patterns.headers)) {
|
|
711
|
+
const headerValue = res.headers.get(headerName);
|
|
712
|
+
if (headerValue) {
|
|
713
|
+
try {
|
|
714
|
+
if (new RegExp(headerPattern, "i").test(headerValue)) {
|
|
715
|
+
matchedPatterns.push(`header ${headerName}: ${headerPattern}`);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
catch {
|
|
719
|
+
// Invalid regex — skip
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
if (matchedPatterns.length > 0) {
|
|
725
|
+
matchedSignatures.push({
|
|
726
|
+
server: sig.server,
|
|
727
|
+
confidence: sig.confidence,
|
|
728
|
+
matchedPatterns,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
errorResults.push({
|
|
733
|
+
name: test.name,
|
|
734
|
+
status: res.status,
|
|
735
|
+
serverHeader,
|
|
736
|
+
bodySnippet,
|
|
737
|
+
matchedSignatures,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
catch (err) {
|
|
741
|
+
errorResults.push({
|
|
742
|
+
name: test.name,
|
|
743
|
+
status: 0,
|
|
744
|
+
serverHeader: null,
|
|
745
|
+
bodySnippet: "",
|
|
746
|
+
matchedSignatures: [],
|
|
747
|
+
error: err.message,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
// Aggregate the most confident detections
|
|
752
|
+
const allDetections = errorResults.flatMap((r) => r.matchedSignatures);
|
|
753
|
+
const serverCounts = {};
|
|
754
|
+
for (const d of allDetections) {
|
|
755
|
+
if (!serverCounts[d.server]) {
|
|
756
|
+
serverCounts[d.server] = { count: 0, maxConfidence: 0 };
|
|
757
|
+
}
|
|
758
|
+
serverCounts[d.server].count++;
|
|
759
|
+
serverCounts[d.server].maxConfidence = Math.max(serverCounts[d.server].maxConfidence, d.confidence);
|
|
760
|
+
}
|
|
761
|
+
const topDetection = Object.entries(serverCounts)
|
|
762
|
+
.sort((a, b) => b[1].maxConfidence - a[1].maxConfidence || b[1].count - a[1].count)
|
|
763
|
+
.map(([server, info]) => ({ server, ...info }));
|
|
764
|
+
const result = {
|
|
765
|
+
url,
|
|
766
|
+
errorTests: errorResults,
|
|
767
|
+
summary: {
|
|
768
|
+
detectedServers: topDetection,
|
|
769
|
+
primaryDetection: topDetection[0]?.server ?? null,
|
|
770
|
+
debugInfoExposed: errorResults.some((r) => r.bodySnippet.includes("Traceback") ||
|
|
771
|
+
r.bodySnippet.includes("stack trace") ||
|
|
772
|
+
r.bodySnippet.includes("DEBUG = True") ||
|
|
773
|
+
r.bodySnippet.includes("Exception")),
|
|
774
|
+
},
|
|
775
|
+
};
|
|
776
|
+
cache.set(cacheKey, result);
|
|
777
|
+
return json(result);
|
|
778
|
+
},
|
|
779
|
+
};
|
|
780
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
781
|
+
// Tool 6: http_cookies
|
|
782
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
783
|
+
const httpCookies = {
|
|
784
|
+
name: "http_cookies",
|
|
785
|
+
description: "Cookie analysis for technology and infrastructure detection. Identifies session technology " +
|
|
786
|
+
"(PHPSESSID, JSESSIONID, connect.sid, etc.), decodes infrastructure cookies (F5 BIG-IP " +
|
|
787
|
+
"backend IP:port, Citrix NetScaler VIP:port), and assesses cookie security (HttpOnly, Secure, " +
|
|
788
|
+
"SameSite, domain scope, expiry).",
|
|
789
|
+
schema: {
|
|
790
|
+
url: z.string().url().describe("Target URL for cookie analysis"),
|
|
791
|
+
},
|
|
792
|
+
async execute(args) {
|
|
793
|
+
const url = args.url;
|
|
794
|
+
const cacheKey = `http_cookies:${url}`;
|
|
795
|
+
const cached = cache.get(cacheKey);
|
|
796
|
+
if (cached)
|
|
797
|
+
return json(cached);
|
|
798
|
+
try {
|
|
799
|
+
const res = await safeFetch(url, { redirect: "manual" });
|
|
800
|
+
// Collect all Set-Cookie headers
|
|
801
|
+
const setCookieHeaders = [];
|
|
802
|
+
res.headers.forEach((value, key) => {
|
|
803
|
+
if (key.toLowerCase() === "set-cookie") {
|
|
804
|
+
setCookieHeaders.push(value);
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
// Some environments merge set-cookie — also try splitting on comma+space pattern
|
|
808
|
+
// But first try the raw headers approach
|
|
809
|
+
if (setCookieHeaders.length === 0) {
|
|
810
|
+
const raw = res.headers.get("set-cookie");
|
|
811
|
+
if (raw) {
|
|
812
|
+
// Set-Cookie headers can contain commas in dates, so splitting is complex
|
|
813
|
+
// Just treat the whole thing as one cookie for now
|
|
814
|
+
setCookieHeaders.push(raw);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
if (setCookieHeaders.length === 0) {
|
|
818
|
+
const result = {
|
|
819
|
+
url,
|
|
820
|
+
cookies: [],
|
|
821
|
+
technologies: [],
|
|
822
|
+
infrastructure: [],
|
|
823
|
+
securityAssessment: { findings: ["No cookies set by server"] },
|
|
824
|
+
};
|
|
825
|
+
cache.set(cacheKey, result);
|
|
826
|
+
return json(result);
|
|
827
|
+
}
|
|
828
|
+
const cookies = setCookieHeaders.map(parseCookieString);
|
|
829
|
+
// Session technology detection
|
|
830
|
+
const technologies = [];
|
|
831
|
+
for (const cookie of cookies) {
|
|
832
|
+
for (const [pattern, tech] of Object.entries(SESSION_COOKIE_MAP)) {
|
|
833
|
+
if (cookie.name === pattern || cookie.name.startsWith(pattern)) {
|
|
834
|
+
technologies.push({ cookieName: cookie.name, technology: tech });
|
|
835
|
+
break;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
// WordPress-specific pattern checks
|
|
839
|
+
if (cookie.name.startsWith("wordpress_logged_in_")) {
|
|
840
|
+
technologies.push({ cookieName: cookie.name, technology: "WordPress" });
|
|
841
|
+
}
|
|
842
|
+
if (cookie.name.startsWith("wp-settings-")) {
|
|
843
|
+
technologies.push({ cookieName: cookie.name, technology: "WordPress" });
|
|
844
|
+
}
|
|
845
|
+
// Drupal pattern
|
|
846
|
+
if (/^SESS[0-9a-f]{32}$/.test(cookie.name)) {
|
|
847
|
+
technologies.push({ cookieName: cookie.name, technology: "Drupal" });
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
// Infrastructure decode
|
|
851
|
+
const infrastructure = [];
|
|
852
|
+
for (const cookie of cookies) {
|
|
853
|
+
// F5 BIG-IP
|
|
854
|
+
if (cookie.name.startsWith("BIGipServer")) {
|
|
855
|
+
const decoded = decodeBigIPCookie(cookie.value);
|
|
856
|
+
infrastructure.push({
|
|
857
|
+
cookieName: cookie.name,
|
|
858
|
+
technology: "F5 BIG-IP",
|
|
859
|
+
decoded: decoded
|
|
860
|
+
? { backendIP: decoded.ip, backendPort: decoded.port, poolName: cookie.name.replace("BIGipServer", "") }
|
|
861
|
+
: { raw: cookie.value, decodeFailed: true },
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
// Citrix NetScaler
|
|
865
|
+
if (cookie.name.startsWith("NSC_")) {
|
|
866
|
+
const decoded = decodeNetScalerCookie(cookie.name, cookie.value);
|
|
867
|
+
infrastructure.push({
|
|
868
|
+
cookieName: cookie.name,
|
|
869
|
+
technology: "Citrix NetScaler",
|
|
870
|
+
decoded,
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
// AWS ALB
|
|
874
|
+
if (cookie.name === "AWSALB" || cookie.name === "AWSALBCORS") {
|
|
875
|
+
infrastructure.push({
|
|
876
|
+
cookieName: cookie.name,
|
|
877
|
+
technology: "AWS ALB",
|
|
878
|
+
decoded: { note: "ALB routing cookie — base64-encoded target group info" },
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
// Google Cloud LB
|
|
882
|
+
if (cookie.name === "GCLB") {
|
|
883
|
+
infrastructure.push({
|
|
884
|
+
cookieName: cookie.name,
|
|
885
|
+
technology: "Google Cloud Load Balancer",
|
|
886
|
+
decoded: { note: "GCLB backend routing cookie" },
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
// HAProxy
|
|
890
|
+
if (cookie.name === "ROUTEID" || cookie.name === "SERVERID") {
|
|
891
|
+
infrastructure.push({
|
|
892
|
+
cookieName: cookie.name,
|
|
893
|
+
technology: "HAProxy",
|
|
894
|
+
decoded: { backendServer: cookie.value },
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
// Cloudflare
|
|
898
|
+
if (cookie.name === "__cf_bm" ||
|
|
899
|
+
cookie.name === "_cfuvid" ||
|
|
900
|
+
cookie.name === "cf_clearance") {
|
|
901
|
+
infrastructure.push({
|
|
902
|
+
cookieName: cookie.name,
|
|
903
|
+
technology: "Cloudflare",
|
|
904
|
+
decoded: { note: "Cloudflare bot management / security cookie" },
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// Security assessment
|
|
909
|
+
const securityFindings = [];
|
|
910
|
+
for (const cookie of cookies) {
|
|
911
|
+
const prefix = `Cookie "${cookie.name}":`;
|
|
912
|
+
if (!cookie.attributes.httponly) {
|
|
913
|
+
securityFindings.push(`${prefix} missing HttpOnly flag — accessible via JavaScript`);
|
|
914
|
+
}
|
|
915
|
+
if (!cookie.attributes.secure) {
|
|
916
|
+
securityFindings.push(`${prefix} missing Secure flag — may be sent over HTTP`);
|
|
917
|
+
}
|
|
918
|
+
if (!cookie.attributes.samesite) {
|
|
919
|
+
securityFindings.push(`${prefix} missing SameSite attribute — defaults to Lax in modern browsers`);
|
|
920
|
+
}
|
|
921
|
+
if (cookie.attributes.domain) {
|
|
922
|
+
const domain = cookie.attributes.domain;
|
|
923
|
+
if (domain.startsWith(".")) {
|
|
924
|
+
securityFindings.push(`${prefix} wide domain scope (${domain}) — shared across subdomains`);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
if (cookie.attributes.expires) {
|
|
928
|
+
const expiryDate = new Date(cookie.attributes.expires);
|
|
929
|
+
const daysUntilExpiry = Math.floor((expiryDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
|
930
|
+
if (daysUntilExpiry > 365) {
|
|
931
|
+
securityFindings.push(`${prefix} long expiry (${daysUntilExpiry} days) — consider shorter session lifetime`);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
const result = {
|
|
936
|
+
url,
|
|
937
|
+
cookies: cookies.map((c) => ({
|
|
938
|
+
name: c.name,
|
|
939
|
+
valueLength: c.value.length,
|
|
940
|
+
attributes: c.attributes,
|
|
941
|
+
})),
|
|
942
|
+
technologies,
|
|
943
|
+
infrastructure,
|
|
944
|
+
securityAssessment: {
|
|
945
|
+
totalCookies: cookies.length,
|
|
946
|
+
findings: securityFindings.length > 0 ? securityFindings : ["All cookies have proper security attributes"],
|
|
947
|
+
},
|
|
948
|
+
};
|
|
949
|
+
cache.set(cacheKey, result);
|
|
950
|
+
return json(result);
|
|
951
|
+
}
|
|
952
|
+
catch (err) {
|
|
953
|
+
return json({ url, error: err.message });
|
|
954
|
+
}
|
|
955
|
+
},
|
|
956
|
+
};
|
|
957
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
958
|
+
// Tool 7: http_methods
|
|
959
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
960
|
+
const httpMethods = {
|
|
961
|
+
name: "http_methods",
|
|
962
|
+
description: "HTTP method probing. Tests GET, POST, PUT, DELETE, PATCH, OPTIONS, TRACE, CONNECT, PROPFIND, " +
|
|
963
|
+
"MKCOL and method override headers (X-HTTP-Method-Override). Identifies TRACE 200 (XST risk), " +
|
|
964
|
+
"PROPFIND 207 (WebDAV), verb tampering (FAKEVERB), and method override support.",
|
|
965
|
+
schema: {
|
|
966
|
+
url: z.string().url().describe("Target URL for HTTP method probing"),
|
|
967
|
+
},
|
|
968
|
+
async execute(args) {
|
|
969
|
+
const url = args.url;
|
|
970
|
+
const cacheKey = `http_methods:${url}`;
|
|
971
|
+
const cached = cache.get(cacheKey);
|
|
972
|
+
if (cached)
|
|
973
|
+
return json(cached);
|
|
974
|
+
const methodsToTest = [
|
|
975
|
+
"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS",
|
|
976
|
+
"TRACE", "PROPFIND", "MKCOL", "FAKEVERB",
|
|
977
|
+
];
|
|
978
|
+
const methodResults = {};
|
|
979
|
+
for (const method of methodsToTest) {
|
|
980
|
+
try {
|
|
981
|
+
const res = await safeFetch(url, {
|
|
982
|
+
method: method,
|
|
983
|
+
redirect: "follow",
|
|
984
|
+
});
|
|
985
|
+
const status = res.status;
|
|
986
|
+
const allowed = status < 400 || status === 401 || status === 403;
|
|
987
|
+
let note;
|
|
988
|
+
if (method === "TRACE" && status === 200) {
|
|
989
|
+
note = "SECURITY: TRACE enabled — Cross-Site Tracing (XST) risk";
|
|
990
|
+
}
|
|
991
|
+
if (method === "PROPFIND" && status === 207) {
|
|
992
|
+
note = "WebDAV enabled (207 Multi-Status response)";
|
|
993
|
+
}
|
|
994
|
+
if (method === "FAKEVERB" && status < 400) {
|
|
995
|
+
note = "Verb tampering possible — server accepts unknown HTTP methods";
|
|
996
|
+
}
|
|
997
|
+
if (method === "OPTIONS") {
|
|
998
|
+
const allow = res.headers.get("allow");
|
|
999
|
+
if (allow) {
|
|
1000
|
+
note = `Allowed methods: ${allow}`;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
// Consume body to avoid connection issues
|
|
1004
|
+
await res.text().catch(() => { });
|
|
1005
|
+
methodResults[method] = { status, allowed, note };
|
|
1006
|
+
}
|
|
1007
|
+
catch (err) {
|
|
1008
|
+
methodResults[method] = { status: 0, allowed: false, error: err.message };
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
// Test method override headers
|
|
1012
|
+
const overrideResults = {};
|
|
1013
|
+
const overrideHeaders = [
|
|
1014
|
+
"X-HTTP-Method-Override",
|
|
1015
|
+
"X-HTTP-Method",
|
|
1016
|
+
"X-Method-Override",
|
|
1017
|
+
];
|
|
1018
|
+
for (const header of overrideHeaders) {
|
|
1019
|
+
try {
|
|
1020
|
+
const res = await safeFetch(url, {
|
|
1021
|
+
method: "POST",
|
|
1022
|
+
headers: {
|
|
1023
|
+
"User-Agent": USER_AGENT,
|
|
1024
|
+
[header]: "DELETE",
|
|
1025
|
+
"Content-Length": "0",
|
|
1026
|
+
},
|
|
1027
|
+
redirect: "follow",
|
|
1028
|
+
});
|
|
1029
|
+
// Compare against normal POST status
|
|
1030
|
+
const normalPostStatus = methodResults["POST"]?.status ?? 0;
|
|
1031
|
+
const overrideWorked = res.status !== normalPostStatus && res.status < 400;
|
|
1032
|
+
await res.text().catch(() => { });
|
|
1033
|
+
overrideResults[header] = {
|
|
1034
|
+
status: res.status,
|
|
1035
|
+
overrideWorked,
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
catch (err) {
|
|
1039
|
+
overrideResults[header] = { status: 0, overrideWorked: false };
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
const findings = [];
|
|
1043
|
+
if (methodResults["TRACE"]?.status === 200) {
|
|
1044
|
+
findings.push("TRACE method enabled — XST (Cross-Site Tracing) vulnerability risk");
|
|
1045
|
+
}
|
|
1046
|
+
if (methodResults["PROPFIND"]?.status === 207) {
|
|
1047
|
+
findings.push("WebDAV enabled — PROPFIND returns 207 Multi-Status");
|
|
1048
|
+
}
|
|
1049
|
+
if (methodResults["PUT"]?.allowed) {
|
|
1050
|
+
findings.push("PUT method allowed — check for arbitrary file upload");
|
|
1051
|
+
}
|
|
1052
|
+
if (methodResults["DELETE"]?.allowed) {
|
|
1053
|
+
findings.push("DELETE method allowed — check for unauthorized deletion");
|
|
1054
|
+
}
|
|
1055
|
+
if (methodResults["FAKEVERB"]?.allowed) {
|
|
1056
|
+
findings.push("Server accepts unknown HTTP verbs — potential verb tampering");
|
|
1057
|
+
}
|
|
1058
|
+
for (const [header, r] of Object.entries(overrideResults)) {
|
|
1059
|
+
if (r.overrideWorked) {
|
|
1060
|
+
findings.push(`Method override via ${header} header is supported`);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
const result = {
|
|
1064
|
+
url,
|
|
1065
|
+
methods: methodResults,
|
|
1066
|
+
methodOverride: overrideResults,
|
|
1067
|
+
securityFindings: findings.length > 0 ? findings : ["No concerning method configurations found"],
|
|
1068
|
+
allowedMethods: Object.entries(methodResults)
|
|
1069
|
+
.filter(([, r]) => r.allowed)
|
|
1070
|
+
.map(([m]) => m),
|
|
1071
|
+
};
|
|
1072
|
+
cache.set(cacheKey, result);
|
|
1073
|
+
return json(result);
|
|
1074
|
+
},
|
|
1075
|
+
};
|
|
1076
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1077
|
+
// Tool 8: http_cors
|
|
1078
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1079
|
+
const httpCors = {
|
|
1080
|
+
name: "http_cors",
|
|
1081
|
+
description: "CORS (Cross-Origin Resource Sharing) configuration analysis. Tests origin reflection " +
|
|
1082
|
+
"(evil.com), null origin, subdomain trust, prefix/suffix regex bypass. Extracts " +
|
|
1083
|
+
"Access-Control-Allow-Methods/Headers/Expose-Headers to map API surface and discover " +
|
|
1084
|
+
"internal header names.",
|
|
1085
|
+
schema: {
|
|
1086
|
+
url: z.string().url().describe("Target URL for CORS configuration analysis"),
|
|
1087
|
+
},
|
|
1088
|
+
async execute(args) {
|
|
1089
|
+
const url = args.url;
|
|
1090
|
+
const cacheKey = `http_cors:${url}`;
|
|
1091
|
+
const cached = cache.get(cacheKey);
|
|
1092
|
+
if (cached)
|
|
1093
|
+
return json(cached);
|
|
1094
|
+
const targetHost = new URL(url).hostname;
|
|
1095
|
+
const originTests = [
|
|
1096
|
+
{
|
|
1097
|
+
name: "Arbitrary origin",
|
|
1098
|
+
origin: "https://evil.com",
|
|
1099
|
+
expectReflect: false,
|
|
1100
|
+
risk: "HIGH: Server reflects arbitrary origins — full CORS bypass",
|
|
1101
|
+
},
|
|
1102
|
+
{
|
|
1103
|
+
name: "Null origin",
|
|
1104
|
+
origin: "null",
|
|
1105
|
+
expectReflect: false,
|
|
1106
|
+
risk: "HIGH: Null origin accepted — exploitable via sandboxed iframes, data: URIs",
|
|
1107
|
+
},
|
|
1108
|
+
{
|
|
1109
|
+
name: "Subdomain",
|
|
1110
|
+
origin: `https://test.${targetHost}`,
|
|
1111
|
+
expectReflect: false,
|
|
1112
|
+
risk: "MEDIUM: Subdomain trusted — subdomain takeover could lead to CORS bypass",
|
|
1113
|
+
},
|
|
1114
|
+
{
|
|
1115
|
+
name: "Prefix bypass",
|
|
1116
|
+
origin: `https://${targetHost}.evil.com`,
|
|
1117
|
+
expectReflect: false,
|
|
1118
|
+
risk: "HIGH: Origin prefix bypass — regex/string matching flaw",
|
|
1119
|
+
},
|
|
1120
|
+
{
|
|
1121
|
+
name: "Suffix bypass",
|
|
1122
|
+
origin: `https://evil${targetHost}`,
|
|
1123
|
+
expectReflect: false,
|
|
1124
|
+
risk: "HIGH: Origin suffix bypass — regex/string matching flaw",
|
|
1125
|
+
},
|
|
1126
|
+
{
|
|
1127
|
+
name: "Scheme mismatch (HTTP)",
|
|
1128
|
+
origin: `http://${targetHost}`,
|
|
1129
|
+
expectReflect: false,
|
|
1130
|
+
risk: "MEDIUM: HTTP origin accepted on HTTPS site — downgrade risk",
|
|
1131
|
+
},
|
|
1132
|
+
];
|
|
1133
|
+
const corsResults = [];
|
|
1134
|
+
for (const test of originTests) {
|
|
1135
|
+
try {
|
|
1136
|
+
const res = await safeFetch(url, {
|
|
1137
|
+
method: "OPTIONS",
|
|
1138
|
+
headers: {
|
|
1139
|
+
"User-Agent": USER_AGENT,
|
|
1140
|
+
Origin: test.origin,
|
|
1141
|
+
"Access-Control-Request-Method": "GET",
|
|
1142
|
+
},
|
|
1143
|
+
redirect: "follow",
|
|
1144
|
+
});
|
|
1145
|
+
const acao = res.headers.get("access-control-allow-origin");
|
|
1146
|
+
const reflected = acao === test.origin || acao === "*";
|
|
1147
|
+
const allowCredentials = res.headers.get("access-control-allow-credentials") === "true";
|
|
1148
|
+
const allowMethods = res.headers.get("access-control-allow-methods");
|
|
1149
|
+
const allowHeaders = res.headers.get("access-control-allow-headers");
|
|
1150
|
+
const exposeHeaders = res.headers.get("access-control-expose-headers");
|
|
1151
|
+
await res.text().catch(() => { });
|
|
1152
|
+
corsResults.push({
|
|
1153
|
+
name: test.name,
|
|
1154
|
+
origin: test.origin,
|
|
1155
|
+
reflected,
|
|
1156
|
+
allowCredentials,
|
|
1157
|
+
allowMethods,
|
|
1158
|
+
allowHeaders,
|
|
1159
|
+
exposeHeaders,
|
|
1160
|
+
risk: reflected ? test.risk : null,
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
catch (err) {
|
|
1164
|
+
corsResults.push({
|
|
1165
|
+
name: test.name,
|
|
1166
|
+
origin: test.origin,
|
|
1167
|
+
reflected: false,
|
|
1168
|
+
allowCredentials: false,
|
|
1169
|
+
allowMethods: null,
|
|
1170
|
+
allowHeaders: null,
|
|
1171
|
+
exposeHeaders: null,
|
|
1172
|
+
risk: null,
|
|
1173
|
+
error: err.message,
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
// Also do a simple GET with Origin to check non-preflight behavior
|
|
1178
|
+
let getReflection = null;
|
|
1179
|
+
try {
|
|
1180
|
+
const res = await safeFetch(url, {
|
|
1181
|
+
headers: {
|
|
1182
|
+
"User-Agent": USER_AGENT,
|
|
1183
|
+
Origin: "https://evil.com",
|
|
1184
|
+
},
|
|
1185
|
+
redirect: "follow",
|
|
1186
|
+
});
|
|
1187
|
+
const acao = res.headers.get("access-control-allow-origin");
|
|
1188
|
+
getReflection = {
|
|
1189
|
+
reflected: acao === "https://evil.com" || acao === "*",
|
|
1190
|
+
acao,
|
|
1191
|
+
allowCredentials: res.headers.get("access-control-allow-credentials") === "true",
|
|
1192
|
+
};
|
|
1193
|
+
await res.text().catch(() => { });
|
|
1194
|
+
}
|
|
1195
|
+
catch {
|
|
1196
|
+
// Skip
|
|
1197
|
+
}
|
|
1198
|
+
const vulnerabilities = corsResults.filter((r) => r.risk !== null);
|
|
1199
|
+
const wildcardWithCredentials = corsResults.some((r) => r.reflected && r.allowCredentials);
|
|
1200
|
+
// Extract API surface from allowed methods/headers
|
|
1201
|
+
const allMethods = new Set();
|
|
1202
|
+
const allHeaders = new Set();
|
|
1203
|
+
const allExposedHeaders = new Set();
|
|
1204
|
+
for (const r of corsResults) {
|
|
1205
|
+
if (r.allowMethods) {
|
|
1206
|
+
r.allowMethods.split(",").map((m) => m.trim()).forEach((m) => allMethods.add(m));
|
|
1207
|
+
}
|
|
1208
|
+
if (r.allowHeaders) {
|
|
1209
|
+
r.allowHeaders.split(",").map((h) => h.trim()).forEach((h) => allHeaders.add(h));
|
|
1210
|
+
}
|
|
1211
|
+
if (r.exposeHeaders) {
|
|
1212
|
+
r.exposeHeaders.split(",").map((h) => h.trim()).forEach((h) => allExposedHeaders.add(h));
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
const result = {
|
|
1216
|
+
url,
|
|
1217
|
+
tests: corsResults,
|
|
1218
|
+
getReflection,
|
|
1219
|
+
apiSurface: {
|
|
1220
|
+
allowedMethods: [...allMethods],
|
|
1221
|
+
allowedHeaders: [...allHeaders],
|
|
1222
|
+
exposedHeaders: [...allExposedHeaders],
|
|
1223
|
+
},
|
|
1224
|
+
summary: {
|
|
1225
|
+
vulnerabilities: vulnerabilities.map((v) => v.risk),
|
|
1226
|
+
wildcardWithCredentials,
|
|
1227
|
+
wildcardWithCredentialsNote: wildcardWithCredentials
|
|
1228
|
+
? "CRITICAL: Origin reflected with credentials — allows full cross-origin data theft"
|
|
1229
|
+
: null,
|
|
1230
|
+
corsConfigured: corsResults.some((r) => r.reflected || r.allowMethods !== null),
|
|
1231
|
+
},
|
|
1232
|
+
};
|
|
1233
|
+
cache.set(cacheKey, result);
|
|
1234
|
+
return json(result);
|
|
1235
|
+
},
|
|
1236
|
+
};
|
|
1237
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1238
|
+
// Tool 9: http_compression
|
|
1239
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1240
|
+
const httpCompression = {
|
|
1241
|
+
name: "http_compression",
|
|
1242
|
+
description: "Compression support probing. Tests gzip, deflate, br (Brotli), and zstd encoding support. " +
|
|
1243
|
+
"Intelligence: Brotli + modern stack = modern infrastructure. Only gzip = legacy. " +
|
|
1244
|
+
"No compression = embedded/IoT device.",
|
|
1245
|
+
schema: {
|
|
1246
|
+
url: z.string().url().describe("Target URL for compression support probing"),
|
|
1247
|
+
},
|
|
1248
|
+
async execute(args) {
|
|
1249
|
+
const url = args.url;
|
|
1250
|
+
const cacheKey = `http_compression:${url}`;
|
|
1251
|
+
const cached = cache.get(cacheKey);
|
|
1252
|
+
if (cached)
|
|
1253
|
+
return json(cached);
|
|
1254
|
+
const encodings = ["gzip", "deflate", "br", "zstd"];
|
|
1255
|
+
const compressionResults = {};
|
|
1256
|
+
// Also fetch without compression for baseline
|
|
1257
|
+
let baselineSize = null;
|
|
1258
|
+
try {
|
|
1259
|
+
const baseRes = await safeFetch(url, {
|
|
1260
|
+
headers: {
|
|
1261
|
+
"User-Agent": USER_AGENT,
|
|
1262
|
+
"Accept-Encoding": "identity",
|
|
1263
|
+
},
|
|
1264
|
+
redirect: "follow",
|
|
1265
|
+
});
|
|
1266
|
+
const baseBody = await baseRes.arrayBuffer();
|
|
1267
|
+
baselineSize = baseBody.byteLength;
|
|
1268
|
+
}
|
|
1269
|
+
catch {
|
|
1270
|
+
// Skip baseline
|
|
1271
|
+
}
|
|
1272
|
+
for (const encoding of encodings) {
|
|
1273
|
+
try {
|
|
1274
|
+
const res = await safeFetch(url, {
|
|
1275
|
+
headers: {
|
|
1276
|
+
"User-Agent": USER_AGENT,
|
|
1277
|
+
"Accept-Encoding": encoding,
|
|
1278
|
+
},
|
|
1279
|
+
redirect: "follow",
|
|
1280
|
+
});
|
|
1281
|
+
const contentEncoding = res.headers.get("content-encoding");
|
|
1282
|
+
const contentLength = res.headers.get("content-length");
|
|
1283
|
+
const vary = res.headers.get("vary");
|
|
1284
|
+
// Consume body
|
|
1285
|
+
await res.arrayBuffer().catch(() => { });
|
|
1286
|
+
compressionResults[encoding] = {
|
|
1287
|
+
supported: contentEncoding?.toLowerCase().includes(encoding) ?? false,
|
|
1288
|
+
contentEncoding,
|
|
1289
|
+
contentLength,
|
|
1290
|
+
vary,
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
catch (err) {
|
|
1294
|
+
compressionResults[encoding] = {
|
|
1295
|
+
supported: false,
|
|
1296
|
+
contentEncoding: null,
|
|
1297
|
+
contentLength: null,
|
|
1298
|
+
vary: null,
|
|
1299
|
+
error: err.message,
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
const supportedEncodings = Object.entries(compressionResults)
|
|
1304
|
+
.filter(([, r]) => r.supported)
|
|
1305
|
+
.map(([enc]) => enc);
|
|
1306
|
+
let stackAssessment;
|
|
1307
|
+
if (supportedEncodings.includes("br") && supportedEncodings.includes("zstd")) {
|
|
1308
|
+
stackAssessment = "Cutting-edge stack — Brotli and Zstd support indicates modern infrastructure (likely CDN or recent server)";
|
|
1309
|
+
}
|
|
1310
|
+
else if (supportedEncodings.includes("br")) {
|
|
1311
|
+
stackAssessment = "Modern stack — Brotli support indicates recent server software or CDN";
|
|
1312
|
+
}
|
|
1313
|
+
else if (supportedEncodings.includes("gzip") && !supportedEncodings.includes("br")) {
|
|
1314
|
+
stackAssessment = "Legacy stack — only gzip compression, no Brotli support";
|
|
1315
|
+
}
|
|
1316
|
+
else if (supportedEncodings.length === 0) {
|
|
1317
|
+
stackAssessment = "No compression — may indicate embedded device, IoT, or minimal HTTP server";
|
|
1318
|
+
}
|
|
1319
|
+
else {
|
|
1320
|
+
stackAssessment = "Standard compression support";
|
|
1321
|
+
}
|
|
1322
|
+
const result = {
|
|
1323
|
+
url,
|
|
1324
|
+
baselineSizeBytes: baselineSize,
|
|
1325
|
+
encodings: compressionResults,
|
|
1326
|
+
supported: supportedEncodings,
|
|
1327
|
+
intelligence: {
|
|
1328
|
+
stackAssessment,
|
|
1329
|
+
compressionRatio: baselineSize && compressionResults.gzip?.contentLength
|
|
1330
|
+
? (parseInt(compressionResults.gzip.contentLength, 10) / baselineSize).toFixed(3)
|
|
1331
|
+
: null,
|
|
1332
|
+
},
|
|
1333
|
+
};
|
|
1334
|
+
cache.set(cacheKey, result);
|
|
1335
|
+
return json(result);
|
|
1336
|
+
},
|
|
1337
|
+
};
|
|
1338
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1339
|
+
// Tool 10: http_cache
|
|
1340
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1341
|
+
const httpCache = {
|
|
1342
|
+
name: "http_cache",
|
|
1343
|
+
description: "Cache behavior analysis. Detects hit/miss via X-Cache, CF-Cache-Status headers. " +
|
|
1344
|
+
"Discovers cache key components by varying query string, headers, and cookies. " +
|
|
1345
|
+
"Tests cache poisoning potential via unkeyed headers.",
|
|
1346
|
+
schema: {
|
|
1347
|
+
url: z.string().url().describe("Target URL for cache behavior analysis"),
|
|
1348
|
+
},
|
|
1349
|
+
async execute(args) {
|
|
1350
|
+
const url = args.url;
|
|
1351
|
+
const cacheKey = `http_cache:${url}`;
|
|
1352
|
+
const cached = cache.get(cacheKey);
|
|
1353
|
+
if (cached)
|
|
1354
|
+
return json(cached);
|
|
1355
|
+
// First request — establish baseline
|
|
1356
|
+
let baseline = null;
|
|
1357
|
+
const cacheHeaderNames = [
|
|
1358
|
+
"x-cache", "x-cache-status", "cf-cache-status",
|
|
1359
|
+
"x-varnish", "x-fastly-request-id", "x-served-by",
|
|
1360
|
+
"x-cache-hits", "cache-control", "expires",
|
|
1361
|
+
"pragma", "age", "via",
|
|
1362
|
+
];
|
|
1363
|
+
try {
|
|
1364
|
+
const res = await safeFetch(url, { redirect: "follow" });
|
|
1365
|
+
const headers = {};
|
|
1366
|
+
for (const h of cacheHeaderNames) {
|
|
1367
|
+
headers[h] = res.headers.get(h);
|
|
1368
|
+
}
|
|
1369
|
+
baseline = {
|
|
1370
|
+
status: res.status,
|
|
1371
|
+
cacheHeaders: headers,
|
|
1372
|
+
age: res.headers.get("age"),
|
|
1373
|
+
};
|
|
1374
|
+
await res.text().catch(() => { });
|
|
1375
|
+
}
|
|
1376
|
+
catch (err) {
|
|
1377
|
+
return json({ url, error: err.message });
|
|
1378
|
+
}
|
|
1379
|
+
// Second request — check if cache warms
|
|
1380
|
+
let secondRequest = null;
|
|
1381
|
+
try {
|
|
1382
|
+
const res2 = await safeFetch(url, { redirect: "follow" });
|
|
1383
|
+
secondRequest = {};
|
|
1384
|
+
for (const h of cacheHeaderNames) {
|
|
1385
|
+
secondRequest[h] = res2.headers.get(h);
|
|
1386
|
+
}
|
|
1387
|
+
await res2.text().catch(() => { });
|
|
1388
|
+
}
|
|
1389
|
+
catch {
|
|
1390
|
+
// Skip
|
|
1391
|
+
}
|
|
1392
|
+
// Cache key discovery: vary query string
|
|
1393
|
+
let queryStringKeyed = false;
|
|
1394
|
+
try {
|
|
1395
|
+
const bustUrl = new URL(url);
|
|
1396
|
+
bustUrl.searchParams.set("_fpmcp_bust", String(Date.now()));
|
|
1397
|
+
const res = await safeFetch(bustUrl.toString(), { redirect: "follow" });
|
|
1398
|
+
const xcache = res.headers.get("x-cache") || res.headers.get("cf-cache-status") || "";
|
|
1399
|
+
queryStringKeyed = xcache.toLowerCase().includes("miss") || xcache.toLowerCase().includes("dynamic");
|
|
1400
|
+
await res.text().catch(() => { });
|
|
1401
|
+
}
|
|
1402
|
+
catch {
|
|
1403
|
+
// Skip
|
|
1404
|
+
}
|
|
1405
|
+
// Test unkeyed headers for cache poisoning
|
|
1406
|
+
const poisonTests = [];
|
|
1407
|
+
const unkeyedHeaders = [
|
|
1408
|
+
{ header: "X-Forwarded-Host", value: "evil.com" },
|
|
1409
|
+
{ header: "X-Original-URL", value: "/admin" },
|
|
1410
|
+
{ header: "X-Rewrite-URL", value: "/admin" },
|
|
1411
|
+
{ header: "X-Forwarded-Scheme", value: "nothttps" },
|
|
1412
|
+
];
|
|
1413
|
+
for (const test of unkeyedHeaders) {
|
|
1414
|
+
try {
|
|
1415
|
+
const res = await safeFetch(url, {
|
|
1416
|
+
headers: {
|
|
1417
|
+
"User-Agent": USER_AGENT,
|
|
1418
|
+
[test.header]: test.value,
|
|
1419
|
+
},
|
|
1420
|
+
redirect: "follow",
|
|
1421
|
+
});
|
|
1422
|
+
const xc = res.headers.get("x-cache") || res.headers.get("cf-cache-status") || "";
|
|
1423
|
+
// If still a cache HIT, the header is unkeyed
|
|
1424
|
+
const isHit = xc.toLowerCase().includes("hit");
|
|
1425
|
+
poisonTests.push({
|
|
1426
|
+
header: test.header,
|
|
1427
|
+
value: test.value,
|
|
1428
|
+
keyed: !isHit,
|
|
1429
|
+
});
|
|
1430
|
+
await res.text().catch(() => { });
|
|
1431
|
+
}
|
|
1432
|
+
catch (err) {
|
|
1433
|
+
poisonTests.push({
|
|
1434
|
+
header: test.header,
|
|
1435
|
+
value: test.value,
|
|
1436
|
+
keyed: true,
|
|
1437
|
+
error: err.message,
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
// Detect CDN / cache provider
|
|
1442
|
+
let cacheProvider = null;
|
|
1443
|
+
if (baseline.cacheHeaders["cf-cache-status"])
|
|
1444
|
+
cacheProvider = "Cloudflare";
|
|
1445
|
+
else if (baseline.cacheHeaders["x-varnish"])
|
|
1446
|
+
cacheProvider = "Varnish";
|
|
1447
|
+
else if (baseline.cacheHeaders["x-fastly-request-id"])
|
|
1448
|
+
cacheProvider = "Fastly";
|
|
1449
|
+
else if (baseline.cacheHeaders["x-served-by"]?.includes("cache-"))
|
|
1450
|
+
cacheProvider = "Fastly";
|
|
1451
|
+
else if (baseline.cacheHeaders["via"]?.includes("cloudfront"))
|
|
1452
|
+
cacheProvider = "CloudFront";
|
|
1453
|
+
else if (baseline.cacheHeaders["x-cache"])
|
|
1454
|
+
cacheProvider = "CDN/Cache (unknown vendor)";
|
|
1455
|
+
const poisonable = poisonTests.filter((p) => !p.keyed && !p.error);
|
|
1456
|
+
const result = {
|
|
1457
|
+
url,
|
|
1458
|
+
cacheProvider,
|
|
1459
|
+
baseline: baseline.cacheHeaders,
|
|
1460
|
+
secondRequest,
|
|
1461
|
+
cacheKeyAnalysis: {
|
|
1462
|
+
queryStringKeyed,
|
|
1463
|
+
unkeyedHeaders: poisonTests,
|
|
1464
|
+
},
|
|
1465
|
+
summary: {
|
|
1466
|
+
cachingDetected: cacheProvider !== null || baseline.age !== null,
|
|
1467
|
+
provider: cacheProvider,
|
|
1468
|
+
poisoningRisk: poisonable.length > 0,
|
|
1469
|
+
poisonableHeaders: poisonable.map((p) => p.header),
|
|
1470
|
+
cacheControl: baseline.cacheHeaders["cache-control"],
|
|
1471
|
+
},
|
|
1472
|
+
};
|
|
1473
|
+
cache.set(cacheKey, result);
|
|
1474
|
+
return json(result);
|
|
1475
|
+
},
|
|
1476
|
+
};
|
|
1477
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1478
|
+
// Tool 11: http_security
|
|
1479
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1480
|
+
const httpSecurity = {
|
|
1481
|
+
name: "http_security",
|
|
1482
|
+
description: "Security header assessment. Evaluates HSTS, CSP, X-Frame-Options, X-Content-Type-Options, " +
|
|
1483
|
+
"Permissions-Policy, Referrer-Policy, COOP, CORP, and other security headers. " +
|
|
1484
|
+
"Missing headers are reported as findings with severity ratings.",
|
|
1485
|
+
schema: {
|
|
1486
|
+
url: z.string().url().describe("Target URL for security header assessment"),
|
|
1487
|
+
},
|
|
1488
|
+
async execute(args) {
|
|
1489
|
+
const url = args.url;
|
|
1490
|
+
const cacheKey = `http_security:${url}`;
|
|
1491
|
+
const cached = cache.get(cacheKey);
|
|
1492
|
+
if (cached)
|
|
1493
|
+
return json(cached);
|
|
1494
|
+
try {
|
|
1495
|
+
const res = await safeFetch(url, { redirect: "follow" });
|
|
1496
|
+
const securityHeaders = [
|
|
1497
|
+
{
|
|
1498
|
+
name: "Strict-Transport-Security",
|
|
1499
|
+
value: res.headers.get("strict-transport-security"),
|
|
1500
|
+
present: !!res.headers.get("strict-transport-security"),
|
|
1501
|
+
severity: "high",
|
|
1502
|
+
description: "HSTS — forces HTTPS connections, prevents SSL stripping attacks",
|
|
1503
|
+
recommendation: "max-age=31536000; includeSubDomains; preload",
|
|
1504
|
+
},
|
|
1505
|
+
{
|
|
1506
|
+
name: "Content-Security-Policy",
|
|
1507
|
+
value: res.headers.get("content-security-policy"),
|
|
1508
|
+
present: !!res.headers.get("content-security-policy"),
|
|
1509
|
+
severity: "high",
|
|
1510
|
+
description: "CSP — mitigates XSS, data injection, and clickjacking attacks",
|
|
1511
|
+
recommendation: "Define strict policy with nonces or hashes",
|
|
1512
|
+
},
|
|
1513
|
+
{
|
|
1514
|
+
name: "X-Frame-Options",
|
|
1515
|
+
value: res.headers.get("x-frame-options"),
|
|
1516
|
+
present: !!res.headers.get("x-frame-options"),
|
|
1517
|
+
severity: "medium",
|
|
1518
|
+
description: "Clickjacking protection — prevents page from being embedded in iframes",
|
|
1519
|
+
recommendation: "DENY or SAMEORIGIN",
|
|
1520
|
+
},
|
|
1521
|
+
{
|
|
1522
|
+
name: "X-Content-Type-Options",
|
|
1523
|
+
value: res.headers.get("x-content-type-options"),
|
|
1524
|
+
present: !!res.headers.get("x-content-type-options"),
|
|
1525
|
+
severity: "medium",
|
|
1526
|
+
description: "MIME type sniffing prevention",
|
|
1527
|
+
recommendation: "nosniff",
|
|
1528
|
+
},
|
|
1529
|
+
{
|
|
1530
|
+
name: "Permissions-Policy",
|
|
1531
|
+
value: res.headers.get("permissions-policy"),
|
|
1532
|
+
present: !!res.headers.get("permissions-policy"),
|
|
1533
|
+
severity: "medium",
|
|
1534
|
+
description: "Controls browser feature access (camera, microphone, geolocation, etc.)",
|
|
1535
|
+
recommendation: "Restrict unused features: camera=(), microphone=(), geolocation=()",
|
|
1536
|
+
},
|
|
1537
|
+
{
|
|
1538
|
+
name: "Referrer-Policy",
|
|
1539
|
+
value: res.headers.get("referrer-policy"),
|
|
1540
|
+
present: !!res.headers.get("referrer-policy"),
|
|
1541
|
+
severity: "low",
|
|
1542
|
+
description: "Controls how much referrer information is sent with requests",
|
|
1543
|
+
recommendation: "strict-origin-when-cross-origin or no-referrer",
|
|
1544
|
+
},
|
|
1545
|
+
{
|
|
1546
|
+
name: "Cross-Origin-Opener-Policy",
|
|
1547
|
+
value: res.headers.get("cross-origin-opener-policy"),
|
|
1548
|
+
present: !!res.headers.get("cross-origin-opener-policy"),
|
|
1549
|
+
severity: "low",
|
|
1550
|
+
description: "COOP — isolates browsing context to prevent Spectre-like attacks",
|
|
1551
|
+
recommendation: "same-origin",
|
|
1552
|
+
},
|
|
1553
|
+
{
|
|
1554
|
+
name: "Cross-Origin-Resource-Policy",
|
|
1555
|
+
value: res.headers.get("cross-origin-resource-policy"),
|
|
1556
|
+
present: !!res.headers.get("cross-origin-resource-policy"),
|
|
1557
|
+
severity: "low",
|
|
1558
|
+
description: "CORP — controls which origins can load resources",
|
|
1559
|
+
recommendation: "same-origin or cross-origin (if CDN)",
|
|
1560
|
+
},
|
|
1561
|
+
{
|
|
1562
|
+
name: "Cross-Origin-Embedder-Policy",
|
|
1563
|
+
value: res.headers.get("cross-origin-embedder-policy"),
|
|
1564
|
+
present: !!res.headers.get("cross-origin-embedder-policy"),
|
|
1565
|
+
severity: "low",
|
|
1566
|
+
description: "COEP — prevents loading cross-origin resources without explicit opt-in",
|
|
1567
|
+
recommendation: "require-corp",
|
|
1568
|
+
},
|
|
1569
|
+
{
|
|
1570
|
+
name: "X-XSS-Protection",
|
|
1571
|
+
value: res.headers.get("x-xss-protection"),
|
|
1572
|
+
present: !!res.headers.get("x-xss-protection"),
|
|
1573
|
+
severity: "info",
|
|
1574
|
+
description: "Legacy XSS filter (deprecated in modern browsers, CSP is preferred)",
|
|
1575
|
+
},
|
|
1576
|
+
];
|
|
1577
|
+
// Analyze HSTS strength
|
|
1578
|
+
let hstsAnalysis = null;
|
|
1579
|
+
const hstsValue = res.headers.get("strict-transport-security");
|
|
1580
|
+
if (hstsValue) {
|
|
1581
|
+
const maxAgeMatch = hstsValue.match(/max-age=(\d+)/i);
|
|
1582
|
+
const maxAge = maxAgeMatch ? parseInt(maxAgeMatch[1], 10) : 0;
|
|
1583
|
+
hstsAnalysis = {
|
|
1584
|
+
maxAge,
|
|
1585
|
+
maxAgeDays: Math.floor(maxAge / 86400),
|
|
1586
|
+
includeSubDomains: /includeSubDomains/i.test(hstsValue),
|
|
1587
|
+
preload: /preload/i.test(hstsValue),
|
|
1588
|
+
adequate: maxAge >= 31536000,
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
// Analyze CSP
|
|
1592
|
+
let cspAnalysis = null;
|
|
1593
|
+
const cspValue = res.headers.get("content-security-policy");
|
|
1594
|
+
if (cspValue) {
|
|
1595
|
+
const hasUnsafeInline = cspValue.includes("'unsafe-inline'");
|
|
1596
|
+
const hasUnsafeEval = cspValue.includes("'unsafe-eval'");
|
|
1597
|
+
const hasWildcard = /\s\*\s/.test(cspValue) || cspValue.includes(" * ");
|
|
1598
|
+
const directives = cspValue.split(";").map((d) => d.trim()).filter(Boolean);
|
|
1599
|
+
cspAnalysis = {
|
|
1600
|
+
directiveCount: directives.length,
|
|
1601
|
+
unsafeInline: hasUnsafeInline,
|
|
1602
|
+
unsafeEval: hasUnsafeEval,
|
|
1603
|
+
wildcardSource: hasWildcard,
|
|
1604
|
+
weaknesses: [
|
|
1605
|
+
...(hasUnsafeInline ? ["unsafe-inline allows inline scripts/styles"] : []),
|
|
1606
|
+
...(hasUnsafeEval ? ["unsafe-eval allows eval() and similar"] : []),
|
|
1607
|
+
...(hasWildcard ? ["Wildcard (*) source allows loading from any origin"] : []),
|
|
1608
|
+
],
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
// Information disclosure headers
|
|
1612
|
+
const infoDisclosure = [];
|
|
1613
|
+
const disclosureHeaders = [
|
|
1614
|
+
"server", "x-powered-by", "x-aspnet-version",
|
|
1615
|
+
"x-runtime", "x-generator", "x-drupal-cache",
|
|
1616
|
+
"x-drupal-dynamic-cache",
|
|
1617
|
+
];
|
|
1618
|
+
for (const h of disclosureHeaders) {
|
|
1619
|
+
const val = res.headers.get(h);
|
|
1620
|
+
if (val) {
|
|
1621
|
+
infoDisclosure.push({ header: h, value: val });
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
const missing = securityHeaders.filter((h) => !h.present);
|
|
1625
|
+
const present = securityHeaders.filter((h) => h.present);
|
|
1626
|
+
// Score (0-100)
|
|
1627
|
+
const totalWeight = securityHeaders.reduce((sum, h) => {
|
|
1628
|
+
const w = h.severity === "high" ? 25 : h.severity === "medium" ? 15 : h.severity === "low" ? 10 : 5;
|
|
1629
|
+
return sum + w;
|
|
1630
|
+
}, 0);
|
|
1631
|
+
const earnedWeight = present.reduce((sum, h) => {
|
|
1632
|
+
const w = h.severity === "high" ? 25 : h.severity === "medium" ? 15 : h.severity === "low" ? 10 : 5;
|
|
1633
|
+
return sum + w;
|
|
1634
|
+
}, 0);
|
|
1635
|
+
const score = Math.round((earnedWeight / totalWeight) * 100);
|
|
1636
|
+
const result = {
|
|
1637
|
+
url,
|
|
1638
|
+
score,
|
|
1639
|
+
grade: score >= 90 ? "A" : score >= 75 ? "B" : score >= 60 ? "C" : score >= 40 ? "D" : "F",
|
|
1640
|
+
presentHeaders: present.map((h) => ({ name: h.name, value: h.value })),
|
|
1641
|
+
missingHeaders: missing.map((h) => ({
|
|
1642
|
+
name: h.name,
|
|
1643
|
+
severity: h.severity,
|
|
1644
|
+
description: h.description,
|
|
1645
|
+
recommendation: h.recommendation,
|
|
1646
|
+
})),
|
|
1647
|
+
hstsAnalysis,
|
|
1648
|
+
cspAnalysis,
|
|
1649
|
+
informationDisclosure: infoDisclosure,
|
|
1650
|
+
};
|
|
1651
|
+
cache.set(cacheKey, result);
|
|
1652
|
+
return json(result);
|
|
1653
|
+
}
|
|
1654
|
+
catch (err) {
|
|
1655
|
+
return json({ url, error: err.message });
|
|
1656
|
+
}
|
|
1657
|
+
},
|
|
1658
|
+
};
|
|
1659
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1660
|
+
// Tool 12: http_timing
|
|
1661
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1662
|
+
const httpTiming = {
|
|
1663
|
+
name: "http_timing",
|
|
1664
|
+
description: "Response timing analysis. Sends 10 identical GET requests and measures response times. " +
|
|
1665
|
+
"Computes mean and standard deviation. Low stddev = bare metal / dedicated. High stddev = " +
|
|
1666
|
+
"shared / cloud. High first request, low subsequent = serverless cold start pattern.",
|
|
1667
|
+
schema: {
|
|
1668
|
+
url: z.string().url().describe("Target URL for response timing analysis"),
|
|
1669
|
+
},
|
|
1670
|
+
async execute(args) {
|
|
1671
|
+
const url = args.url;
|
|
1672
|
+
const cacheKey = `http_timing:${url}`;
|
|
1673
|
+
const cached = cache.get(cacheKey);
|
|
1674
|
+
if (cached)
|
|
1675
|
+
return json(cached);
|
|
1676
|
+
const timings = [];
|
|
1677
|
+
const requestCount = 10;
|
|
1678
|
+
for (let i = 0; i < requestCount; i++) {
|
|
1679
|
+
try {
|
|
1680
|
+
const start = performance.now();
|
|
1681
|
+
const res = await safeFetch(url, { redirect: "follow" });
|
|
1682
|
+
await res.text().catch(() => { });
|
|
1683
|
+
const elapsed = performance.now() - start;
|
|
1684
|
+
timings.push(Math.round(elapsed * 100) / 100);
|
|
1685
|
+
}
|
|
1686
|
+
catch {
|
|
1687
|
+
timings.push(-1); // Mark failed requests
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
const validTimings = timings.filter((t) => t >= 0);
|
|
1691
|
+
if (validTimings.length === 0) {
|
|
1692
|
+
return json({ url, error: "All requests failed", timings });
|
|
1693
|
+
}
|
|
1694
|
+
const mean = validTimings.reduce((a, b) => a + b, 0) / validTimings.length;
|
|
1695
|
+
const variance = validTimings.reduce((sum, t) => sum + Math.pow(t - mean, 2), 0) /
|
|
1696
|
+
validTimings.length;
|
|
1697
|
+
const stddev = Math.sqrt(variance);
|
|
1698
|
+
const min = Math.min(...validTimings);
|
|
1699
|
+
const max = Math.max(...validTimings);
|
|
1700
|
+
const coefficientOfVariation = mean > 0 ? (stddev / mean) * 100 : 0;
|
|
1701
|
+
// Detect cold start pattern: first request significantly slower than rest
|
|
1702
|
+
const firstTiming = validTimings[0];
|
|
1703
|
+
const restMean = validTimings.length > 1
|
|
1704
|
+
? validTimings.slice(1).reduce((a, b) => a + b, 0) /
|
|
1705
|
+
(validTimings.length - 1)
|
|
1706
|
+
: mean;
|
|
1707
|
+
const coldStartRatio = firstTiming / restMean;
|
|
1708
|
+
const coldStartDetected = coldStartRatio > 2.0 && validTimings.length > 2;
|
|
1709
|
+
let infrastructureAssessment;
|
|
1710
|
+
if (coldStartDetected) {
|
|
1711
|
+
infrastructureAssessment =
|
|
1712
|
+
"Serverless / Lambda pattern detected — first request significantly slower (cold start), subsequent requests fast";
|
|
1713
|
+
}
|
|
1714
|
+
else if (coefficientOfVariation < 10) {
|
|
1715
|
+
infrastructureAssessment =
|
|
1716
|
+
"Consistent timing (low CV) — likely bare metal, dedicated server, or well-provisioned infrastructure";
|
|
1717
|
+
}
|
|
1718
|
+
else if (coefficientOfVariation < 30) {
|
|
1719
|
+
infrastructureAssessment =
|
|
1720
|
+
"Moderate variance — likely cloud VM or containerized deployment";
|
|
1721
|
+
}
|
|
1722
|
+
else {
|
|
1723
|
+
infrastructureAssessment =
|
|
1724
|
+
"High variance — shared hosting, heavily loaded server, or multi-region CDN with variable latency";
|
|
1725
|
+
}
|
|
1726
|
+
const result = {
|
|
1727
|
+
url,
|
|
1728
|
+
requestCount,
|
|
1729
|
+
successfulRequests: validTimings.length,
|
|
1730
|
+
timingsMs: timings,
|
|
1731
|
+
statistics: {
|
|
1732
|
+
meanMs: Math.round(mean * 100) / 100,
|
|
1733
|
+
stddevMs: Math.round(stddev * 100) / 100,
|
|
1734
|
+
minMs: min,
|
|
1735
|
+
maxMs: max,
|
|
1736
|
+
coefficientOfVariation: Math.round(coefficientOfVariation * 100) / 100,
|
|
1737
|
+
},
|
|
1738
|
+
coldStartAnalysis: {
|
|
1739
|
+
firstRequestMs: firstTiming,
|
|
1740
|
+
subsequentMeanMs: Math.round(restMean * 100) / 100,
|
|
1741
|
+
ratio: Math.round(coldStartRatio * 100) / 100,
|
|
1742
|
+
coldStartDetected,
|
|
1743
|
+
},
|
|
1744
|
+
intelligence: {
|
|
1745
|
+
infrastructureAssessment,
|
|
1746
|
+
},
|
|
1747
|
+
};
|
|
1748
|
+
cache.set(cacheKey, result);
|
|
1749
|
+
return json(result);
|
|
1750
|
+
},
|
|
1751
|
+
};
|
|
1752
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1753
|
+
// Tool 13: http_server_timing
|
|
1754
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1755
|
+
const httpServerTiming = {
|
|
1756
|
+
name: "http_server_timing",
|
|
1757
|
+
description: "Server-Timing header intelligence. Parses Server-Timing for CDN layer information, " +
|
|
1758
|
+
"backend service names, cache architecture details. Reveals internal service topology " +
|
|
1759
|
+
"and processing pipeline stages.",
|
|
1760
|
+
schema: {
|
|
1761
|
+
url: z.string().url().describe("Target URL for Server-Timing header analysis"),
|
|
1762
|
+
},
|
|
1763
|
+
async execute(args) {
|
|
1764
|
+
const url = args.url;
|
|
1765
|
+
const cacheKey = `http_server_timing:${url}`;
|
|
1766
|
+
const cached = cache.get(cacheKey);
|
|
1767
|
+
if (cached)
|
|
1768
|
+
return json(cached);
|
|
1769
|
+
try {
|
|
1770
|
+
const res = await safeFetch(url, { redirect: "follow" });
|
|
1771
|
+
const serverTiming = res.headers.get("server-timing");
|
|
1772
|
+
if (!serverTiming) {
|
|
1773
|
+
const result = {
|
|
1774
|
+
url,
|
|
1775
|
+
serverTiming: null,
|
|
1776
|
+
analysis: "No Server-Timing header present",
|
|
1777
|
+
};
|
|
1778
|
+
cache.set(cacheKey, result);
|
|
1779
|
+
return json(result);
|
|
1780
|
+
}
|
|
1781
|
+
// Parse Server-Timing: metric-name;desc="description";dur=123.4, metric2;dur=56
|
|
1782
|
+
const metrics = [];
|
|
1783
|
+
const entries = serverTiming.split(",").map((e) => e.trim());
|
|
1784
|
+
for (const entry of entries) {
|
|
1785
|
+
const parts = entry.split(";").map((p) => p.trim());
|
|
1786
|
+
const name = parts[0];
|
|
1787
|
+
let description = null;
|
|
1788
|
+
let durationMs = null;
|
|
1789
|
+
for (let i = 1; i < parts.length; i++) {
|
|
1790
|
+
const part = parts[i];
|
|
1791
|
+
if (part.startsWith("desc=")) {
|
|
1792
|
+
description = part.slice(5).replace(/^"|"$/g, "");
|
|
1793
|
+
}
|
|
1794
|
+
else if (part.startsWith("dur=")) {
|
|
1795
|
+
durationMs = parseFloat(part.slice(4));
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
metrics.push({ name, description, durationMs, raw: entry });
|
|
1799
|
+
}
|
|
1800
|
+
// Intelligence extraction
|
|
1801
|
+
const serviceNames = metrics.map((m) => m.name);
|
|
1802
|
+
const totalDuration = metrics
|
|
1803
|
+
.filter((m) => m.durationMs !== null)
|
|
1804
|
+
.reduce((sum, m) => sum + (m.durationMs ?? 0), 0);
|
|
1805
|
+
// Detect CDN indicators
|
|
1806
|
+
const cdnIndicators = [];
|
|
1807
|
+
for (const m of metrics) {
|
|
1808
|
+
const lower = (m.name + " " + (m.description || "")).toLowerCase();
|
|
1809
|
+
if (lower.includes("cdn") || lower.includes("edge") || lower.includes("pop")) {
|
|
1810
|
+
cdnIndicators.push(`${m.name}: CDN/edge processing`);
|
|
1811
|
+
}
|
|
1812
|
+
if (lower.includes("cache") || lower.includes("hit") || lower.includes("miss")) {
|
|
1813
|
+
cdnIndicators.push(`${m.name}: Cache layer`);
|
|
1814
|
+
}
|
|
1815
|
+
if (lower.includes("origin") || lower.includes("backend") || lower.includes("upstream")) {
|
|
1816
|
+
cdnIndicators.push(`${m.name}: Origin/backend fetch`);
|
|
1817
|
+
}
|
|
1818
|
+
if (lower.includes("waf") || lower.includes("firewall") || lower.includes("security")) {
|
|
1819
|
+
cdnIndicators.push(`${m.name}: WAF/security processing`);
|
|
1820
|
+
}
|
|
1821
|
+
if (lower.includes("db") || lower.includes("database") || lower.includes("sql") || lower.includes("redis")) {
|
|
1822
|
+
cdnIndicators.push(`${m.name}: Database layer`);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
const result = {
|
|
1826
|
+
url,
|
|
1827
|
+
serverTiming,
|
|
1828
|
+
metrics,
|
|
1829
|
+
intelligence: {
|
|
1830
|
+
totalProcessingMs: Math.round(totalDuration * 100) / 100,
|
|
1831
|
+
serviceNames,
|
|
1832
|
+
cdnIndicators,
|
|
1833
|
+
internalTopology: cdnIndicators.length > 0
|
|
1834
|
+
? "Server-Timing reveals internal processing pipeline and service names"
|
|
1835
|
+
: "No obvious infrastructure indicators in metric names",
|
|
1836
|
+
},
|
|
1837
|
+
};
|
|
1838
|
+
cache.set(cacheKey, result);
|
|
1839
|
+
return json(result);
|
|
1840
|
+
}
|
|
1841
|
+
catch (err) {
|
|
1842
|
+
return json({ url, error: err.message });
|
|
1843
|
+
}
|
|
1844
|
+
},
|
|
1845
|
+
};
|
|
1846
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1847
|
+
// Tool 14: http_resource_hints
|
|
1848
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1849
|
+
const httpResourceHints = {
|
|
1850
|
+
name: "http_resource_hints",
|
|
1851
|
+
description: "Resource hint analysis. Parses <link rel=\"preconnect\">, <link rel=\"dns-prefetch\">, " +
|
|
1852
|
+
"<link rel=\"preload\"> and Link HTTP headers. Maps third-party dependency graph and " +
|
|
1853
|
+
"identifies known services (Google Fonts, Stripe, Sentry, Auth0, Segment, etc.).",
|
|
1854
|
+
schema: {
|
|
1855
|
+
url: z.string().url().describe("Target URL for resource hint and third-party dependency analysis"),
|
|
1856
|
+
},
|
|
1857
|
+
async execute(args) {
|
|
1858
|
+
const url = args.url;
|
|
1859
|
+
const cacheKey = `http_resource_hints:${url}`;
|
|
1860
|
+
const cached = cache.get(cacheKey);
|
|
1861
|
+
if (cached)
|
|
1862
|
+
return json(cached);
|
|
1863
|
+
try {
|
|
1864
|
+
const res = await safeFetch(url, { redirect: "follow" });
|
|
1865
|
+
const html = await res.text();
|
|
1866
|
+
const linkHeader = res.headers.get("link");
|
|
1867
|
+
const $ = cheerio.load(html);
|
|
1868
|
+
const hints = [];
|
|
1869
|
+
// Parse HTML link elements
|
|
1870
|
+
const hintTypes = ["preconnect", "dns-prefetch", "preload", "prefetch", "modulepreload"];
|
|
1871
|
+
for (const rel of hintTypes) {
|
|
1872
|
+
$(`link[rel="${rel}"]`).each((_i, el) => {
|
|
1873
|
+
const href = $(el).attr("href");
|
|
1874
|
+
if (!href)
|
|
1875
|
+
return;
|
|
1876
|
+
let domain = null;
|
|
1877
|
+
try {
|
|
1878
|
+
domain = new URL(href, url).hostname;
|
|
1879
|
+
}
|
|
1880
|
+
catch {
|
|
1881
|
+
// Relative URL or invalid
|
|
1882
|
+
}
|
|
1883
|
+
hints.push({
|
|
1884
|
+
type: rel,
|
|
1885
|
+
href,
|
|
1886
|
+
source: "html",
|
|
1887
|
+
domain,
|
|
1888
|
+
knownService: domain ? (KNOWN_SERVICES[domain] ?? null) : null,
|
|
1889
|
+
as: $(el).attr("as") || undefined,
|
|
1890
|
+
crossorigin: $(el).attr("crossorigin") || undefined,
|
|
1891
|
+
});
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
// Parse Link HTTP header
|
|
1895
|
+
if (linkHeader) {
|
|
1896
|
+
const linkParts = linkHeader.split(",").map((p) => p.trim());
|
|
1897
|
+
for (const part of linkParts) {
|
|
1898
|
+
const urlMatch = part.match(/<([^>]+)>/);
|
|
1899
|
+
const relMatch = part.match(/rel="([^"]+)"/);
|
|
1900
|
+
if (urlMatch && relMatch) {
|
|
1901
|
+
const href = urlMatch[1];
|
|
1902
|
+
const rel = relMatch[1];
|
|
1903
|
+
let domain = null;
|
|
1904
|
+
try {
|
|
1905
|
+
domain = new URL(href, url).hostname;
|
|
1906
|
+
}
|
|
1907
|
+
catch {
|
|
1908
|
+
// Invalid URL
|
|
1909
|
+
}
|
|
1910
|
+
hints.push({
|
|
1911
|
+
type: rel,
|
|
1912
|
+
href,
|
|
1913
|
+
source: "header",
|
|
1914
|
+
domain,
|
|
1915
|
+
knownService: domain ? (KNOWN_SERVICES[domain] ?? null) : null,
|
|
1916
|
+
});
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
// Build dependency graph
|
|
1921
|
+
const thirdPartyDomains = [
|
|
1922
|
+
...new Set(hints
|
|
1923
|
+
.filter((h) => h.domain && h.domain !== new URL(url).hostname)
|
|
1924
|
+
.map((h) => h.domain)),
|
|
1925
|
+
];
|
|
1926
|
+
const identifiedServices = hints
|
|
1927
|
+
.filter((h) => h.knownService)
|
|
1928
|
+
.map((h) => ({
|
|
1929
|
+
service: h.knownService,
|
|
1930
|
+
domain: h.domain,
|
|
1931
|
+
hintType: h.type,
|
|
1932
|
+
}));
|
|
1933
|
+
// Deduplicate identified services by service name
|
|
1934
|
+
const uniqueServices = [
|
|
1935
|
+
...new Map(identifiedServices.map((s) => [s.service, s])).values(),
|
|
1936
|
+
];
|
|
1937
|
+
const result = {
|
|
1938
|
+
url,
|
|
1939
|
+
hints,
|
|
1940
|
+
dependencyGraph: {
|
|
1941
|
+
thirdPartyDomains,
|
|
1942
|
+
thirdPartyCount: thirdPartyDomains.length,
|
|
1943
|
+
identifiedServices: uniqueServices,
|
|
1944
|
+
unidentifiedDomains: thirdPartyDomains.filter((d) => !KNOWN_SERVICES[d]),
|
|
1945
|
+
},
|
|
1946
|
+
summary: {
|
|
1947
|
+
totalHints: hints.length,
|
|
1948
|
+
preconnect: hints.filter((h) => h.type === "preconnect").length,
|
|
1949
|
+
dnsPrefetch: hints.filter((h) => h.type === "dns-prefetch").length,
|
|
1950
|
+
preload: hints.filter((h) => h.type === "preload").length,
|
|
1951
|
+
prefetch: hints.filter((h) => h.type === "prefetch").length,
|
|
1952
|
+
},
|
|
1953
|
+
};
|
|
1954
|
+
cache.set(cacheKey, result);
|
|
1955
|
+
return json(result);
|
|
1956
|
+
}
|
|
1957
|
+
catch (err) {
|
|
1958
|
+
return json({ url, error: err.message });
|
|
1959
|
+
}
|
|
1960
|
+
},
|
|
1961
|
+
};
|
|
1962
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1963
|
+
// Tool 15: http_keepalive
|
|
1964
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1965
|
+
const httpKeepalive = {
|
|
1966
|
+
name: "http_keepalive",
|
|
1967
|
+
description: "Connection keep-alive behavior analysis. Measures server idle timeout, max requests " +
|
|
1968
|
+
"per connection (via Keep-Alive header parsing). Detects HTTP/2 behavior and connection " +
|
|
1969
|
+
"management strategies.",
|
|
1970
|
+
schema: {
|
|
1971
|
+
url: z.string().url().describe("Target URL for keep-alive behavior analysis"),
|
|
1972
|
+
},
|
|
1973
|
+
async execute(args) {
|
|
1974
|
+
const url = args.url;
|
|
1975
|
+
const cacheKey = `http_keepalive:${url}`;
|
|
1976
|
+
const cached = cache.get(cacheKey);
|
|
1977
|
+
if (cached)
|
|
1978
|
+
return json(cached);
|
|
1979
|
+
try {
|
|
1980
|
+
// Send request with Connection: keep-alive
|
|
1981
|
+
const res = await safeFetch(url, {
|
|
1982
|
+
headers: {
|
|
1983
|
+
"User-Agent": USER_AGENT,
|
|
1984
|
+
Connection: "keep-alive",
|
|
1985
|
+
},
|
|
1986
|
+
redirect: "follow",
|
|
1987
|
+
});
|
|
1988
|
+
const connectionHeader = res.headers.get("connection");
|
|
1989
|
+
const keepAliveHeader = res.headers.get("keep-alive");
|
|
1990
|
+
const altSvc = res.headers.get("alt-svc");
|
|
1991
|
+
// Parse Keep-Alive header: timeout=5, max=100
|
|
1992
|
+
let timeout = null;
|
|
1993
|
+
let maxRequests = null;
|
|
1994
|
+
if (keepAliveHeader) {
|
|
1995
|
+
const timeoutMatch = keepAliveHeader.match(/timeout=(\d+)/i);
|
|
1996
|
+
const maxMatch = keepAliveHeader.match(/max=(\d+)/i);
|
|
1997
|
+
if (timeoutMatch)
|
|
1998
|
+
timeout = parseInt(timeoutMatch[1], 10);
|
|
1999
|
+
if (maxMatch)
|
|
2000
|
+
maxRequests = parseInt(maxMatch[1], 10);
|
|
2001
|
+
}
|
|
2002
|
+
await res.text().catch(() => { });
|
|
2003
|
+
// Also test with Connection: close
|
|
2004
|
+
let closeResponse = null;
|
|
2005
|
+
try {
|
|
2006
|
+
const closeRes = await safeFetch(url, {
|
|
2007
|
+
headers: {
|
|
2008
|
+
"User-Agent": USER_AGENT,
|
|
2009
|
+
Connection: "close",
|
|
2010
|
+
},
|
|
2011
|
+
redirect: "follow",
|
|
2012
|
+
});
|
|
2013
|
+
const closeConn = closeRes.headers.get("connection");
|
|
2014
|
+
await closeRes.text().catch(() => { });
|
|
2015
|
+
closeResponse = {
|
|
2016
|
+
connectionHeader: closeConn,
|
|
2017
|
+
serverRespected: closeConn?.toLowerCase() === "close",
|
|
2018
|
+
};
|
|
2019
|
+
}
|
|
2020
|
+
catch {
|
|
2021
|
+
// Skip
|
|
2022
|
+
}
|
|
2023
|
+
// HTTP/2 detection from Alt-Svc
|
|
2024
|
+
let http2Indicators = [];
|
|
2025
|
+
if (altSvc) {
|
|
2026
|
+
if (altSvc.includes("h2"))
|
|
2027
|
+
http2Indicators.push("Alt-Svc advertises HTTP/2 (h2)");
|
|
2028
|
+
if (altSvc.includes("h3"))
|
|
2029
|
+
http2Indicators.push("Alt-Svc advertises HTTP/3 (h3/QUIC)");
|
|
2030
|
+
}
|
|
2031
|
+
let assessment;
|
|
2032
|
+
if (keepAliveHeader) {
|
|
2033
|
+
assessment = `Keep-Alive configured: timeout=${timeout ?? "unknown"}s, max=${maxRequests ?? "unknown"} requests`;
|
|
2034
|
+
}
|
|
2035
|
+
else if (connectionHeader?.toLowerCase() === "keep-alive") {
|
|
2036
|
+
assessment =
|
|
2037
|
+
"Server supports keep-alive but does not expose timeout/max parameters";
|
|
2038
|
+
}
|
|
2039
|
+
else if (connectionHeader?.toLowerCase() === "close") {
|
|
2040
|
+
assessment =
|
|
2041
|
+
"Server closes connections after each request — may be HTTP/1.0 compatible or behind a proxy that disables keep-alive";
|
|
2042
|
+
}
|
|
2043
|
+
else {
|
|
2044
|
+
assessment =
|
|
2045
|
+
"No explicit Connection header — default behavior applies (HTTP/1.1 defaults to keep-alive)";
|
|
2046
|
+
}
|
|
2047
|
+
const result = {
|
|
2048
|
+
url,
|
|
2049
|
+
connection: connectionHeader,
|
|
2050
|
+
keepAlive: {
|
|
2051
|
+
header: keepAliveHeader,
|
|
2052
|
+
timeout,
|
|
2053
|
+
maxRequests,
|
|
2054
|
+
},
|
|
2055
|
+
altSvc,
|
|
2056
|
+
http2Indicators,
|
|
2057
|
+
closeTest: closeResponse,
|
|
2058
|
+
assessment,
|
|
2059
|
+
};
|
|
2060
|
+
cache.set(cacheKey, result);
|
|
2061
|
+
return json(result);
|
|
2062
|
+
}
|
|
2063
|
+
catch (err) {
|
|
2064
|
+
return json({ url, error: err.message });
|
|
2065
|
+
}
|
|
2066
|
+
},
|
|
2067
|
+
};
|
|
2068
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
2069
|
+
// Tool 16: http_nel
|
|
2070
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
2071
|
+
const httpNel = {
|
|
2072
|
+
name: "http_nel",
|
|
2073
|
+
description: "NEL (Network Error Logging), Report-To, Reporting-Endpoints, and Expect-CT header analysis. " +
|
|
2074
|
+
"Extracts monitoring provider information, CDN/security vendor confirmation from reporting " +
|
|
2075
|
+
"endpoints and error logging configuration.",
|
|
2076
|
+
schema: {
|
|
2077
|
+
url: z.string().url().describe("Target URL for NEL / reporting header analysis"),
|
|
2078
|
+
},
|
|
2079
|
+
async execute(args) {
|
|
2080
|
+
const url = args.url;
|
|
2081
|
+
const cacheKey = `http_nel:${url}`;
|
|
2082
|
+
const cached = cache.get(cacheKey);
|
|
2083
|
+
if (cached)
|
|
2084
|
+
return json(cached);
|
|
2085
|
+
try {
|
|
2086
|
+
const res = await safeFetch(url, { redirect: "follow" });
|
|
2087
|
+
const nel = res.headers.get("nel");
|
|
2088
|
+
const reportTo = res.headers.get("report-to");
|
|
2089
|
+
const reportingEndpoints = res.headers.get("reporting-endpoints");
|
|
2090
|
+
const expectCt = res.headers.get("expect-ct");
|
|
2091
|
+
const analysis = {
|
|
2092
|
+
nel: null,
|
|
2093
|
+
reportTo: null,
|
|
2094
|
+
reportingEndpoints: null,
|
|
2095
|
+
expectCt: null,
|
|
2096
|
+
providers: [],
|
|
2097
|
+
endpoints: [],
|
|
2098
|
+
};
|
|
2099
|
+
// Parse NEL header (JSON)
|
|
2100
|
+
if (nel) {
|
|
2101
|
+
try {
|
|
2102
|
+
analysis.nel = JSON.parse(nel);
|
|
2103
|
+
}
|
|
2104
|
+
catch {
|
|
2105
|
+
analysis.nel = { raw: nel, parseError: true };
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
// Parse Report-To header (JSON, may be array)
|
|
2109
|
+
if (reportTo) {
|
|
2110
|
+
try {
|
|
2111
|
+
// Report-To can be multiple JSON objects separated by commas
|
|
2112
|
+
// Each object is a separate group
|
|
2113
|
+
const groups = reportTo.split(/(?<=\})\s*,\s*(?=\{)/).map((g) => {
|
|
2114
|
+
try {
|
|
2115
|
+
return JSON.parse(g.trim());
|
|
2116
|
+
}
|
|
2117
|
+
catch {
|
|
2118
|
+
return { raw: g.trim(), parseError: true };
|
|
2119
|
+
}
|
|
2120
|
+
});
|
|
2121
|
+
analysis.reportTo = groups;
|
|
2122
|
+
// Extract endpoint URLs
|
|
2123
|
+
for (const group of groups) {
|
|
2124
|
+
if (group.endpoints && Array.isArray(group.endpoints)) {
|
|
2125
|
+
for (const ep of group.endpoints) {
|
|
2126
|
+
if (ep.url) {
|
|
2127
|
+
analysis.endpoints.push(ep.url);
|
|
2128
|
+
try {
|
|
2129
|
+
const domain = new URL(ep.url).hostname;
|
|
2130
|
+
if (domain.includes("cloudflare"))
|
|
2131
|
+
analysis.providers.push("Cloudflare");
|
|
2132
|
+
else if (domain.includes("sentry"))
|
|
2133
|
+
analysis.providers.push("Sentry");
|
|
2134
|
+
else if (domain.includes("report-uri"))
|
|
2135
|
+
analysis.providers.push("Report URI");
|
|
2136
|
+
else if (domain.includes("datadog"))
|
|
2137
|
+
analysis.providers.push("Datadog");
|
|
2138
|
+
else if (domain.includes("newrelic"))
|
|
2139
|
+
analysis.providers.push("New Relic");
|
|
2140
|
+
else
|
|
2141
|
+
analysis.providers.push(domain);
|
|
2142
|
+
}
|
|
2143
|
+
catch {
|
|
2144
|
+
// Invalid URL
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
catch {
|
|
2152
|
+
analysis.reportTo = { raw: reportTo, parseError: true };
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
// Parse Reporting-Endpoints header (key=value pairs)
|
|
2156
|
+
if (reportingEndpoints) {
|
|
2157
|
+
const endpoints = {};
|
|
2158
|
+
const parts = reportingEndpoints.split(",").map((p) => p.trim());
|
|
2159
|
+
for (const part of parts) {
|
|
2160
|
+
const eqIdx = part.indexOf("=");
|
|
2161
|
+
if (eqIdx > 0) {
|
|
2162
|
+
const key = part.slice(0, eqIdx).trim();
|
|
2163
|
+
const value = part.slice(eqIdx + 1).trim().replace(/^"|"$/g, "");
|
|
2164
|
+
endpoints[key] = value;
|
|
2165
|
+
analysis.endpoints.push(value);
|
|
2166
|
+
try {
|
|
2167
|
+
const domain = new URL(value).hostname;
|
|
2168
|
+
if (domain.includes("cloudflare"))
|
|
2169
|
+
analysis.providers.push("Cloudflare");
|
|
2170
|
+
else if (domain.includes("sentry"))
|
|
2171
|
+
analysis.providers.push("Sentry");
|
|
2172
|
+
else if (domain.includes("report-uri"))
|
|
2173
|
+
analysis.providers.push("Report URI");
|
|
2174
|
+
else if (domain.includes("datadog"))
|
|
2175
|
+
analysis.providers.push("Datadog");
|
|
2176
|
+
else
|
|
2177
|
+
analysis.providers.push(domain);
|
|
2178
|
+
}
|
|
2179
|
+
catch {
|
|
2180
|
+
// Invalid URL
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
analysis.reportingEndpoints = endpoints;
|
|
2185
|
+
}
|
|
2186
|
+
// Parse Expect-CT header
|
|
2187
|
+
if (expectCt) {
|
|
2188
|
+
const parts = {};
|
|
2189
|
+
for (const part of expectCt.split(",").map((p) => p.trim())) {
|
|
2190
|
+
if (part.startsWith("max-age=")) {
|
|
2191
|
+
parts["max-age"] = part.split("=")[1];
|
|
2192
|
+
}
|
|
2193
|
+
else if (part.startsWith("report-uri=")) {
|
|
2194
|
+
const uri = part.split("=").slice(1).join("=").replace(/^"|"$/g, "");
|
|
2195
|
+
parts["report-uri"] = uri;
|
|
2196
|
+
analysis.endpoints.push(uri);
|
|
2197
|
+
}
|
|
2198
|
+
else if (part === "enforce") {
|
|
2199
|
+
parts.enforce = true;
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
analysis.expectCt = parts;
|
|
2203
|
+
}
|
|
2204
|
+
// Deduplicate providers
|
|
2205
|
+
analysis.providers = [...new Set(analysis.providers)];
|
|
2206
|
+
analysis.endpoints = [...new Set(analysis.endpoints)];
|
|
2207
|
+
const hasReporting = nel !== null ||
|
|
2208
|
+
reportTo !== null ||
|
|
2209
|
+
reportingEndpoints !== null ||
|
|
2210
|
+
expectCt !== null;
|
|
2211
|
+
const result = {
|
|
2212
|
+
url,
|
|
2213
|
+
headers: {
|
|
2214
|
+
nel: nel ?? null,
|
|
2215
|
+
reportTo: reportTo ?? null,
|
|
2216
|
+
reportingEndpoints: reportingEndpoints ?? null,
|
|
2217
|
+
expectCt: expectCt ?? null,
|
|
2218
|
+
},
|
|
2219
|
+
analysis,
|
|
2220
|
+
summary: {
|
|
2221
|
+
reportingConfigured: hasReporting,
|
|
2222
|
+
monitoringProviders: analysis.providers,
|
|
2223
|
+
reportingEndpoints: analysis.endpoints,
|
|
2224
|
+
insight: hasReporting
|
|
2225
|
+
? `Reporting configured — monitored by: ${analysis.providers.join(", ") || "unknown provider"}`
|
|
2226
|
+
: "No NEL/Report-To/Reporting-Endpoints/Expect-CT headers present",
|
|
2227
|
+
},
|
|
2228
|
+
};
|
|
2229
|
+
cache.set(cacheKey, result);
|
|
2230
|
+
return json(result);
|
|
2231
|
+
}
|
|
2232
|
+
catch (err) {
|
|
2233
|
+
return json({ url, error: err.message });
|
|
2234
|
+
}
|
|
2235
|
+
},
|
|
2236
|
+
};
|
|
2237
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
2238
|
+
// Tool 17: http_redirect
|
|
2239
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
2240
|
+
const httpRedirect = {
|
|
2241
|
+
name: "http_redirect",
|
|
2242
|
+
description: "Full redirect chain analysis. Follows all 3xx responses manually (no auto-follow). " +
|
|
2243
|
+
"Maps complete redirection path. Detects CDN redirect patterns, WAF challenge redirects, " +
|
|
2244
|
+
"and authentication redirects. Extracts intermediate servers from Via/Server headers at each hop.",
|
|
2245
|
+
schema: {
|
|
2246
|
+
url: z.string().url().describe("Target URL for redirect chain analysis"),
|
|
2247
|
+
},
|
|
2248
|
+
async execute(args) {
|
|
2249
|
+
const url = args.url;
|
|
2250
|
+
const cacheKey = `http_redirect:${url}`;
|
|
2251
|
+
const cached = cache.get(cacheKey);
|
|
2252
|
+
if (cached)
|
|
2253
|
+
return json(cached);
|
|
2254
|
+
const chain = [];
|
|
2255
|
+
let currentUrl = url;
|
|
2256
|
+
const maxRedirects = 20;
|
|
2257
|
+
const visitedUrls = new Set();
|
|
2258
|
+
for (let step = 0; step < maxRedirects; step++) {
|
|
2259
|
+
if (visitedUrls.has(currentUrl)) {
|
|
2260
|
+
chain.push({
|
|
2261
|
+
step,
|
|
2262
|
+
url: currentUrl,
|
|
2263
|
+
status: 0,
|
|
2264
|
+
statusText: "REDIRECT LOOP DETECTED",
|
|
2265
|
+
location: null,
|
|
2266
|
+
serverHeader: null,
|
|
2267
|
+
viaHeader: null,
|
|
2268
|
+
setCookies: [],
|
|
2269
|
+
headers: {},
|
|
2270
|
+
pattern: "redirect-loop",
|
|
2271
|
+
});
|
|
2272
|
+
break;
|
|
2273
|
+
}
|
|
2274
|
+
visitedUrls.add(currentUrl);
|
|
2275
|
+
try {
|
|
2276
|
+
const res = await safeFetch(currentUrl, { redirect: "manual" });
|
|
2277
|
+
const location = res.headers.get("location");
|
|
2278
|
+
const serverHeader = res.headers.get("server");
|
|
2279
|
+
const viaHeader = res.headers.get("via");
|
|
2280
|
+
// Collect Set-Cookie headers
|
|
2281
|
+
const setCookies = [];
|
|
2282
|
+
res.headers.forEach((value, key) => {
|
|
2283
|
+
if (key.toLowerCase() === "set-cookie") {
|
|
2284
|
+
setCookies.push(value);
|
|
2285
|
+
}
|
|
2286
|
+
});
|
|
2287
|
+
if (setCookies.length === 0) {
|
|
2288
|
+
const raw = res.headers.get("set-cookie");
|
|
2289
|
+
if (raw)
|
|
2290
|
+
setCookies.push(raw);
|
|
2291
|
+
}
|
|
2292
|
+
// Collect interesting headers
|
|
2293
|
+
const interestingHeaders = {};
|
|
2294
|
+
const headerNames = [
|
|
2295
|
+
"server", "via", "x-powered-by", "x-forwarded-for",
|
|
2296
|
+
"cf-ray", "x-cache", "x-amz-cf-id", "x-served-by",
|
|
2297
|
+
];
|
|
2298
|
+
for (const h of headerNames) {
|
|
2299
|
+
const val = res.headers.get(h);
|
|
2300
|
+
if (val)
|
|
2301
|
+
interestingHeaders[h] = val;
|
|
2302
|
+
}
|
|
2303
|
+
// Detect redirect patterns
|
|
2304
|
+
let pattern;
|
|
2305
|
+
if (res.status === 301)
|
|
2306
|
+
pattern = "permanent-redirect";
|
|
2307
|
+
else if (res.status === 302)
|
|
2308
|
+
pattern = "temporary-redirect";
|
|
2309
|
+
else if (res.status === 303)
|
|
2310
|
+
pattern = "see-other";
|
|
2311
|
+
else if (res.status === 307)
|
|
2312
|
+
pattern = "temporary-redirect-preserving-method";
|
|
2313
|
+
else if (res.status === 308)
|
|
2314
|
+
pattern = "permanent-redirect-preserving-method";
|
|
2315
|
+
// Detect specific redirect patterns
|
|
2316
|
+
if (location) {
|
|
2317
|
+
if (location.includes("/login") || location.includes("/auth") || location.includes("/signin")) {
|
|
2318
|
+
pattern = "authentication-redirect";
|
|
2319
|
+
}
|
|
2320
|
+
else if (location.includes("challenge") || location.includes("captcha")) {
|
|
2321
|
+
pattern = "waf-challenge-redirect";
|
|
2322
|
+
}
|
|
2323
|
+
else if (location.startsWith("https://") &&
|
|
2324
|
+
currentUrl.startsWith("http://") &&
|
|
2325
|
+
new URL(location).hostname === new URL(currentUrl).hostname) {
|
|
2326
|
+
pattern = "http-to-https-upgrade";
|
|
2327
|
+
}
|
|
2328
|
+
else if (location.includes("www.") && !currentUrl.includes("www.")) {
|
|
2329
|
+
pattern = "www-redirect";
|
|
2330
|
+
}
|
|
2331
|
+
else if (!location.includes("www.") && currentUrl.includes("www.")) {
|
|
2332
|
+
pattern = "www-to-bare-redirect";
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
await res.text().catch(() => { });
|
|
2336
|
+
chain.push({
|
|
2337
|
+
step,
|
|
2338
|
+
url: currentUrl,
|
|
2339
|
+
status: res.status,
|
|
2340
|
+
statusText: res.statusText,
|
|
2341
|
+
location,
|
|
2342
|
+
serverHeader,
|
|
2343
|
+
viaHeader,
|
|
2344
|
+
setCookies,
|
|
2345
|
+
headers: interestingHeaders,
|
|
2346
|
+
pattern,
|
|
2347
|
+
});
|
|
2348
|
+
// Follow redirect
|
|
2349
|
+
if (res.status >= 300 && res.status < 400 && location) {
|
|
2350
|
+
try {
|
|
2351
|
+
currentUrl = new URL(location, currentUrl).toString();
|
|
2352
|
+
}
|
|
2353
|
+
catch {
|
|
2354
|
+
// Invalid location header
|
|
2355
|
+
break;
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
else {
|
|
2359
|
+
// Not a redirect — we've reached the final destination
|
|
2360
|
+
break;
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
catch (err) {
|
|
2364
|
+
chain.push({
|
|
2365
|
+
step,
|
|
2366
|
+
url: currentUrl,
|
|
2367
|
+
status: 0,
|
|
2368
|
+
statusText: err.message,
|
|
2369
|
+
location: null,
|
|
2370
|
+
serverHeader: null,
|
|
2371
|
+
viaHeader: null,
|
|
2372
|
+
setCookies: [],
|
|
2373
|
+
headers: {},
|
|
2374
|
+
pattern: "error",
|
|
2375
|
+
});
|
|
2376
|
+
break;
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
// Analyze the chain
|
|
2380
|
+
const redirectCount = chain.filter((c) => c.status >= 300 && c.status < 400).length;
|
|
2381
|
+
const finalHop = chain[chain.length - 1];
|
|
2382
|
+
const intermediateServers = [
|
|
2383
|
+
...new Set(chain.map((c) => c.serverHeader).filter(Boolean)),
|
|
2384
|
+
];
|
|
2385
|
+
const viaChain = [
|
|
2386
|
+
...new Set(chain.map((c) => c.viaHeader).filter(Boolean)),
|
|
2387
|
+
];
|
|
2388
|
+
const patterns = [
|
|
2389
|
+
...new Set(chain.map((c) => c.pattern).filter(Boolean)),
|
|
2390
|
+
];
|
|
2391
|
+
const hostsInChain = [
|
|
2392
|
+
...new Set(chain.map((c) => {
|
|
2393
|
+
try {
|
|
2394
|
+
return new URL(c.url).hostname;
|
|
2395
|
+
}
|
|
2396
|
+
catch {
|
|
2397
|
+
return null;
|
|
2398
|
+
}
|
|
2399
|
+
}).filter(Boolean)),
|
|
2400
|
+
];
|
|
2401
|
+
const result = {
|
|
2402
|
+
url,
|
|
2403
|
+
chain,
|
|
2404
|
+
summary: {
|
|
2405
|
+
totalHops: chain.length,
|
|
2406
|
+
redirectCount,
|
|
2407
|
+
finalUrl: finalHop?.url ?? url,
|
|
2408
|
+
finalStatus: finalHop?.status ?? 0,
|
|
2409
|
+
intermediateServers,
|
|
2410
|
+
viaChain,
|
|
2411
|
+
redirectPatterns: patterns,
|
|
2412
|
+
hostsInChain,
|
|
2413
|
+
crossDomain: hostsInChain.length > 1,
|
|
2414
|
+
hasLoop: chain.some((c) => c.pattern === "redirect-loop"),
|
|
2415
|
+
excessiveRedirects: redirectCount > 5,
|
|
2416
|
+
},
|
|
2417
|
+
};
|
|
2418
|
+
cache.set(cacheKey, result);
|
|
2419
|
+
return json(result);
|
|
2420
|
+
},
|
|
2421
|
+
};
|
|
2422
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
2423
|
+
// Export All HTTP Tools
|
|
2424
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
2425
|
+
export const httpTools = [
|
|
2426
|
+
httpHeaders,
|
|
2427
|
+
httpMultiFingerprint,
|
|
2428
|
+
httpFavicon,
|
|
2429
|
+
httpEtag,
|
|
2430
|
+
httpErrors,
|
|
2431
|
+
httpCookies,
|
|
2432
|
+
httpMethods,
|
|
2433
|
+
httpCors,
|
|
2434
|
+
httpCompression,
|
|
2435
|
+
httpCache,
|
|
2436
|
+
httpSecurity,
|
|
2437
|
+
httpTiming,
|
|
2438
|
+
httpServerTiming,
|
|
2439
|
+
httpResourceHints,
|
|
2440
|
+
httpKeepalive,
|
|
2441
|
+
httpNel,
|
|
2442
|
+
httpRedirect,
|
|
2443
|
+
];
|
|
2444
|
+
//# sourceMappingURL=index.js.map
|