securl 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +241 -0
- package/LICENSE +21 -0
- package/README.md +427 -0
- package/RELEASING.md +37 -0
- package/SECURITY.md +27 -0
- package/dist/certificate.d.ts +5 -0
- package/dist/certificate.js +92 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +674 -0
- package/dist/compromiseSignals.d.ts +10 -0
- package/dist/compromiseSignals.js +183 -0
- package/dist/cookie-analysis.d.ts +2 -0
- package/dist/cookie-analysis.js +41 -0
- package/dist/cookieAnalysis.d.ts +2 -0
- package/dist/cookieAnalysis.js +82 -0
- package/dist/ctDiscovery.d.ts +19 -0
- package/dist/ctDiscovery.js +357 -0
- package/dist/domain-security.d.ts +10 -0
- package/dist/domain-security.js +416 -0
- package/dist/header-analysis.d.ts +14 -0
- package/dist/header-analysis.js +165 -0
- package/dist/historyDiff.d.ts +4 -0
- package/dist/historyDiff.js +117 -0
- package/dist/html-extraction.d.ts +12 -0
- package/dist/html-extraction.js +279 -0
- package/dist/html-page-analysis.d.ts +38 -0
- package/dist/html-page-analysis.js +459 -0
- package/dist/htmlInsights.d.ts +23 -0
- package/dist/htmlInsights.js +460 -0
- package/dist/identityProvider.d.ts +14 -0
- package/dist/identityProvider.js +259 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +1008 -0
- package/dist/infrastructure.d.ts +9 -0
- package/dist/infrastructure.js +149 -0
- package/dist/libraryRisk.d.ts +3 -0
- package/dist/libraryRisk.js +164 -0
- package/dist/network-validation.d.ts +30 -0
- package/dist/network-validation.js +161 -0
- package/dist/network.d.ts +34 -0
- package/dist/network.js +139 -0
- package/dist/passive-intelligence.d.ts +21 -0
- package/dist/passive-intelligence.js +247 -0
- package/dist/path-discovery.d.ts +4 -0
- package/dist/path-discovery.js +50 -0
- package/dist/postureDigest.d.ts +142 -0
- package/dist/postureDigest.js +159 -0
- package/dist/postureDrift.d.ts +4 -0
- package/dist/postureDrift.js +118 -0
- package/dist/postureRemediation.d.ts +6 -0
- package/dist/postureRemediation.js +286 -0
- package/dist/redirectChain.d.ts +2 -0
- package/dist/redirectChain.js +39 -0
- package/dist/riskEvents.d.ts +3 -0
- package/dist/riskEvents.js +187 -0
- package/dist/scannerConfig.d.ts +49 -0
- package/dist/scannerConfig.js +79 -0
- package/dist/scoring.d.ts +32 -0
- package/dist/scoring.js +367 -0
- package/dist/security-txt.d.ts +4 -0
- package/dist/security-txt.js +123 -0
- package/dist/surfaceEnrichment.d.ts +44 -0
- package/dist/surfaceEnrichment.js +377 -0
- package/dist/technology-detection.d.ts +4 -0
- package/dist/technology-detection.js +93 -0
- package/dist/types.d.ts +730 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +7 -0
- package/dist/utils.js +66 -0
- package/dist/wafFingerprint.d.ts +5 -0
- package/dist/wafFingerprint.js +156 -0
- package/examples/risk-events.mjs +27 -0
- package/examples/scan-url.mjs +17 -0
- package/package.json +102 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
export const snapshotFromAnalysis = (analysis) => ({
|
|
2
|
+
finalUrl: analysis.finalUrl,
|
|
3
|
+
host: analysis.host,
|
|
4
|
+
scannedAt: analysis.scannedAt,
|
|
5
|
+
score: analysis.score,
|
|
6
|
+
grade: analysis.grade,
|
|
7
|
+
statusCode: analysis.statusCode,
|
|
8
|
+
responseTimeMs: analysis.responseTimeMs,
|
|
9
|
+
certificateDaysRemaining: analysis.certificate.daysRemaining,
|
|
10
|
+
thirdPartyProviders: analysis.thirdPartyTrust.providers.map((provider) => provider.name),
|
|
11
|
+
aiVendors: analysis.aiSurface.vendors.map((vendor) => vendor.name),
|
|
12
|
+
identityProvider: analysis.identityProvider.provider,
|
|
13
|
+
wafProviders: analysis.wafFingerprint.providers.map((provider) => provider.name),
|
|
14
|
+
ctPriorityHosts: analysis.ctDiscovery.prioritizedHosts.map((host) => host.host),
|
|
15
|
+
headers: analysis.headers.map((header) => ({
|
|
16
|
+
label: header.label,
|
|
17
|
+
status: header.status,
|
|
18
|
+
value: header.value,
|
|
19
|
+
})),
|
|
20
|
+
issues: analysis.issues.map((issue) => ({
|
|
21
|
+
severity: issue.severity,
|
|
22
|
+
title: issue.title,
|
|
23
|
+
detail: issue.detail,
|
|
24
|
+
confidence: issue.confidence,
|
|
25
|
+
source: issue.source,
|
|
26
|
+
})),
|
|
27
|
+
});
|
|
28
|
+
export const buildHistoryDiffFromSnapshots = (current, previous) => {
|
|
29
|
+
const currentIssues = new Set(current.issues.map((issue) => issue.title));
|
|
30
|
+
const previousIssues = new Set(previous.issues.map((issue) => issue.title));
|
|
31
|
+
const previousHeaders = new Map(previous.headers.map((header) => [header.label, header.status]));
|
|
32
|
+
const currentThirdParties = new Set(current.thirdPartyProviders ?? []);
|
|
33
|
+
const previousThirdParties = new Set(previous.thirdPartyProviders ?? []);
|
|
34
|
+
const currentAiVendors = new Set(current.aiVendors ?? []);
|
|
35
|
+
const previousAiVendors = new Set(previous.aiVendors ?? []);
|
|
36
|
+
const currentWafProviders = new Set(current.wafProviders ?? []);
|
|
37
|
+
const previousWafProviders = new Set(previous.wafProviders ?? []);
|
|
38
|
+
const currentCtPriorityHosts = new Set(current.ctPriorityHosts ?? []);
|
|
39
|
+
const previousCtPriorityHosts = new Set(previous.ctPriorityHosts ?? []);
|
|
40
|
+
const scoreDelta = current.score - previous.score;
|
|
41
|
+
const certificateDaysRemainingDelta = current.certificateDaysRemaining !== null &&
|
|
42
|
+
current.certificateDaysRemaining !== undefined &&
|
|
43
|
+
previous.certificateDaysRemaining !== null &&
|
|
44
|
+
previous.certificateDaysRemaining !== undefined
|
|
45
|
+
? current.certificateDaysRemaining - previous.certificateDaysRemaining
|
|
46
|
+
: null;
|
|
47
|
+
const newWafProviders = [...currentWafProviders].filter((provider) => !previousWafProviders.has(provider));
|
|
48
|
+
const newThirdPartyProviders = [...currentThirdParties].filter((provider) => !previousThirdParties.has(provider));
|
|
49
|
+
const newCtPriorityHosts = [...currentCtPriorityHosts].filter((host) => !previousCtPriorityHosts.has(host));
|
|
50
|
+
const summary = [
|
|
51
|
+
scoreDelta > 0 ? `Score improved by ${scoreDelta} point${scoreDelta === 1 ? "" : "s"}.` : null,
|
|
52
|
+
scoreDelta < 0 ? `Score regressed by ${Math.abs(scoreDelta)} point${Math.abs(scoreDelta) === 1 ? "" : "s"}.` : null,
|
|
53
|
+
current.statusCode !== previous.statusCode
|
|
54
|
+
? `HTTP status changed from ${previous.statusCode} to ${current.statusCode}.`
|
|
55
|
+
: null,
|
|
56
|
+
(current.identityProvider ?? null) !== (previous.identityProvider ?? null)
|
|
57
|
+
? `Identity provider changed from ${previous.identityProvider ?? "none"} to ${current.identityProvider ?? "none"}.`
|
|
58
|
+
: null,
|
|
59
|
+
newWafProviders.length ? `New WAF or edge signals appeared: ${newWafProviders.join(", ")}.` : null,
|
|
60
|
+
newThirdPartyProviders.length
|
|
61
|
+
? `New third-party providers were observed: ${newThirdPartyProviders.join(", ")}.`
|
|
62
|
+
: null,
|
|
63
|
+
newCtPriorityHosts.length ? `New high-priority CT hosts appeared: ${newCtPriorityHosts.join(", ")}.` : null,
|
|
64
|
+
certificateDaysRemainingDelta !== null && certificateDaysRemainingDelta < 0
|
|
65
|
+
? `Certificate window shortened by ${Math.abs(certificateDaysRemainingDelta)} day${Math.abs(certificateDaysRemainingDelta) === 1 ? "" : "s"}.`
|
|
66
|
+
: null,
|
|
67
|
+
].filter((item) => Boolean(item));
|
|
68
|
+
return {
|
|
69
|
+
previousScore: previous.score,
|
|
70
|
+
scoreDelta,
|
|
71
|
+
previousGrade: previous.grade,
|
|
72
|
+
currentGrade: current.grade,
|
|
73
|
+
statusCodeDelta: {
|
|
74
|
+
from: previous.statusCode,
|
|
75
|
+
to: current.statusCode,
|
|
76
|
+
},
|
|
77
|
+
certificateDaysRemainingDelta: {
|
|
78
|
+
from: previous.certificateDaysRemaining ?? null,
|
|
79
|
+
to: current.certificateDaysRemaining ?? null,
|
|
80
|
+
delta: certificateDaysRemainingDelta,
|
|
81
|
+
},
|
|
82
|
+
newIssues: [...currentIssues].filter((issue) => !previousIssues.has(issue)),
|
|
83
|
+
resolvedIssues: [...previousIssues].filter((issue) => !currentIssues.has(issue)),
|
|
84
|
+
headerChanges: current.headers
|
|
85
|
+
.map((header) => ({
|
|
86
|
+
label: header.label,
|
|
87
|
+
from: previousHeaders.get(header.label) ?? "unknown",
|
|
88
|
+
to: header.status,
|
|
89
|
+
}))
|
|
90
|
+
.filter((change) => change.from !== change.to),
|
|
91
|
+
newThirdPartyProviders,
|
|
92
|
+
removedThirdPartyProviders: [...previousThirdParties].filter((provider) => !currentThirdParties.has(provider)),
|
|
93
|
+
newAiVendors: [...currentAiVendors].filter((vendor) => !previousAiVendors.has(vendor)),
|
|
94
|
+
removedAiVendors: [...previousAiVendors].filter((vendor) => !currentAiVendors.has(vendor)),
|
|
95
|
+
identityProviderChange: (current.identityProvider ?? null) !== (previous.identityProvider ?? null)
|
|
96
|
+
? {
|
|
97
|
+
from: previous.identityProvider ?? null,
|
|
98
|
+
to: current.identityProvider ?? null,
|
|
99
|
+
}
|
|
100
|
+
: null,
|
|
101
|
+
wafProviderChanges: {
|
|
102
|
+
newProviders: newWafProviders,
|
|
103
|
+
removedProviders: [...previousWafProviders].filter((provider) => !currentWafProviders.has(provider)),
|
|
104
|
+
},
|
|
105
|
+
ctPriorityHostChanges: {
|
|
106
|
+
newHosts: newCtPriorityHosts,
|
|
107
|
+
removedHosts: [...previousCtPriorityHosts].filter((host) => !currentCtPriorityHosts.has(host)),
|
|
108
|
+
},
|
|
109
|
+
summary,
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
export const buildHistoryDiff = (history) => {
|
|
113
|
+
if (history.length < 2) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
return buildHistoryDiffFromSnapshots(history[0], history[1]);
|
|
117
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type ResponseHeaders = Record<string, string | string[] | undefined>;
|
|
2
|
+
export declare function normalizeHtmlSignature(body: string): string;
|
|
3
|
+
export declare function getHtmlTitle(body: string): string | null;
|
|
4
|
+
export declare function extractHtmlTitle(body: string): string | null;
|
|
5
|
+
export declare function summarizeEvidence<T>(values: Array<T | null | undefined | false>, limit?: number): T[];
|
|
6
|
+
export declare function redactToken(value: string, visible?: number): string;
|
|
7
|
+
export declare function collectPassiveLeakSignals(html: string, finalUrl: URL, metaGenerator: string | null, externalScriptUrls: string[], externalStylesheetUrls: string[]): any[];
|
|
8
|
+
export declare function collectClientExposureSignals(html: string, finalUrl: URL): any[];
|
|
9
|
+
export declare function collectSameSiteHosts(finalUrl: URL, values: Array<string | null | undefined>): string[];
|
|
10
|
+
export declare function classifyHtmlApiFallback(probePath: string, finalUrl: URL, resolvedUrl: URL, body: string, homepageSignature: string | null, homepageTitle: string | null): boolean;
|
|
11
|
+
export declare function isAccessDeniedHtml(headers: ResponseHeaders, body: string): boolean;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { CLIENT_EXPOSURE_EVIDENCE_LIMIT, HTML_SIGNATURE_LIMIT, SUMMARY_EVIDENCE_LIMIT } from "./scannerConfig.js";
|
|
2
|
+
import { getSiteDomain, headerValue, unique } from "./utils.js";
|
|
3
|
+
const stripTagBlocks = (input, tagName) => {
|
|
4
|
+
const openToken = `<${tagName}`;
|
|
5
|
+
const closeToken = `</${tagName}>`;
|
|
6
|
+
const lower = input.toLowerCase();
|
|
7
|
+
let cursor = 0;
|
|
8
|
+
let output = "";
|
|
9
|
+
while (cursor < input.length) {
|
|
10
|
+
const openIndex = lower.indexOf(openToken, cursor);
|
|
11
|
+
if (openIndex === -1) {
|
|
12
|
+
output += input.slice(cursor);
|
|
13
|
+
break;
|
|
14
|
+
}
|
|
15
|
+
output += input.slice(cursor, openIndex);
|
|
16
|
+
const closeIndex = lower.indexOf(closeToken, openIndex + openToken.length);
|
|
17
|
+
if (closeIndex === -1) {
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
cursor = closeIndex + closeToken.length;
|
|
21
|
+
output += " ";
|
|
22
|
+
}
|
|
23
|
+
return output;
|
|
24
|
+
};
|
|
25
|
+
export function normalizeHtmlSignature(body) {
|
|
26
|
+
const withoutScriptBlocks = stripTagBlocks(body, "script");
|
|
27
|
+
const withoutStyleBlocks = stripTagBlocks(withoutScriptBlocks, "style");
|
|
28
|
+
return withoutStyleBlocks
|
|
29
|
+
.replace(/<[^>]+>/g, " ")
|
|
30
|
+
.replace(/\s+/g, " ")
|
|
31
|
+
.trim()
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.slice(0, HTML_SIGNATURE_LIMIT);
|
|
34
|
+
}
|
|
35
|
+
export function getHtmlTitle(body) {
|
|
36
|
+
const lower = body.toLowerCase();
|
|
37
|
+
const openIndex = lower.indexOf("<title");
|
|
38
|
+
if (openIndex === -1) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const openTagEnd = lower.indexOf(">", openIndex);
|
|
42
|
+
if (openTagEnd === -1) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const closeIndex = lower.indexOf("</title>", openTagEnd + 1);
|
|
46
|
+
if (closeIndex === -1 || closeIndex <= openTagEnd) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return body.slice(openTagEnd + 1, closeIndex).replace(/\s+/g, " ").trim();
|
|
50
|
+
}
|
|
51
|
+
export function extractHtmlTitle(body) {
|
|
52
|
+
const title = getHtmlTitle(body);
|
|
53
|
+
return title ? title.toLowerCase() : null;
|
|
54
|
+
}
|
|
55
|
+
export function summarizeEvidence(values, limit = SUMMARY_EVIDENCE_LIMIT) {
|
|
56
|
+
return unique(values).slice(0, limit);
|
|
57
|
+
}
|
|
58
|
+
export function redactToken(value, visible = 8) {
|
|
59
|
+
if (!value || value.length <= visible * 2) {
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
return `${value.slice(0, visible)}...${value.slice(-visible)}`;
|
|
63
|
+
}
|
|
64
|
+
export function collectPassiveLeakSignals(html, finalUrl, metaGenerator, externalScriptUrls, externalStylesheetUrls) {
|
|
65
|
+
const signals = [];
|
|
66
|
+
const boundedHtml = html.slice(0, HTML_SIGNATURE_LIMIT * 100);
|
|
67
|
+
const sourceMapReferences = summarizeEvidence([
|
|
68
|
+
...[...boundedHtml.matchAll(/sourceMappingURL\s*=\s*([^\s"'<>]+)/gi)].map((match) => match[1]),
|
|
69
|
+
...externalScriptUrls.filter((url) => /\.map(?:$|[?#])/i.test(url)),
|
|
70
|
+
...externalStylesheetUrls.filter((url) => /\.map(?:$|[?#])/i.test(url)),
|
|
71
|
+
]).map((value) => {
|
|
72
|
+
try {
|
|
73
|
+
return new URL(value, finalUrl).toString();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
if (sourceMapReferences.length) {
|
|
80
|
+
signals.push({
|
|
81
|
+
category: "source_map",
|
|
82
|
+
severity: "warning",
|
|
83
|
+
title: "Source map references visible",
|
|
84
|
+
detail: "Production page markup exposes source map references. Review whether any public source maps reveal internal code comments, paths, or debugging detail.",
|
|
85
|
+
evidence: sourceMapReferences,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const configMarkers = summarizeEvidence([
|
|
89
|
+
/__NEXT_DATA__/.test(boundedHtml) ? "__NEXT_DATA__" : null,
|
|
90
|
+
/__NUXT__/.test(boundedHtml) ? "__NUXT__" : null,
|
|
91
|
+
/window\.__INITIAL_STATE__/.test(boundedHtml) ? "window.__INITIAL_STATE__" : null,
|
|
92
|
+
/window\.__PRELOADED_STATE__/.test(boundedHtml) ? "window.__PRELOADED_STATE__" : null,
|
|
93
|
+
/window\.__APOLLO_STATE__/.test(boundedHtml) ? "window.__APOLLO_STATE__" : null,
|
|
94
|
+
/window\.__ENV\b/.test(boundedHtml) ? "window.__ENV" : null,
|
|
95
|
+
/drupalSettings/.test(boundedHtml) ? "drupalSettings" : null,
|
|
96
|
+
/window\.__remixContext/.test(boundedHtml) ? "window.__remixContext" : null,
|
|
97
|
+
]);
|
|
98
|
+
if (configMarkers.length) {
|
|
99
|
+
signals.push({
|
|
100
|
+
category: "client_config",
|
|
101
|
+
severity: "info",
|
|
102
|
+
title: "Client bootstrap data is visible",
|
|
103
|
+
detail: "The page exposes client-side bootstrap or state objects. That is often normal, but it is worth reviewing for internal URLs, feature flags, and environment metadata that should stay private.",
|
|
104
|
+
evidence: configMarkers,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const stripeKeyMatch = boundedHtml.match(/pk_(?:live|test)_[A-Za-z0-9]{16,}/);
|
|
108
|
+
const gcpApiKeyMatch = boundedHtml.match(/AIza[0-9A-Za-z_-]{20,}/);
|
|
109
|
+
const publicKeyMatch = boundedHtml.match(/pk\.[A-Za-z0-9_-]{20,}/);
|
|
110
|
+
const sentryDsnMatch = boundedHtml.match(/https:\/\/[A-Za-z0-9_-]+@[A-Za-z0-9.-]+\.ingest\.sentry\.io\/\d+/);
|
|
111
|
+
const publicTokenEvidence = summarizeEvidence([
|
|
112
|
+
stripeKeyMatch ? redactToken(stripeKeyMatch[0]) : null,
|
|
113
|
+
gcpApiKeyMatch ? redactToken(gcpApiKeyMatch[0]) : null,
|
|
114
|
+
publicKeyMatch ? redactToken(publicKeyMatch[0]) : null,
|
|
115
|
+
sentryDsnMatch ? redactToken(sentryDsnMatch[0]) : null,
|
|
116
|
+
/apiKey["']?\s*:\s*["'][^"']{16,}["']/.test(boundedHtml) && /projectId["']?\s*:\s*["'][^"']+["']/.test(boundedHtml)
|
|
117
|
+
? "Firebase-style client config"
|
|
118
|
+
: null,
|
|
119
|
+
]);
|
|
120
|
+
if (publicTokenEvidence.length) {
|
|
121
|
+
signals.push({
|
|
122
|
+
category: "public_token",
|
|
123
|
+
severity: "warning",
|
|
124
|
+
title: "Public client-side tokens or DSNs were visible",
|
|
125
|
+
detail: "The page markup includes token- or DSN-like values that may be intended for public use. Review scopes and restrictions so they cannot be misused or confused with secrets.",
|
|
126
|
+
evidence: publicTokenEvidence,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const wpVersionMatch = boundedHtml.match(/\/wp-(?:content|includes)\/[^"' ]+\?ver=\d[\w.-]*/i);
|
|
130
|
+
const cmsMetaVersionMatch = boundedHtml.match(/content\s*=\s*["'][^"']*(wordpress|drupal|joomla|ghost)[^"']*\d[^"']*["']/i);
|
|
131
|
+
const versionEvidence = summarizeEvidence([
|
|
132
|
+
metaGenerator && /\d/.test(metaGenerator) ? metaGenerator : null,
|
|
133
|
+
wpVersionMatch ? wpVersionMatch[0] : null,
|
|
134
|
+
cmsMetaVersionMatch ? cmsMetaVersionMatch[0] : null,
|
|
135
|
+
]);
|
|
136
|
+
if (versionEvidence.length) {
|
|
137
|
+
signals.push({
|
|
138
|
+
category: "version_leak",
|
|
139
|
+
severity: "info",
|
|
140
|
+
title: "Version metadata is publicly visible",
|
|
141
|
+
detail: "The fetched page exposes framework or asset version markers. These can help maintenance, but they also make public fingerprinting easier.",
|
|
142
|
+
evidence: versionEvidence,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return signals;
|
|
146
|
+
}
|
|
147
|
+
export function collectClientExposureSignals(html, finalUrl) {
|
|
148
|
+
const signals = [];
|
|
149
|
+
const isLikelyApiAsset = (value) => /\/assets?\//i.test(value) ||
|
|
150
|
+
/\.(?:css|js|mjs|png|jpe?g|gif|svg|webp|avif|woff2?|ttf|eot)(?:[?#]|$)/i.test(value);
|
|
151
|
+
const endpointKeywords = ["/api", "/graphql", "/trpc", "/socket.io", "/rpc", "/_next/data"];
|
|
152
|
+
const rawCandidates = html
|
|
153
|
+
.slice(0, HTML_SIGNATURE_LIMIT * 100)
|
|
154
|
+
.split(/[\s"'`<>]+/)
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.filter((token) => token.startsWith("/") || token.startsWith("http://") || token.startsWith("https://"))
|
|
157
|
+
.filter((token) => endpointKeywords.some((keyword) => token.toLowerCase().includes(keyword)));
|
|
158
|
+
const rawEndpoints = summarizeEvidence(rawCandidates, CLIENT_EXPOSURE_EVIDENCE_LIMIT).map((value) => {
|
|
159
|
+
try {
|
|
160
|
+
return new URL(value, finalUrl).toString();
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
}).filter((value) => !isLikelyApiAsset(value));
|
|
166
|
+
if (rawEndpoints.length) {
|
|
167
|
+
signals.push({
|
|
168
|
+
category: "api_endpoint",
|
|
169
|
+
severity: "info",
|
|
170
|
+
title: "Client-visible API endpoints were referenced",
|
|
171
|
+
detail: "The fetched page exposes endpoint-style paths or URLs in markup or bootstrap data. That is often normal, but it makes the public application surface easier to enumerate.",
|
|
172
|
+
evidence: rawEndpoints,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
const serviceMarkers = summarizeEvidence([
|
|
176
|
+
/supabase/i.test(html) ? "Supabase" : null,
|
|
177
|
+
/algolia/i.test(html) ? "Algolia" : null,
|
|
178
|
+
/sentry/i.test(html) ? "Sentry" : null,
|
|
179
|
+
/firebase/i.test(html) ? "Firebase" : null,
|
|
180
|
+
/segment/i.test(html) ? "Segment" : null,
|
|
181
|
+
/launchdarkly/i.test(html) ? "LaunchDarkly" : null,
|
|
182
|
+
/amplitude/i.test(html) ? "Amplitude" : null,
|
|
183
|
+
]);
|
|
184
|
+
if (serviceMarkers.length) {
|
|
185
|
+
signals.push({
|
|
186
|
+
category: "service",
|
|
187
|
+
severity: "info",
|
|
188
|
+
title: "Client-integrated services were visible",
|
|
189
|
+
detail: "Public page content reveals named third-party or backend-adjacent client integrations. Review what configuration or identifiers are intentionally exposed.",
|
|
190
|
+
evidence: serviceMarkers,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
const configMarkers = summarizeEvidence([
|
|
194
|
+
/apiBaseUrl/i.test(html) ? "apiBaseUrl" : null,
|
|
195
|
+
/graphqlEndpoint/i.test(html) ? "graphqlEndpoint" : null,
|
|
196
|
+
/sentryDsn/i.test(html) ? "sentryDsn" : null,
|
|
197
|
+
/supabaseUrl/i.test(html) ? "supabaseUrl" : null,
|
|
198
|
+
/projectId/i.test(html) && /apiKey/i.test(html) ? "projectId + apiKey" : null,
|
|
199
|
+
/environment["']?\s*:\s*["'][^"']+/i.test(html) ? "environment" : null,
|
|
200
|
+
]);
|
|
201
|
+
if (configMarkers.length) {
|
|
202
|
+
signals.push({
|
|
203
|
+
category: "config",
|
|
204
|
+
severity: "info",
|
|
205
|
+
title: "Client configuration markers were visible",
|
|
206
|
+
detail: "The page includes configuration-style keys or bootstrap fields that may reveal how the client talks to backend services.",
|
|
207
|
+
evidence: configMarkers,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
const environmentMarkers = summarizeEvidence([
|
|
211
|
+
/\b(?:environment|env|release)[^"'`\n]{0,32}staging|staging[^"'`\n]{0,32}(?:environment|env|release)/i.test(html) ? "staging environment" : null,
|
|
212
|
+
/\b(?:environment|env|release)[^"'`\n]{0,32}dev(?:elopment)?|dev(?:elopment)?[^"'`\n]{0,32}(?:environment|env|release)/i.test(html) ? "development environment" : null,
|
|
213
|
+
/\b(?:environment|env|release)[^"'`\n]{0,32}internal|internal[^"'`\n]{0,32}(?:environment|env|release)/i.test(html) ? "internal environment" : null,
|
|
214
|
+
/\b(?:environment|env|release)[^"'`\n]{0,32}sandbox|sandbox[^"'`\n]{0,32}(?:environment|env|release)/i.test(html) ? "sandbox environment" : null,
|
|
215
|
+
/\b(?:environment|env|release)[^"'`\n]{0,32}preview|preview[^"'`\n]{0,32}(?:environment|env|release)/i.test(html) ? "preview environment" : null,
|
|
216
|
+
]);
|
|
217
|
+
if (environmentMarkers.length) {
|
|
218
|
+
signals.push({
|
|
219
|
+
category: "environment",
|
|
220
|
+
severity: "warning",
|
|
221
|
+
title: "Environment naming was visible in client content",
|
|
222
|
+
detail: "The fetched page references environment-like labels such as staging, development, preview, or internal. That can be harmless, but it is worth checking for unintended environment leakage.",
|
|
223
|
+
evidence: environmentMarkers,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return signals;
|
|
227
|
+
}
|
|
228
|
+
export function collectSameSiteHosts(finalUrl, values) {
|
|
229
|
+
const siteDomain = getSiteDomain(finalUrl.hostname);
|
|
230
|
+
const hosts = values
|
|
231
|
+
.map((value) => {
|
|
232
|
+
if (!value) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
return new URL(value, finalUrl).hostname.toLowerCase();
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
.filter((hostname) => Boolean(hostname))
|
|
243
|
+
.filter((hostname) => hostname !== finalUrl.hostname.toLowerCase())
|
|
244
|
+
.filter((hostname) => getSiteDomain(hostname) === siteDomain);
|
|
245
|
+
return unique(hosts);
|
|
246
|
+
}
|
|
247
|
+
export function classifyHtmlApiFallback(probePath, finalUrl, resolvedUrl, body, homepageSignature, homepageTitle) {
|
|
248
|
+
const looksLikeHtml = /<html[\s>]|<!doctype html/i.test(body);
|
|
249
|
+
if (!looksLikeHtml) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
if (resolvedUrl.origin === finalUrl.origin && resolvedUrl.pathname === finalUrl.pathname) {
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
const probeSegments = probePath.split("/").filter(Boolean);
|
|
256
|
+
const resolvedSegments = resolvedUrl.pathname.split("/").filter(Boolean);
|
|
257
|
+
if (!resolvedSegments.length && probeSegments.length) {
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
const bodySignature = normalizeHtmlSignature(body);
|
|
261
|
+
const bodyTitle = extractHtmlTitle(body);
|
|
262
|
+
return Boolean(homepageSignature &&
|
|
263
|
+
bodySignature &&
|
|
264
|
+
(bodySignature === homepageSignature ||
|
|
265
|
+
(homepageTitle && bodyTitle && homepageTitle === bodyTitle)));
|
|
266
|
+
}
|
|
267
|
+
export function isAccessDeniedHtml(headers, body) {
|
|
268
|
+
const server = (headerValue(headers, "server") || "").toLowerCase();
|
|
269
|
+
const bodyText = body.toLowerCase();
|
|
270
|
+
const title = extractHtmlTitle(body) || "";
|
|
271
|
+
return (server.includes("sucuri") ||
|
|
272
|
+
bodyText.includes("website security - access denied") ||
|
|
273
|
+
bodyText.includes("access denied") ||
|
|
274
|
+
bodyText.includes("403 forbidden") ||
|
|
275
|
+
bodyText.includes("request forbidden by administrative rules") ||
|
|
276
|
+
bodyText.includes("request blocked") ||
|
|
277
|
+
title.includes("access denied") ||
|
|
278
|
+
title.includes("403 forbidden"));
|
|
279
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { URL } from "node:url";
|
|
2
|
+
import type { HtmlSecurityInfo } from "./types.js";
|
|
3
|
+
export declare function fetchHtmlDocument(finalUrl: URL): Promise<{
|
|
4
|
+
html: string;
|
|
5
|
+
pageTitle: string;
|
|
6
|
+
signature: string;
|
|
7
|
+
}>;
|
|
8
|
+
export declare function analyzeHtmlSecurity(finalUrl: URL, document: {
|
|
9
|
+
html: string;
|
|
10
|
+
pageTitle: string | null;
|
|
11
|
+
} | null): HtmlSecurityInfo;
|
|
12
|
+
export declare function analyzeHtmlDocument(input: string | URL, html: string): HtmlSecurityInfo;
|
|
13
|
+
export declare function detectAssessmentLimitation(statusCode: number, headers: Record<string, string>, html: string | null): {
|
|
14
|
+
limited: boolean;
|
|
15
|
+
kind: "auth_required";
|
|
16
|
+
title: string;
|
|
17
|
+
detail: string;
|
|
18
|
+
} | {
|
|
19
|
+
limited: boolean;
|
|
20
|
+
kind: "rate_limited";
|
|
21
|
+
title: string;
|
|
22
|
+
detail: string;
|
|
23
|
+
} | {
|
|
24
|
+
limited: boolean;
|
|
25
|
+
kind: "service_unavailable";
|
|
26
|
+
title: string;
|
|
27
|
+
detail: string;
|
|
28
|
+
} | {
|
|
29
|
+
limited: boolean;
|
|
30
|
+
kind: "blocked_edge_response";
|
|
31
|
+
title: string;
|
|
32
|
+
detail: string;
|
|
33
|
+
} | {
|
|
34
|
+
limited: boolean;
|
|
35
|
+
kind: any;
|
|
36
|
+
title: any;
|
|
37
|
+
detail: any;
|
|
38
|
+
};
|