securl 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/LICENSE +21 -0
  3. package/README.md +427 -0
  4. package/RELEASING.md +37 -0
  5. package/SECURITY.md +27 -0
  6. package/dist/certificate.d.ts +5 -0
  7. package/dist/certificate.js +92 -0
  8. package/dist/cli.d.ts +1 -0
  9. package/dist/cli.js +674 -0
  10. package/dist/compromiseSignals.d.ts +10 -0
  11. package/dist/compromiseSignals.js +183 -0
  12. package/dist/cookie-analysis.d.ts +2 -0
  13. package/dist/cookie-analysis.js +41 -0
  14. package/dist/cookieAnalysis.d.ts +2 -0
  15. package/dist/cookieAnalysis.js +82 -0
  16. package/dist/ctDiscovery.d.ts +19 -0
  17. package/dist/ctDiscovery.js +357 -0
  18. package/dist/domain-security.d.ts +10 -0
  19. package/dist/domain-security.js +416 -0
  20. package/dist/header-analysis.d.ts +14 -0
  21. package/dist/header-analysis.js +165 -0
  22. package/dist/historyDiff.d.ts +4 -0
  23. package/dist/historyDiff.js +117 -0
  24. package/dist/html-extraction.d.ts +12 -0
  25. package/dist/html-extraction.js +279 -0
  26. package/dist/html-page-analysis.d.ts +38 -0
  27. package/dist/html-page-analysis.js +459 -0
  28. package/dist/htmlInsights.d.ts +23 -0
  29. package/dist/htmlInsights.js +460 -0
  30. package/dist/identityProvider.d.ts +14 -0
  31. package/dist/identityProvider.js +259 -0
  32. package/dist/index.d.ts +17 -0
  33. package/dist/index.js +1008 -0
  34. package/dist/infrastructure.d.ts +9 -0
  35. package/dist/infrastructure.js +149 -0
  36. package/dist/libraryRisk.d.ts +3 -0
  37. package/dist/libraryRisk.js +164 -0
  38. package/dist/network-validation.d.ts +30 -0
  39. package/dist/network-validation.js +161 -0
  40. package/dist/network.d.ts +34 -0
  41. package/dist/network.js +139 -0
  42. package/dist/passive-intelligence.d.ts +21 -0
  43. package/dist/passive-intelligence.js +247 -0
  44. package/dist/path-discovery.d.ts +4 -0
  45. package/dist/path-discovery.js +50 -0
  46. package/dist/postureDigest.d.ts +142 -0
  47. package/dist/postureDigest.js +159 -0
  48. package/dist/postureDrift.d.ts +4 -0
  49. package/dist/postureDrift.js +118 -0
  50. package/dist/postureRemediation.d.ts +6 -0
  51. package/dist/postureRemediation.js +286 -0
  52. package/dist/redirectChain.d.ts +2 -0
  53. package/dist/redirectChain.js +39 -0
  54. package/dist/riskEvents.d.ts +3 -0
  55. package/dist/riskEvents.js +187 -0
  56. package/dist/scannerConfig.d.ts +49 -0
  57. package/dist/scannerConfig.js +79 -0
  58. package/dist/scoring.d.ts +32 -0
  59. package/dist/scoring.js +367 -0
  60. package/dist/security-txt.d.ts +4 -0
  61. package/dist/security-txt.js +123 -0
  62. package/dist/surfaceEnrichment.d.ts +44 -0
  63. package/dist/surfaceEnrichment.js +377 -0
  64. package/dist/technology-detection.d.ts +4 -0
  65. package/dist/technology-detection.js +93 -0
  66. package/dist/types.d.ts +730 -0
  67. package/dist/types.js +1 -0
  68. package/dist/utils.d.ts +7 -0
  69. package/dist/utils.js +66 -0
  70. package/dist/wafFingerprint.d.ts +5 -0
  71. package/dist/wafFingerprint.js +156 -0
  72. package/examples/risk-events.mjs +27 -0
  73. package/examples/scan-url.mjs +17 -0
  74. package/package.json +102 -0
@@ -0,0 +1,9 @@
1
+ import type { InfrastructureInfo, TechnologyResult } from "./types.js";
2
+ interface InfrastructureResolver {
3
+ resolveCname(host: string): Promise<string[]>;
4
+ resolve4(host: string): Promise<string[]>;
5
+ resolve6(host: string): Promise<string[]>;
6
+ reverse(address: string): Promise<string[]>;
7
+ }
8
+ export declare function analyzeInfrastructure(finalUrl: URL, headers: Record<string, string | string[] | undefined>, technologies: TechnologyResult[], resolver?: InfrastructureResolver): Promise<InfrastructureInfo>;
9
+ export {};
@@ -0,0 +1,149 @@
1
+ import dns from "node:dns/promises";
2
+ import { DNS_LOOKUP_TIMEOUT_MS } from "./scannerConfig.js";
3
+ import { headerValue, mapWithConcurrency, safeResolveWithTimeout, unique } from "./utils.js";
4
+ const defaultResolver = {
5
+ resolveCname: (host) => dns.resolveCname(host),
6
+ resolve4: (host) => dns.resolve4(host),
7
+ resolve6: (host) => dns.resolve6(host),
8
+ reverse: (address) => dns.reverse(address),
9
+ };
10
+ const PROVIDER_SIGNATURES = [
11
+ { provider: "Cloudflare", category: "edge", pattern: /cloudflare|cf-ray|cf-cache-status/i },
12
+ { provider: "AWS / CloudFront", category: "cloud", pattern: /amazonaws|aws|cloudfront|awsglobalaccelerator|elb\.amazonaws/i },
13
+ { provider: "Microsoft Azure", category: "cloud", pattern: /azure|trafficmanager\.net|cloudapp\.net|azurefd\.net|windows\.net/i },
14
+ { provider: "Google Cloud", category: "cloud", pattern: /googleusercontent|googlehosted|googleapis|gcp|appspot\.com|goog/i },
15
+ { provider: "Fastly", category: "cdn", pattern: /fastly|fastlylb/i },
16
+ { provider: "Akamai", category: "cdn", pattern: /akamai|edgesuite|edgekey/i },
17
+ { provider: "Bunny.net", category: "cdn", pattern: /bunnycdn|bunny\.net|b-cdn\.net/i },
18
+ { provider: "Vercel", category: "paas", pattern: /vercel|now\.sh/i },
19
+ { provider: "Netlify", category: "paas", pattern: /netlify/i },
20
+ { provider: "Cloudflare Pages", category: "paas", pattern: /pages\.dev/i },
21
+ { provider: "Railway", category: "paas", pattern: /railway\.app|up\.railway\.app/i },
22
+ { provider: "Render", category: "paas", pattern: /render\.com|onrender\.com/i },
23
+ { provider: "Fly.io", category: "paas", pattern: /fly\.dev|fly\.io/i },
24
+ { provider: "DigitalOcean", category: "hosting", pattern: /digitalocean|do-static|ondigitalocean/i },
25
+ { provider: "Hostinger", category: "hosting", pattern: /hostinger|hstgr\.io/i },
26
+ { provider: "OVHcloud", category: "hosting", pattern: /ovh|ovhcloud/i },
27
+ { provider: "Hetzner", category: "hosting", pattern: /hetzner|your-server\.de/i },
28
+ { provider: "Heroku", category: "paas", pattern: /herokuapp|herokudns|heroku/i },
29
+ { provider: "GitHub Pages", category: "paas", pattern: /github\.io|githubusercontent|github\.com/i },
30
+ ];
31
+ const signalFromEvidence = (evidence, source) => {
32
+ for (const signature of PROVIDER_SIGNATURES) {
33
+ if (signature.pattern.test(evidence)) {
34
+ return {
35
+ provider: signature.provider,
36
+ category: signature.category,
37
+ confidence: source === "headers" || source === "technology" ? "high" : "medium",
38
+ source,
39
+ evidence,
40
+ };
41
+ }
42
+ }
43
+ return null;
44
+ };
45
+ const addSignals = (signals, values, source) => {
46
+ for (const value of values.filter(Boolean)) {
47
+ const signal = signalFromEvidence(value, source);
48
+ if (signal) {
49
+ signals.push(signal);
50
+ }
51
+ }
52
+ };
53
+ const detectProtocol = (headers) => {
54
+ const altSvc = headerValue(headers, "alt-svc");
55
+ const http3Advertised = Boolean(altSvc && /\bh3(?:-|=|")/i.test(altSvc));
56
+ // Node's fetch API does not expose the protocol version directly from response headers alone.
57
+ // Default to "unknown" rather than asserting HTTP/1.1 incorrectly for HTTP/2+ sites.
58
+ return {
59
+ http: "unknown",
60
+ http3Advertised,
61
+ altSvc,
62
+ };
63
+ };
64
+ const detectWaf = (headers) => {
65
+ const server = headerValue(headers, "server") || "";
66
+ const xCdn = headerValue(headers, "x-cdn") || "";
67
+ const setCookie = headerValue(headers, "set-cookie") || "";
68
+ const detectors = [
69
+ { provider: "Cloudflare", confidence: "high", evidence: "Observed Cloudflare edge headers.", matched: Boolean(headerValue(headers, "cf-ray") || headerValue(headers, "cf-cache-status") || /cloudflare/i.test(server)) },
70
+ { provider: "Akamai", confidence: "high", evidence: "Observed Akamai cache/request headers.", matched: Boolean(headerValue(headers, "x-check-cacheable") || headerValue(headers, "x-akamai-request-id") || headerValue(headers, "akamai-cache-status")) },
71
+ { provider: "AWS WAF / CloudFront", confidence: "medium", evidence: "Observed AWS CloudFront edge headers.", matched: Boolean(headerValue(headers, "x-amz-cf-id") || headerValue(headers, "x-amz-cf-pop")) },
72
+ { provider: "Imperva", confidence: "high", evidence: "Observed Imperva / Incapsula headers.", matched: Boolean(headerValue(headers, "x-iinfo") || /imperva/i.test(xCdn)) },
73
+ { provider: "Fastly", confidence: "medium", evidence: "Observed Fastly request/cache headers.", matched: Boolean(headerValue(headers, "x-fastly-request-id") || headerValue(headers, "fastly-restarts")) },
74
+ { provider: "Vercel Edge", confidence: "high", evidence: "Observed Vercel edge headers.", matched: Boolean(headerValue(headers, "x-vercel-cache") || headerValue(headers, "x-vercel-id")) },
75
+ { provider: "Sucuri", confidence: "high", evidence: "Observed Sucuri edge headers.", matched: Boolean(headerValue(headers, "x-sucuri-id") || headerValue(headers, "x-sucuri-cache")) },
76
+ { provider: "Azure Front Door", confidence: "high", evidence: "Observed Azure Front Door headers.", matched: Boolean(headerValue(headers, "x-azure-ref") || headerValue(headers, "x-fd-healthprobe")) },
77
+ { provider: "F5 / BIG-IP", confidence: "medium", evidence: "Observed F5 / BIG-IP headers or cookie markers.", matched: Boolean(headerValue(headers, "x-wa-info") || /bigipserver/i.test(setCookie)) },
78
+ ];
79
+ const match = detectors.find((detector) => detector.matched);
80
+ return match
81
+ ? { detected: true, provider: match.provider, confidence: match.confidence, evidence: match.evidence }
82
+ : { detected: false, provider: null, confidence: "low", evidence: "No passive WAF signature headers were observed." };
83
+ };
84
+ export async function analyzeInfrastructure(finalUrl, headers, technologies, resolver = defaultResolver) {
85
+ const host = finalUrl.hostname;
86
+ const resolveDns = (operation) => safeResolveWithTimeout(operation, DNS_LOOKUP_TIMEOUT_MS);
87
+ const [cnameTargetsRaw, ipv4Raw, ipv6Raw] = await Promise.all([
88
+ resolveDns(() => resolver.resolveCname(host)),
89
+ resolveDns(() => resolver.resolve4(host)),
90
+ resolveDns(() => resolver.resolve6(host)),
91
+ ]);
92
+ const cnameTargets = cnameTargetsRaw || [];
93
+ const addresses = unique([...(ipv4Raw || []), ...(ipv6Raw || [])]);
94
+ const reverseDns = unique((await mapWithConcurrency(addresses.slice(0, 4), 2, async (address) => (await resolveDns(() => resolver.reverse(address))) || [])).flat());
95
+ const signals = [];
96
+ addSignals(signals, [host, ...cnameTargets], "dns");
97
+ addSignals(signals, reverseDns, "reverse_dns");
98
+ addSignals(signals, [
99
+ headerValue(headers, "server") || "",
100
+ headerValue(headers, "via") || "",
101
+ headerValue(headers, "x-served-by") || "",
102
+ headerValue(headers, "x-cache") || "",
103
+ headerValue(headers, "cf-ray") || "",
104
+ headerValue(headers, "x-amz-cf-id") || "",
105
+ headerValue(headers, "x-vercel-id") || "",
106
+ headerValue(headers, "x-nf-request-id") || "",
107
+ headerValue(headers, "x-render-origin-server") || "",
108
+ headerValue(headers, "x-railway-edge") || "",
109
+ headerValue(headers, "platform") || "",
110
+ headerValue(headers, "panel") || "",
111
+ ], "headers");
112
+ addSignals(signals, technologies.map((technology) => `${technology.name} ${technology.evidence}`), "technology");
113
+ const providers = unique(signals.map((signal) => `${signal.provider}|${signal.category}|${signal.source}`))
114
+ .map((key) => signals.find((signal) => `${signal.provider}|${signal.category}|${signal.source}` === key))
115
+ .filter((signal) => Boolean(signal));
116
+ const providerNames = unique(providers.map((signal) => signal.provider));
117
+ const protocol = detectProtocol(headers);
118
+ const waf = detectWaf(headers);
119
+ const issues = [];
120
+ const strengths = [];
121
+ if (protocol.http3Advertised) {
122
+ strengths.push("HTTP/3 support is advertised via Alt-Svc.");
123
+ }
124
+ else {
125
+ issues.push("No HTTP/3 Alt-Svc advertisement was visible on the main response.");
126
+ }
127
+ if (waf.detected && waf.provider) {
128
+ strengths.push(`Passive WAF or edge-protection headers indicate ${waf.provider}.`);
129
+ }
130
+ return {
131
+ host,
132
+ addresses,
133
+ cnameTargets,
134
+ reverseDns,
135
+ providers,
136
+ protocol,
137
+ waf,
138
+ issues,
139
+ strengths: [
140
+ ...strengths,
141
+ ...(providerNames.length
142
+ ? [`Passive DNS, header, or stack evidence identified ${providerNames.join(", ")}.`]
143
+ : []),
144
+ ],
145
+ summary: providerNames.length
146
+ ? `Passive infrastructure evidence points to ${providerNames.join(", ")}.`
147
+ : "No obvious cloud, CDN, or hosting provider was inferred from passive evidence.",
148
+ };
149
+ }
@@ -0,0 +1,3 @@
1
+ import type { LibraryFingerprint, LibraryRiskSignal } from "./types.js";
2
+ export declare const collectLibraryFingerprints: (externalScriptUrls: string[]) => LibraryFingerprint[];
3
+ export declare const fetchLibraryRiskSignals: (fingerprints: LibraryFingerprint[]) => Promise<LibraryRiskSignal[]>;
@@ -0,0 +1,164 @@
1
+ import { LIBRARY_RISK_LOOKUP_LIMIT, OSV_DETAIL_CONCURRENCY_LIMIT, OSV_DETAIL_LOOKUP_LIMIT, OSV_QUERY_TIMEOUT_MS, } from "./scannerConfig.js";
2
+ import { mapWithConcurrency, unique } from "./utils.js";
3
+ const OSV_QUERYBATCH_URL = "https://api.osv.dev/v1/querybatch";
4
+ const OSV_VULN_URL = "https://api.osv.dev/v1/vulns/";
5
+ const LIBRARY_PATTERNS = [
6
+ { packageName: "jquery", pattern: /(?:^|[/_-])jquery(?:[-_.](?:slim|ui))?[-@/_]?v?(\d+\.\d+\.\d+)(?:\.min)?\.js(?:[?#]|$)/i, evidence: "Detected from a versioned jQuery script URL" },
7
+ { packageName: "bootstrap", pattern: /(?:^|[/_-])bootstrap(?:\.bundle)?[-@/_]?v?(\d+\.\d+\.\d+)(?:\.min)?\.js(?:[?#]|$)/i, evidence: "Detected from a versioned Bootstrap script URL" },
8
+ { packageName: "react", pattern: /(?:^|[/_-])react[-@/_]?v?(\d+\.\d+\.\d+)(?:\.production\.min|\.development|\.min)?\.js(?:[?#]|$)/i, evidence: "Detected from a versioned React script URL" },
9
+ { packageName: "react-dom", pattern: /(?:^|[/_-])react-dom[-@/_]?v?(\d+\.\d+\.\d+)(?:\.production\.min|\.development|\.min)?\.js(?:[?#]|$)/i, evidence: "Detected from a versioned React DOM script URL" },
10
+ { packageName: "vue", pattern: /(?:^|[/_-])vue(?:\.runtime|\.global|\.esm-browser|\.esm-bundler|\.cjs)?(?:\.prod)?[-@/_]?v?(\d+\.\d+\.\d+)(?:\.min)?\.js(?:[?#]|$)/i, evidence: "Detected from a versioned Vue script URL" },
11
+ { packageName: "angular", pattern: /(?:^|[/_-])angular(?:\.min)?[-@/_]?v?(\d+\.\d+\.\d+)\.js(?:[?#]|$)/i, evidence: "Detected from a versioned AngularJS script URL" },
12
+ { packageName: "lodash", pattern: /(?:^|[/_-])lodash[-@/_]?v?(\d+\.\d+\.\d+)(?:\.min)?\.js(?:[?#]|$)/i, evidence: "Detected from a versioned Lodash script URL" },
13
+ { packageName: "moment", pattern: /(?:^|[/_-])moment[-@/_]?v?(\d+\.\d+\.\d+)(?:\.min)?\.js(?:[?#]|$)/i, evidence: "Detected from a versioned Moment.js script URL" },
14
+ { packageName: "axios", pattern: /(?:^|[/_-])axios[-@/_]?v?(\d+\.\d+\.\d+)(?:\.min)?\.js(?:[?#]|$)/i, evidence: "Detected from a versioned Axios script URL" },
15
+ { packageName: "chart.js", pattern: /(?:^|[/_-])chart(?:\.umd)?[-@/_]?v?(\d+\.\d+\.\d+)(?:\.min)?\.js(?:[?#]|$)/i, evidence: "Detected from a versioned Chart.js script URL" },
16
+ ];
17
+ const riskCache = new Map();
18
+ const abortableJsonFetch = async (url, init) => {
19
+ const controller = new AbortController();
20
+ const timeout = setTimeout(() => controller.abort(), OSV_QUERY_TIMEOUT_MS);
21
+ try {
22
+ const response = await fetch(url, {
23
+ ...init,
24
+ signal: controller.signal,
25
+ headers: {
26
+ "content-type": "application/json",
27
+ ...(init?.headers || {}),
28
+ },
29
+ });
30
+ if (!response.ok) {
31
+ throw new Error(`OSV request failed with status ${response.status}`);
32
+ }
33
+ return (await response.json());
34
+ }
35
+ finally {
36
+ clearTimeout(timeout);
37
+ }
38
+ };
39
+ const toSeverity = (value) => {
40
+ const normalized = typeof value === "string" ? value.toLowerCase() : "";
41
+ if (normalized === "low" || normalized === "moderate" || normalized === "high" || normalized === "critical") {
42
+ return normalized;
43
+ }
44
+ return "unknown";
45
+ };
46
+ const pickReferenceUrl = (references) => {
47
+ if (!Array.isArray(references)) {
48
+ return null;
49
+ }
50
+ const preferred = references.find((item) => typeof item === "object" && item && "url" in item && typeof item.url === "string");
51
+ return preferred && typeof preferred === "object" && preferred && "url" in preferred && typeof preferred.url === "string" ? preferred.url : null;
52
+ };
53
+ const toVulnerability = (payload) => ({
54
+ id: typeof payload.id === "string" ? payload.id : "unknown",
55
+ summary: typeof payload.summary === "string" && payload.summary.trim() ? payload.summary.trim() : "Known advisory recorded in OSV.",
56
+ severity: payload.database_specific && typeof payload.database_specific === "object"
57
+ ? toSeverity(payload.database_specific.severity)
58
+ : "unknown",
59
+ aliases: Array.isArray(payload.aliases) ? payload.aliases.filter((item) => typeof item === "string").slice(0, 4) : [],
60
+ referenceUrl: pickReferenceUrl(payload.references),
61
+ });
62
+ export const collectLibraryFingerprints = (externalScriptUrls) => {
63
+ const fingerprints = [];
64
+ const seen = new Set();
65
+ for (const sourceUrl of externalScriptUrls) {
66
+ // Skip pathologically long URLs to guard against ReDoS
67
+ if (sourceUrl.length > 1024)
68
+ continue;
69
+ for (const matcher of LIBRARY_PATTERNS) {
70
+ const match = sourceUrl.match(matcher.pattern);
71
+ if (!match?.[1]) {
72
+ continue;
73
+ }
74
+ const version = match[1];
75
+ const key = `${matcher.packageName}@${version}`;
76
+ if (seen.has(key)) {
77
+ continue;
78
+ }
79
+ seen.add(key);
80
+ fingerprints.push({
81
+ packageName: matcher.packageName,
82
+ version,
83
+ sourceUrl,
84
+ confidence: "high",
85
+ evidence: matcher.evidence,
86
+ });
87
+ break;
88
+ }
89
+ }
90
+ return fingerprints.slice(0, LIBRARY_RISK_LOOKUP_LIMIT);
91
+ };
92
+ export const fetchLibraryRiskSignals = async (fingerprints) => {
93
+ const queryableFingerprints = fingerprints.filter((item) => item.confidence === "high").slice(0, LIBRARY_RISK_LOOKUP_LIMIT);
94
+ if (!queryableFingerprints.length) {
95
+ return [];
96
+ }
97
+ const cacheKey = unique(queryableFingerprints.map((item) => `${item.packageName}@${item.version}`)).sort().join("|");
98
+ const cached = riskCache.get(cacheKey);
99
+ if (cached) {
100
+ return cached;
101
+ }
102
+ try {
103
+ const queryResponse = await abortableJsonFetch(OSV_QUERYBATCH_URL, {
104
+ method: "POST",
105
+ body: JSON.stringify({
106
+ queries: queryableFingerprints.map((item) => ({
107
+ package: {
108
+ ecosystem: "npm",
109
+ name: item.packageName,
110
+ },
111
+ version: item.version,
112
+ })),
113
+ }),
114
+ });
115
+ const results = Array.isArray(queryResponse.results) ? queryResponse.results : [];
116
+ const vulnerabilityIds = unique(results.flatMap((result) => result && typeof result === "object" && Array.isArray(result.vulns)
117
+ ? result.vulns
118
+ .map((item) => (typeof item.id === "string" ? item.id : null))
119
+ .filter((item) => Boolean(item))
120
+ : [])).slice(0, OSV_DETAIL_LOOKUP_LIMIT);
121
+ const vulnerabilityDetails = await mapWithConcurrency(vulnerabilityIds, OSV_DETAIL_CONCURRENCY_LIMIT, async (id) => {
122
+ try {
123
+ const payload = await abortableJsonFetch(`${OSV_VULN_URL}${encodeURIComponent(id)}`);
124
+ return [id, toVulnerability(payload)];
125
+ }
126
+ catch {
127
+ return [id, null];
128
+ }
129
+ });
130
+ const vulnerabilityMap = new Map(vulnerabilityDetails.filter((entry) => Boolean(entry[1])));
131
+ const signals = queryableFingerprints.flatMap((fingerprint, index) => {
132
+ const result = results[index];
133
+ const ids = result && typeof result === "object" && Array.isArray(result.vulns)
134
+ ? result.vulns
135
+ .map((item) => (typeof item.id === "string" ? item.id : null))
136
+ .filter((item) => Boolean(item))
137
+ : [];
138
+ const vulnerabilities = ids
139
+ .map((id) => vulnerabilityMap.get(id))
140
+ .filter((item) => Boolean(item))
141
+ .slice(0, 4);
142
+ if (!vulnerabilities.length) {
143
+ return [];
144
+ }
145
+ return [
146
+ {
147
+ packageName: fingerprint.packageName,
148
+ version: fingerprint.version,
149
+ confidence: fingerprint.confidence,
150
+ sourceUrl: fingerprint.sourceUrl,
151
+ evidence: fingerprint.evidence,
152
+ vulnerabilities,
153
+ },
154
+ ];
155
+ });
156
+ if (riskCache.size > 500)
157
+ riskCache.clear();
158
+ riskCache.set(cacheKey, signals);
159
+ return signals;
160
+ }
161
+ catch {
162
+ return [];
163
+ }
164
+ };
@@ -0,0 +1,30 @@
1
+ export interface ValidatedAddress {
2
+ address: string;
3
+ family: number;
4
+ }
5
+ interface PinnedLookupOptions {
6
+ family?: number | "IPv4" | "IPv6";
7
+ all?: boolean;
8
+ }
9
+ type PinnedLookupCallback = (err: NodeJS.ErrnoException | null, address: string | ValidatedAddress[], family?: number) => void;
10
+ /** Node's lookup callback used by http/https request `lookup` option. */
11
+ export type PinnedLookup = (hostname: string, options: PinnedLookupOptions | PinnedLookupCallback, callback?: PinnedLookupCallback) => void;
12
+ export declare function isPrivateIpv4(value: string): boolean;
13
+ export declare function isPrivateIpv6(value: string): boolean;
14
+ export declare function isLocalHostname(hostname: string): boolean;
15
+ export declare function isPrivateAddress(value: string): boolean;
16
+ /**
17
+ * Validate that `targetUrl` is a public destination and return the exact set of
18
+ * IP addresses it resolved to. Callers MUST connect using {@link createPinnedLookup}
19
+ * with the returned addresses so the socket cannot be re-pointed at a private IP
20
+ * between validation and connection (DNS-rebinding / TOCTOU SSRF).
21
+ */
22
+ export declare function assertPublicRequestTarget(targetUrl: URL): Promise<ValidatedAddress[]>;
23
+ export declare function assertPublicRedirectTarget(targetUrl: URL): Promise<ValidatedAddress[]>;
24
+ /**
25
+ * Build a `lookup` function for http/https requests that only ever yields the
26
+ * pre-validated public addresses, closing the gap between validation and the
27
+ * connection's own DNS resolution.
28
+ */
29
+ export declare function createPinnedLookup(addresses: ValidatedAddress[]): PinnedLookup;
30
+ export {};
@@ -0,0 +1,161 @@
1
+ import dns from "node:dns/promises";
2
+ import net from "node:net";
3
+ import { DNS_LOOKUP_TIMEOUT_MS } from "./scannerConfig.js";
4
+ import { withTimeout } from "./utils.js";
5
+ function stripBrackets(value) {
6
+ return value.replace(/^\[(.*)\]$/, "$1");
7
+ }
8
+ export function isPrivateIpv4(value) {
9
+ const [first, second] = value.split(".").map((part) => Number(part));
10
+ if ([first, second].some((part) => Number.isNaN(part))) {
11
+ return false;
12
+ }
13
+ return (first === 10 ||
14
+ first === 127 ||
15
+ first === 0 ||
16
+ (first === 100 && second >= 64 && second <= 127) ||
17
+ (first === 169 && second === 254) ||
18
+ (first === 172 && second >= 16 && second <= 31) ||
19
+ (first === 192 && second === 168) ||
20
+ (first === 198 && (second === 18 || second === 19)));
21
+ }
22
+ // Decode the IPv4 address embedded in IPv4-mapped (::ffff:a.b.c.d / ::ffff:HHHH:HHHH),
23
+ // 6to4 (2002:HHHH:HHHH::) and NAT64 (64:ff9b::a.b.c.d) IPv6 forms.
24
+ function extractEmbeddedIpv4(normalized) {
25
+ // Dotted-quad already present in the address (e.g. ::ffff:127.0.0.1, 64:ff9b::127.0.0.1).
26
+ const dotted = normalized.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
27
+ if (dotted) {
28
+ return dotted[1];
29
+ }
30
+ const hexPairToIpv4 = (high, low) => {
31
+ const h = Number.parseInt(high, 16);
32
+ const l = Number.parseInt(low, 16);
33
+ if (Number.isNaN(h) || Number.isNaN(l)) {
34
+ return null;
35
+ }
36
+ return `${(h >> 8) & 0xff}.${h & 0xff}.${(l >> 8) & 0xff}.${l & 0xff}`;
37
+ };
38
+ // IPv4-mapped in hextet form: ::ffff:7f00:1
39
+ const mapped = normalized.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
40
+ if (mapped) {
41
+ return hexPairToIpv4(mapped[1], mapped[2]);
42
+ }
43
+ // 6to4: 2002:HHHH:HHHH::/16 carries the IPv4 gateway in the next 32 bits.
44
+ const sixToFour = normalized.match(/^2002:([0-9a-f]{1,4}):([0-9a-f]{1,4})/);
45
+ if (sixToFour) {
46
+ return hexPairToIpv4(sixToFour[1], sixToFour[2]);
47
+ }
48
+ return null;
49
+ }
50
+ export function isPrivateIpv6(value) {
51
+ const normalized = stripBrackets(value.toLowerCase());
52
+ if (normalized === "::1" || normalized === "::") {
53
+ return true;
54
+ }
55
+ // Unique local (fc00::/7) and link-local (fe80::/10, i.e. fe80–febf).
56
+ const firstHextet = Number.parseInt(normalized.split(":")[0] || "", 16);
57
+ if (!Number.isNaN(firstHextet)) {
58
+ if ((firstHextet & 0xfe00) === 0xfc00) {
59
+ return true; // fc00::/7
60
+ }
61
+ if ((firstHextet & 0xffc0) === 0xfe80) {
62
+ return true; // fe80::/10
63
+ }
64
+ if (firstHextet === 0xfec0) {
65
+ return true; // deprecated site-local
66
+ }
67
+ }
68
+ // IPv4-mapped (::ffff:*) is never a routable IPv6 destination; block and, when
69
+ // possible, also classify the embedded IPv4 so the message is accurate.
70
+ if (normalized.startsWith("::ffff:")) {
71
+ return true;
72
+ }
73
+ // 6to4 and NAT64 tunnels can smuggle a private IPv4 destination.
74
+ if (normalized.startsWith("2002:") || normalized.startsWith("64:ff9b:")) {
75
+ const embedded = extractEmbeddedIpv4(normalized);
76
+ if (embedded && isPrivateIpv4(embedded)) {
77
+ return true;
78
+ }
79
+ }
80
+ return false;
81
+ }
82
+ export function isLocalHostname(hostname) {
83
+ const normalized = hostname.toLowerCase();
84
+ return (normalized === "localhost" ||
85
+ normalized.endsWith(".localhost") ||
86
+ normalized.endsWith(".local") ||
87
+ normalized.endsWith(".internal"));
88
+ }
89
+ export function isPrivateAddress(value) {
90
+ const normalized = stripBrackets(value);
91
+ const ipVersion = net.isIP(normalized);
92
+ if (ipVersion === 4) {
93
+ return isPrivateIpv4(normalized);
94
+ }
95
+ if (ipVersion === 6) {
96
+ return isPrivateIpv6(normalized);
97
+ }
98
+ return false;
99
+ }
100
+ async function resolveValidatedAddresses(hostname, blockedMessage, unresolvedMessage) {
101
+ const literal = stripBrackets(hostname);
102
+ const literalVersion = net.isIP(literal);
103
+ if (literalVersion !== 0) {
104
+ if (isPrivateAddress(literal)) {
105
+ throw new Error(blockedMessage);
106
+ }
107
+ return [{ address: literal, family: literalVersion }];
108
+ }
109
+ const lookups = await withTimeout(dns.lookup(hostname, { all: true }), DNS_LOOKUP_TIMEOUT_MS, `DNS lookup for ${hostname} timed out.`);
110
+ if (!lookups.length || lookups.some((entry) => isPrivateAddress(entry.address))) {
111
+ throw new Error(unresolvedMessage);
112
+ }
113
+ return lookups.map((entry) => ({ address: entry.address, family: entry.family }));
114
+ }
115
+ /**
116
+ * Validate that `targetUrl` is a public destination and return the exact set of
117
+ * IP addresses it resolved to. Callers MUST connect using {@link createPinnedLookup}
118
+ * with the returned addresses so the socket cannot be re-pointed at a private IP
119
+ * between validation and connection (DNS-rebinding / TOCTOU SSRF).
120
+ */
121
+ export async function assertPublicRequestTarget(targetUrl) {
122
+ if (isLocalHostname(targetUrl.hostname) || isPrivateAddress(targetUrl.hostname)) {
123
+ throw new Error(`Target ${targetUrl.hostname} is not public and was blocked.`);
124
+ }
125
+ return resolveValidatedAddresses(targetUrl.hostname, `Target ${targetUrl.hostname} is not public and was blocked.`, `Target ${targetUrl.hostname} did not resolve exclusively to public IP addresses.`);
126
+ }
127
+ export async function assertPublicRedirectTarget(targetUrl) {
128
+ if (isLocalHostname(targetUrl.hostname) || isPrivateAddress(targetUrl.hostname)) {
129
+ throw new Error(`Redirect target ${targetUrl.hostname} is not public and was blocked.`);
130
+ }
131
+ return resolveValidatedAddresses(targetUrl.hostname, `Redirect target ${targetUrl.hostname} is not public and was blocked.`, `Redirect target ${targetUrl.hostname} did not resolve exclusively to public IP addresses.`);
132
+ }
133
+ /**
134
+ * Build a `lookup` function for http/https requests that only ever yields the
135
+ * pre-validated public addresses, closing the gap between validation and the
136
+ * connection's own DNS resolution.
137
+ */
138
+ export function createPinnedLookup(addresses) {
139
+ return function pinnedLookup(_hostname, options, callback) {
140
+ const cb = (typeof options === "function" ? options : callback);
141
+ const opts = typeof options === "function" ? {} : options || {};
142
+ if (!addresses.length) {
143
+ cb(new Error("No validated address available for pinned lookup."), "", 0);
144
+ return;
145
+ }
146
+ const requestedFamily = opts.family === 4 || opts.family === "IPv4"
147
+ ? 4
148
+ : opts.family === 6 || opts.family === "IPv6"
149
+ ? 6
150
+ : 0;
151
+ const matching = requestedFamily
152
+ ? addresses.filter((entry) => entry.family === requestedFamily)
153
+ : addresses;
154
+ const selected = matching.length ? matching : addresses;
155
+ if (opts.all) {
156
+ cb(null, selected.map((entry) => ({ address: entry.address, family: entry.family })));
157
+ return;
158
+ }
159
+ cb(null, selected[0].address, selected[0].family);
160
+ };
161
+ }
@@ -0,0 +1,34 @@
1
+ import http from "node:http";
2
+ import type { RedirectHop } from "./types.js";
3
+ export declare const SCANNER_USER_AGENT = "ExternalPostureInsight/1.0";
4
+ export interface RequestHeadResult {
5
+ statusCode: number;
6
+ headers: http.IncomingHttpHeaders;
7
+ elapsedMs: number;
8
+ }
9
+ export interface RequestTextResult {
10
+ statusCode: number;
11
+ headers: http.IncomingHttpHeaders;
12
+ body: string;
13
+ }
14
+ export interface RequestJsonResult<T = unknown> {
15
+ statusCode: number;
16
+ headers: http.IncomingHttpHeaders;
17
+ body: string;
18
+ json: T | null;
19
+ }
20
+ export type RequestTextFn = (targetUrl: URL, extraHeaders?: Record<string, string>) => Promise<RequestTextResult>;
21
+ export type RequestJsonFn = (targetUrl: URL, extraHeaders?: Record<string, string>) => Promise<RequestJsonResult>;
22
+ interface RequestOptions {
23
+ timeoutMs?: number;
24
+ }
25
+ export declare function requestOnce(targetUrl: URL, method?: string, options?: RequestOptions): Promise<RequestHeadResult>;
26
+ export declare function requestWithHeaders(targetUrl: URL, method?: string, extraHeaders?: Record<string, string>, options?: RequestOptions): Promise<RequestHeadResult>;
27
+ export declare function requestText(targetUrl: URL, extraHeaders?: Record<string, string>, options?: RequestOptions): Promise<RequestTextResult>;
28
+ export declare function requestJson(targetUrl: URL, extraHeaders?: Record<string, string>, options?: RequestOptions): Promise<RequestJsonResult>;
29
+ export declare function fetchWithRedirects(initialUrl: URL, redirectLimit?: number, options?: RequestOptions): Promise<{
30
+ finalUrl: URL;
31
+ redirects: RedirectHop[];
32
+ response: RequestHeadResult;
33
+ }>;
34
+ export {};