securl 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +241 -0
- package/LICENSE +21 -0
- package/README.md +427 -0
- package/RELEASING.md +37 -0
- package/SECURITY.md +27 -0
- package/dist/certificate.d.ts +5 -0
- package/dist/certificate.js +92 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +674 -0
- package/dist/compromiseSignals.d.ts +10 -0
- package/dist/compromiseSignals.js +183 -0
- package/dist/cookie-analysis.d.ts +2 -0
- package/dist/cookie-analysis.js +41 -0
- package/dist/cookieAnalysis.d.ts +2 -0
- package/dist/cookieAnalysis.js +82 -0
- package/dist/ctDiscovery.d.ts +19 -0
- package/dist/ctDiscovery.js +357 -0
- package/dist/domain-security.d.ts +10 -0
- package/dist/domain-security.js +416 -0
- package/dist/header-analysis.d.ts +14 -0
- package/dist/header-analysis.js +165 -0
- package/dist/historyDiff.d.ts +4 -0
- package/dist/historyDiff.js +117 -0
- package/dist/html-extraction.d.ts +12 -0
- package/dist/html-extraction.js +279 -0
- package/dist/html-page-analysis.d.ts +38 -0
- package/dist/html-page-analysis.js +459 -0
- package/dist/htmlInsights.d.ts +23 -0
- package/dist/htmlInsights.js +460 -0
- package/dist/identityProvider.d.ts +14 -0
- package/dist/identityProvider.js +259 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +1008 -0
- package/dist/infrastructure.d.ts +9 -0
- package/dist/infrastructure.js +149 -0
- package/dist/libraryRisk.d.ts +3 -0
- package/dist/libraryRisk.js +164 -0
- package/dist/network-validation.d.ts +30 -0
- package/dist/network-validation.js +161 -0
- package/dist/network.d.ts +34 -0
- package/dist/network.js +139 -0
- package/dist/passive-intelligence.d.ts +21 -0
- package/dist/passive-intelligence.js +247 -0
- package/dist/path-discovery.d.ts +4 -0
- package/dist/path-discovery.js +50 -0
- package/dist/postureDigest.d.ts +142 -0
- package/dist/postureDigest.js +159 -0
- package/dist/postureDrift.d.ts +4 -0
- package/dist/postureDrift.js +118 -0
- package/dist/postureRemediation.d.ts +6 -0
- package/dist/postureRemediation.js +286 -0
- package/dist/redirectChain.d.ts +2 -0
- package/dist/redirectChain.js +39 -0
- package/dist/riskEvents.d.ts +3 -0
- package/dist/riskEvents.js +187 -0
- package/dist/scannerConfig.d.ts +49 -0
- package/dist/scannerConfig.js +79 -0
- package/dist/scoring.d.ts +32 -0
- package/dist/scoring.js +367 -0
- package/dist/security-txt.d.ts +4 -0
- package/dist/security-txt.js +123 -0
- package/dist/surfaceEnrichment.d.ts +44 -0
- package/dist/surfaceEnrichment.js +377 -0
- package/dist/technology-detection.d.ts +4 -0
- package/dist/technology-detection.js +93 -0
- package/dist/types.d.ts +730 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +7 -0
- package/dist/utils.js +66 -0
- package/dist/wafFingerprint.d.ts +5 -0
- package/dist/wafFingerprint.js +156 -0
- package/examples/risk-events.mjs +27 -0
- package/examples/scan-url.mjs +17 -0
- package/package.json +102 -0
package/dist/scoring.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
const HEADER_PENALTY = {
|
|
2
|
+
"strict-transport-security": { missing: 10, warning: 4 },
|
|
3
|
+
"content-security-policy": { missing: 12, warning: 4 },
|
|
4
|
+
"x-frame-options": { missing: 3, warning: 2 },
|
|
5
|
+
"x-content-type-options": { missing: 4, warning: 2 },
|
|
6
|
+
"referrer-policy": { missing: 3, warning: 2 },
|
|
7
|
+
"permissions-policy": { missing: 1, warning: 1 },
|
|
8
|
+
"cross-origin-opener-policy": { missing: 1, warning: 1 },
|
|
9
|
+
"cross-origin-resource-policy": { missing: 1, warning: 1 },
|
|
10
|
+
};
|
|
11
|
+
const POSTURE_WEIGHTS = {
|
|
12
|
+
edge: 0.25,
|
|
13
|
+
content: 0.2,
|
|
14
|
+
domain: 0.2,
|
|
15
|
+
exposure: 0.15,
|
|
16
|
+
api: 0.1,
|
|
17
|
+
trust: 0.05,
|
|
18
|
+
ai: 0.05,
|
|
19
|
+
};
|
|
20
|
+
// A site with no public AI/automation surface has no AI weakness to score, so
|
|
21
|
+
// the AI area is treated as fully neutral (no penalty) when nothing is detected.
|
|
22
|
+
const AI_NEUTRAL_NO_SURFACE_PENALTY = 0;
|
|
23
|
+
// Cap the per-area contribution of fetched-page (HTML) findings so a handful of
|
|
24
|
+
// common low-severity findings (inline scripts, partial SRI) can't zero out the
|
|
25
|
+
// whole content area. CSP remains the dominant content-security driver.
|
|
26
|
+
const HTML_FINDINGS_PENALTY_CAP = 30;
|
|
27
|
+
// Penalty for the non-CSP "edge" headers, weighted by the per-header severity the
|
|
28
|
+
// codebase already declares in HEADER_PENALTY. This keeps the posture scorer
|
|
29
|
+
// consistent with scoreAnalysis: universally-omitted low-value headers
|
|
30
|
+
// (COOP/CORP/Permissions-Policy = 1 each) cost far less than genuinely important
|
|
31
|
+
// ones (HSTS = 10), instead of a flat rate that punished every site equally.
|
|
32
|
+
const edgeHeaderPenaltyFor = (findings, status) => findings
|
|
33
|
+
.filter((header) => header.status === status)
|
|
34
|
+
.reduce((sum, header) => {
|
|
35
|
+
const weights = HEADER_PENALTY[header.key] ?? { missing: 4, warning: 2 };
|
|
36
|
+
return sum + (status === "missing" ? weights.missing : weights.warning);
|
|
37
|
+
}, 0);
|
|
38
|
+
const HOSTED_PLATFORM_SUFFIXES = [
|
|
39
|
+
".up.railway.app",
|
|
40
|
+
".vercel.app",
|
|
41
|
+
".netlify.app",
|
|
42
|
+
".pages.dev",
|
|
43
|
+
".onrender.com",
|
|
44
|
+
".fly.dev",
|
|
45
|
+
".herokuapp.com",
|
|
46
|
+
".github.io",
|
|
47
|
+
];
|
|
48
|
+
const clamp = (value) => Math.max(0, Math.min(100, value));
|
|
49
|
+
const isHostedPlatformTarget = (analysis) => {
|
|
50
|
+
let hostname = "";
|
|
51
|
+
try {
|
|
52
|
+
hostname = new URL(analysis.finalUrl).hostname.toLowerCase();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
if (HOSTED_PLATFORM_SUFFIXES.some((suffix) => hostname.endsWith(suffix))) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
return analysis.infrastructure?.providers?.some((provider) => provider.category === "paas"
|
|
61
|
+
&& ["Vercel", "Netlify", "Heroku"].includes(provider.provider)) ?? false;
|
|
62
|
+
};
|
|
63
|
+
const severeAssessmentCaps = {
|
|
64
|
+
blocked_edge_response: { default: 59, domain: 78 },
|
|
65
|
+
auth_required: { default: 59, domain: 78 },
|
|
66
|
+
rate_limited: { default: 54, domain: 74 },
|
|
67
|
+
service_unavailable: { default: 35, domain: 72 },
|
|
68
|
+
other: { default: 59, domain: 74 },
|
|
69
|
+
};
|
|
70
|
+
const statusAvailabilityPenalty = (statusCode) => {
|
|
71
|
+
if (!statusCode)
|
|
72
|
+
return 0;
|
|
73
|
+
if (statusCode >= 500)
|
|
74
|
+
return 35;
|
|
75
|
+
if (statusCode === 429)
|
|
76
|
+
return 20;
|
|
77
|
+
return 0;
|
|
78
|
+
};
|
|
79
|
+
const limitedAssessmentScoreCap = (kind) => {
|
|
80
|
+
if (kind === "service_unavailable")
|
|
81
|
+
return 49;
|
|
82
|
+
if (kind === "rate_limited")
|
|
83
|
+
return 59;
|
|
84
|
+
return 64;
|
|
85
|
+
};
|
|
86
|
+
const trustWeaknessScoreCap = (areaScores) => {
|
|
87
|
+
const domainArea = areaScores.find((area) => area.key === "domain");
|
|
88
|
+
if (!domainArea || domainArea.score >= 65) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
cap: domainArea.score < 45 ? 79 : 89,
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
const cappedAreaScore = (areaKey, score, assessmentLimitation) => {
|
|
96
|
+
if (!assessmentLimitation.limited || !assessmentLimitation.kind) {
|
|
97
|
+
return score;
|
|
98
|
+
}
|
|
99
|
+
const caps = severeAssessmentCaps[assessmentLimitation.kind];
|
|
100
|
+
return Math.min(score, areaKey === "domain" ? caps.domain : caps.default);
|
|
101
|
+
};
|
|
102
|
+
const statusForScore = (score) => {
|
|
103
|
+
if (score >= 85)
|
|
104
|
+
return "strong";
|
|
105
|
+
if (score >= 65)
|
|
106
|
+
return "watch";
|
|
107
|
+
return "weak";
|
|
108
|
+
};
|
|
109
|
+
const AREA_LABELS = {
|
|
110
|
+
edge: "Edge Security",
|
|
111
|
+
content: "Content Security",
|
|
112
|
+
domain: "Domain & Trust",
|
|
113
|
+
exposure: "Exposure Control",
|
|
114
|
+
api: "API Surface",
|
|
115
|
+
trust: "Third-Party Trust",
|
|
116
|
+
ai: "AI & Automation",
|
|
117
|
+
overall: "Overall posture",
|
|
118
|
+
};
|
|
119
|
+
const scoreDriver = (areaKey, impact, label, detail, source) => {
|
|
120
|
+
if (impact <= 0) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
areaKey,
|
|
125
|
+
areaLabel: AREA_LABELS[areaKey],
|
|
126
|
+
impact,
|
|
127
|
+
label,
|
|
128
|
+
detail,
|
|
129
|
+
source,
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
export function gradeForScore(score) {
|
|
133
|
+
if (score >= 97)
|
|
134
|
+
return "A+";
|
|
135
|
+
if (score >= 90)
|
|
136
|
+
return "A";
|
|
137
|
+
if (score >= 80)
|
|
138
|
+
return "B";
|
|
139
|
+
if (score >= 70)
|
|
140
|
+
return "C";
|
|
141
|
+
if (score >= 60)
|
|
142
|
+
return "D";
|
|
143
|
+
return "F";
|
|
144
|
+
}
|
|
145
|
+
function gradeForPostureScore(score, assessmentLimitation) {
|
|
146
|
+
if (assessmentLimitation.limited) {
|
|
147
|
+
return "U";
|
|
148
|
+
}
|
|
149
|
+
return gradeForScore(score);
|
|
150
|
+
}
|
|
151
|
+
export function scoreAnalysis({ isHttps, headerResults, certificate, cookies, redirects, limitedResponse = false, }) {
|
|
152
|
+
let score = 100;
|
|
153
|
+
if (!isHttps) {
|
|
154
|
+
score -= 35;
|
|
155
|
+
}
|
|
156
|
+
for (const header of headerResults) {
|
|
157
|
+
const weights = HEADER_PENALTY[header.key] || { missing: 4, warning: 2 };
|
|
158
|
+
if (header.status === "missing") {
|
|
159
|
+
if (!limitedResponse) {
|
|
160
|
+
score -= weights.missing;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (header.status === "warning") {
|
|
164
|
+
score -= weights.warning;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (certificate.available) {
|
|
168
|
+
if (!certificate.valid) {
|
|
169
|
+
score -= 25;
|
|
170
|
+
}
|
|
171
|
+
if (certificate.protocol && /tlsv1(\.0|\.1)?$/i.test(certificate.protocol)) {
|
|
172
|
+
score -= 15;
|
|
173
|
+
}
|
|
174
|
+
if ((certificate.daysRemaining ?? 365) <= 14) {
|
|
175
|
+
score -= 10;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const scoredCookies = new Map();
|
|
179
|
+
for (const cookie of cookies) {
|
|
180
|
+
const expiresAt = cookie.expires ? Date.parse(cookie.expires) : NaN;
|
|
181
|
+
if (!Number.isNaN(expiresAt) && expiresAt <= Date.now()) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const cookieKey = cookie.name.toLowerCase();
|
|
185
|
+
const existing = scoredCookies.get(cookieKey);
|
|
186
|
+
scoredCookies.set(cookieKey, existing
|
|
187
|
+
? { secure: existing.secure && cookie.secure, httpOnly: existing.httpOnly && cookie.httpOnly, sameSite: existing.sameSite && cookie.sameSite }
|
|
188
|
+
: { secure: cookie.secure, httpOnly: cookie.httpOnly, sameSite: cookie.sameSite });
|
|
189
|
+
}
|
|
190
|
+
let cookiePenalty = 0;
|
|
191
|
+
if (!limitedResponse) {
|
|
192
|
+
for (const [name, cookie] of scoredCookies.entries()) {
|
|
193
|
+
const isLikelyPreferenceCookie = /(locale|lang|language|country|theme|consent|prefs?|preference|visitor|device|did)/i.test(name);
|
|
194
|
+
let perCookiePenalty = 0;
|
|
195
|
+
if (!cookie.secure)
|
|
196
|
+
perCookiePenalty += 1;
|
|
197
|
+
if (!cookie.httpOnly && !isLikelyPreferenceCookie)
|
|
198
|
+
perCookiePenalty += 1;
|
|
199
|
+
if (!cookie.sameSite)
|
|
200
|
+
perCookiePenalty += 1;
|
|
201
|
+
cookiePenalty += Math.min(perCookiePenalty, 4);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
score -= Math.min(cookiePenalty, 8);
|
|
205
|
+
if (redirects.length > 1) {
|
|
206
|
+
score -= Math.min(redirects.length - 1, 4) * 2;
|
|
207
|
+
}
|
|
208
|
+
if (limitedResponse) {
|
|
209
|
+
score = Math.min(score, 84);
|
|
210
|
+
}
|
|
211
|
+
score = Math.max(0, Math.min(100, score));
|
|
212
|
+
return { score, grade: gradeForScore(score) };
|
|
213
|
+
}
|
|
214
|
+
export function getPostureAreaScores(analysis) {
|
|
215
|
+
const hostedPlatformTarget = isHostedPlatformTarget(analysis);
|
|
216
|
+
const cspHeaderFindings = analysis.headers.filter((header) => header.key === "content-security-policy" && header.status !== "present");
|
|
217
|
+
const edgeHeaderFindings = analysis.headers.filter((header) => header.key !== "content-security-policy" && header.status !== "present");
|
|
218
|
+
const edgeMissingPenalty = edgeHeaderPenaltyFor(edgeHeaderFindings, "missing");
|
|
219
|
+
const edgeWarningPenalty = edgeHeaderPenaltyFor(edgeHeaderFindings, "warning");
|
|
220
|
+
const cspHeaderIssueCount = cspHeaderFindings.length;
|
|
221
|
+
const cookieIssueCount = analysis.cookies.reduce((count, cookie) => count + cookie.issues.length, 0);
|
|
222
|
+
const htmlPenalty = Math.min(analysis.htmlSecurity.issues.length * 10, HTML_FINDINGS_PENALTY_CAP);
|
|
223
|
+
const redirectPenalty = analysis.redirects.length > 1 ? Math.max(analysis.redirects.length - 1, 0) * 2 : 0;
|
|
224
|
+
const availabilityPenalty = statusAvailabilityPenalty(analysis.statusCode);
|
|
225
|
+
const transportPenalty = new URL(analysis.finalUrl).protocol === "https:" ? 0 : 35;
|
|
226
|
+
const certificatePenalty = analysis.certificate.available && !analysis.certificate.valid
|
|
227
|
+
? 25
|
|
228
|
+
: analysis.certificate.protocol && /tlsv1(\.0|\.1)?$/i.test(analysis.certificate.protocol)
|
|
229
|
+
? 15
|
|
230
|
+
: (analysis.certificate.daysRemaining ?? 365) <= 14
|
|
231
|
+
? 10
|
|
232
|
+
: 0;
|
|
233
|
+
const edgePenalty = transportPenalty +
|
|
234
|
+
certificatePenalty +
|
|
235
|
+
edgeMissingPenalty +
|
|
236
|
+
edgeWarningPenalty +
|
|
237
|
+
analysis.corsSecurity.issues.length * 8 +
|
|
238
|
+
availabilityPenalty +
|
|
239
|
+
redirectPenalty;
|
|
240
|
+
const contentPenalty = cspHeaderIssueCount * 18 +
|
|
241
|
+
htmlPenalty +
|
|
242
|
+
cookieIssueCount * 6;
|
|
243
|
+
const domainPenaltyRaw = analysis.domainSecurity.issues.length * 8 +
|
|
244
|
+
analysis.securityTxt.issues.length * 5 +
|
|
245
|
+
analysis.publicSignals.issues.length * 4;
|
|
246
|
+
const domainPenalty = hostedPlatformTarget ? Math.min(domainPenaltyRaw, 30) : domainPenaltyRaw;
|
|
247
|
+
const exposurePenalty = analysis.exposure.issues.length * 20 +
|
|
248
|
+
analysis.exposure.probes.filter((probe) => probe.finding === "interesting").length * 4;
|
|
249
|
+
const apiPenalty = analysis.apiSurface.issues.length * 15 +
|
|
250
|
+
analysis.apiSurface.probes.filter((probe) => probe.classification === "interesting").length * 4;
|
|
251
|
+
const trustPenalty = analysis.thirdPartyTrust.highRiskProviders * 10 +
|
|
252
|
+
analysis.thirdPartyTrust.issues.length * 6;
|
|
253
|
+
const aiPenalty = (!analysis.aiSurface.detected ? AI_NEUTRAL_NO_SURFACE_PENALTY : 0) +
|
|
254
|
+
analysis.aiSurface.issues.length * 12 +
|
|
255
|
+
(analysis.aiSurface.detected && !analysis.aiSurface.disclosures.length ? 8 : 0);
|
|
256
|
+
const areas = [
|
|
257
|
+
{ key: "edge", label: "Edge Security", score: cappedAreaScore("edge", clamp(100 - edgePenalty), analysis.assessmentLimitation) },
|
|
258
|
+
{ key: "content", label: "Content Security", score: cappedAreaScore("content", clamp(100 - contentPenalty), analysis.assessmentLimitation) },
|
|
259
|
+
{ key: "domain", label: "Domain & Trust", score: cappedAreaScore("domain", clamp(100 - domainPenalty), analysis.assessmentLimitation) },
|
|
260
|
+
{ key: "exposure", label: "Exposure Control", score: cappedAreaScore("exposure", clamp(100 - exposurePenalty), analysis.assessmentLimitation) },
|
|
261
|
+
{ key: "api", label: "API Surface", score: cappedAreaScore("api", clamp(100 - apiPenalty), analysis.assessmentLimitation) },
|
|
262
|
+
{ key: "trust", label: "Third-Party Trust", score: cappedAreaScore("trust", clamp(100 - trustPenalty), analysis.assessmentLimitation) },
|
|
263
|
+
{ key: "ai", label: "AI & Automation", score: cappedAreaScore("ai", clamp(100 - aiPenalty), analysis.assessmentLimitation) },
|
|
264
|
+
];
|
|
265
|
+
return areas.map((area) => ({
|
|
266
|
+
...area,
|
|
267
|
+
status: statusForScore(area.score),
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
export function getPostureScoreDrivers(analysis) {
|
|
271
|
+
const hostedPlatformTarget = isHostedPlatformTarget(analysis);
|
|
272
|
+
const cspHeaderFindings = analysis.headers.filter((header) => header.key === "content-security-policy" && header.status !== "present");
|
|
273
|
+
const edgeHeaderFindings = analysis.headers.filter((header) => header.key !== "content-security-policy" && header.status !== "present");
|
|
274
|
+
const missingHeaderCount = edgeHeaderFindings.filter((header) => header.status === "missing").length;
|
|
275
|
+
const warningHeaderCount = edgeHeaderFindings.filter((header) => header.status === "warning").length;
|
|
276
|
+
const edgeMissingPenalty = edgeHeaderPenaltyFor(edgeHeaderFindings, "missing");
|
|
277
|
+
const edgeWarningPenalty = edgeHeaderPenaltyFor(edgeHeaderFindings, "warning");
|
|
278
|
+
const htmlPenalty = Math.min(analysis.htmlSecurity.issues.length * 10, HTML_FINDINGS_PENALTY_CAP);
|
|
279
|
+
const cookieIssueCount = analysis.cookies.reduce((count, cookie) => count + cookie.issues.length, 0);
|
|
280
|
+
const redirectPenalty = analysis.redirects.length > 1 ? Math.max(analysis.redirects.length - 1, 0) * 2 : 0;
|
|
281
|
+
const availabilityPenalty = statusAvailabilityPenalty(analysis.statusCode);
|
|
282
|
+
const transportPenalty = new URL(analysis.finalUrl).protocol === "https:" ? 0 : 35;
|
|
283
|
+
const certificatePenalty = analysis.certificate.available && !analysis.certificate.valid
|
|
284
|
+
? 25
|
|
285
|
+
: analysis.certificate.protocol && /tlsv1(\.0|\.1)?$/i.test(analysis.certificate.protocol)
|
|
286
|
+
? 15
|
|
287
|
+
: (analysis.certificate.daysRemaining ?? 365) <= 14
|
|
288
|
+
? 10
|
|
289
|
+
: 0;
|
|
290
|
+
const domainPenaltyRaw = analysis.domainSecurity.issues.length * 8 +
|
|
291
|
+
analysis.securityTxt.issues.length * 5 +
|
|
292
|
+
analysis.publicSignals.issues.length * 4;
|
|
293
|
+
const domainPenalty = hostedPlatformTarget ? Math.min(domainPenaltyRaw, 30) : domainPenaltyRaw;
|
|
294
|
+
const highRiskThirdPartyPenalty = analysis.thirdPartyTrust.highRiskProviders * 10;
|
|
295
|
+
const thirdPartyIssuePenalty = analysis.thirdPartyTrust.issues.length * 6;
|
|
296
|
+
const absentAiPenalty = !analysis.aiSurface.detected ? AI_NEUTRAL_NO_SURFACE_PENALTY : 0;
|
|
297
|
+
const missingAiDisclosurePenalty = analysis.aiSurface.detected && !analysis.aiSurface.disclosures.length ? 8 : 0;
|
|
298
|
+
return [
|
|
299
|
+
scoreDriver("edge", transportPenalty, "Plain HTTP final URL", "The final URL did not use HTTPS, which heavily reduces edge-security confidence.", "tls"),
|
|
300
|
+
scoreDriver("edge", certificatePenalty, "TLS certificate or protocol issue", "The observed TLS posture was invalid, outdated, or close to expiry.", "tls"),
|
|
301
|
+
scoreDriver("edge", edgeMissingPenalty, "Missing edge headers", `${missingHeaderCount} non-CSP browser-facing protection${missingHeaderCount === 1 ? " is" : "s are"} missing.`, "headers"),
|
|
302
|
+
scoreDriver("edge", edgeWarningPenalty, "Weak edge header values", `${warningHeaderCount} non-CSP browser-facing protection${warningHeaderCount === 1 ? " has" : "s have"} warning-level configuration.`, "headers"),
|
|
303
|
+
scoreDriver("edge", analysis.corsSecurity.issues.length * 8, "CORS configuration findings", `${analysis.corsSecurity.issues.length} CORS finding${analysis.corsSecurity.issues.length === 1 ? "" : "s"} reduced edge confidence.`, "headers"),
|
|
304
|
+
scoreDriver("edge", availabilityPenalty, "Availability status penalty", `The target returned HTTP ${analysis.statusCode}, limiting confidence in the observed posture.`, "availability"),
|
|
305
|
+
scoreDriver("edge", redirectPenalty, "Redirect chain penalty", `The scan followed ${analysis.redirects.length - 1} redirect${analysis.redirects.length === 2 ? "" : "s"} before the final response.`, "headers"),
|
|
306
|
+
scoreDriver("content", cspHeaderFindings.length * 18, "Content-Security-Policy gap", "CSP is missing or weak, which is the largest content-security driver.", "headers"),
|
|
307
|
+
scoreDriver("content", htmlPenalty, "HTML security findings", `${analysis.htmlSecurity.issues.length} fetched-page finding${analysis.htmlSecurity.issues.length === 1 ? "" : "s"} affected content-security confidence.`, "html"),
|
|
308
|
+
scoreDriver("content", cookieIssueCount * 6, "Cookie attribute findings", `${cookieIssueCount} cookie attribute finding${cookieIssueCount === 1 ? "" : "s"} affected content-security confidence.`, "cookies"),
|
|
309
|
+
scoreDriver("domain", domainPenalty, "Domain and public-trust findings", `${analysis.domainSecurity.issues.length + analysis.securityTxt.issues.length + analysis.publicSignals.issues.length} domain, disclosure, or public-trust signal${analysis.domainSecurity.issues.length + analysis.securityTxt.issues.length + analysis.publicSignals.issues.length === 1 ? "" : "s"} reduced trust confidence${hostedPlatformTarget && domainPenaltyRaw > domainPenalty ? " after hosted-platform softening" : ""}.`, "dns"),
|
|
310
|
+
scoreDriver("exposure", analysis.exposure.issues.length * 20, "Exposed sensitive path findings", `${analysis.exposure.issues.length} exposure finding${analysis.exposure.issues.length === 1 ? "" : "s"} had high score impact.`, "public_record"),
|
|
311
|
+
scoreDriver("exposure", analysis.exposure.probes.filter((probe) => probe.finding === "interesting").length * 4, "Interesting exposure probes", "Public discovery or sensitive-looking paths produced review-worthy responses.", "public_record"),
|
|
312
|
+
scoreDriver("api", analysis.apiSurface.issues.length * 15, "API surface findings", `${analysis.apiSurface.issues.length} API surface finding${analysis.apiSurface.issues.length === 1 ? "" : "s"} reduced API confidence.`, "public_record"),
|
|
313
|
+
scoreDriver("api", analysis.apiSurface.probes.filter((probe) => probe.classification === "interesting").length * 4, "Interesting API probes", "API-like paths produced review-worthy responses.", "public_record"),
|
|
314
|
+
scoreDriver("trust", highRiskThirdPartyPenalty, "High-risk third-party providers", `${analysis.thirdPartyTrust.highRiskProviders} higher-risk third-party integration${analysis.thirdPartyTrust.highRiskProviders === 1 ? "" : "s"} affected trust confidence.`, "third_party"),
|
|
315
|
+
scoreDriver("trust", thirdPartyIssuePenalty, "Third-party trust findings", `${analysis.thirdPartyTrust.issues.length} third-party trust finding${analysis.thirdPartyTrust.issues.length === 1 ? "" : "s"} affected trust confidence.`, "third_party"),
|
|
316
|
+
scoreDriver("ai", absentAiPenalty, "No visible AI surface", "No public AI or automation surface was detected; this is scored as strong-neutral rather than perfect assurance.", "ai"),
|
|
317
|
+
scoreDriver("ai", analysis.aiSurface.issues.length * 12, "AI and automation findings", `${analysis.aiSurface.issues.length} AI or automation finding${analysis.aiSurface.issues.length === 1 ? "" : "s"} reduced confidence.`, "ai"),
|
|
318
|
+
scoreDriver("ai", missingAiDisclosurePenalty, "AI disclosure gap", "AI or automation signals were detected without obvious disclosure language.", "ai"),
|
|
319
|
+
]
|
|
320
|
+
.filter((driver) => Boolean(driver))
|
|
321
|
+
.sort((left, right) => right.impact - left.impact)
|
|
322
|
+
.slice(0, 8);
|
|
323
|
+
}
|
|
324
|
+
export function scorePostureAnalysis(analysis) {
|
|
325
|
+
const areaScores = getPostureAreaScores(analysis);
|
|
326
|
+
const weakAreaCount = areaScores.filter((area) => area.score < 65).length;
|
|
327
|
+
const watchAreaCount = areaScores.filter((area) => area.score >= 65 && area.score < 85).length;
|
|
328
|
+
const breadthPenalty = Math.max(0, weakAreaCount - 1) * 4 + Math.min(watchAreaCount, 2) * 2;
|
|
329
|
+
const weightedScore = Math.round(areaScores.reduce((total, area) => total + area.score * POSTURE_WEIGHTS[area.key], 0));
|
|
330
|
+
const adjustedScore = clamp(weightedScore - breadthPenalty);
|
|
331
|
+
const trustCap = trustWeaknessScoreCap(areaScores);
|
|
332
|
+
const cappedScore = trustCap ? Math.min(adjustedScore, trustCap.cap) : adjustedScore;
|
|
333
|
+
const score = analysis.assessmentLimitation.limited
|
|
334
|
+
? Math.min(cappedScore, limitedAssessmentScoreCap(analysis.assessmentLimitation.kind))
|
|
335
|
+
: cappedScore;
|
|
336
|
+
const drivers = getPostureScoreDrivers(analysis);
|
|
337
|
+
const breadthDriver = scoreDriver("overall", breadthPenalty, "Multiple weak or watch areas", "The final score includes a breadth penalty because findings are spread across several posture areas.", "breadth");
|
|
338
|
+
const limitedDriver = analysis.assessmentLimitation.limited
|
|
339
|
+
? scoreDriver("overall", Math.max(1, adjustedScore - score), "Limited assessment score cap", "The target could not be assessed cleanly, so the score is capped and the grade is marked unscored.", "assessment_limit")
|
|
340
|
+
: null;
|
|
341
|
+
const trustCapDriver = trustCap && adjustedScore > trustCap.cap
|
|
342
|
+
? scoreDriver("overall", adjustedScore - trustCap.cap, "Weak domain trust score cap", "Domain & Trust is weak, so the overall posture cannot be graded as A-level even when browser-facing headers are strong.", "dns")
|
|
343
|
+
: null;
|
|
344
|
+
return {
|
|
345
|
+
score,
|
|
346
|
+
grade: gradeForPostureScore(score, analysis.assessmentLimitation),
|
|
347
|
+
scoreDrivers: [...drivers, breadthDriver, trustCapDriver, limitedDriver]
|
|
348
|
+
.filter((driver) => Boolean(driver))
|
|
349
|
+
.sort((left, right) => right.impact - left.impact)
|
|
350
|
+
.slice(0, 8),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
export function summarizePostureGrade(grade) {
|
|
354
|
+
if (grade === "U") {
|
|
355
|
+
return "Assessment confidence is limited, so this result should be treated as directional rather than a full posture read.";
|
|
356
|
+
}
|
|
357
|
+
if (grade === "A+" || grade === "A") {
|
|
358
|
+
return "External posture looks strong across the main passive checks.";
|
|
359
|
+
}
|
|
360
|
+
if (grade === "B") {
|
|
361
|
+
return "External posture is broadly sound, with a few posture areas still worth tightening.";
|
|
362
|
+
}
|
|
363
|
+
if (grade === "C") {
|
|
364
|
+
return "External posture is mixed, with meaningful gaps across one or more posture areas.";
|
|
365
|
+
}
|
|
366
|
+
return "External posture needs work before this would count as well hardened.";
|
|
367
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { RequestTextFn } from "./network.js";
|
|
2
|
+
import type { SecurityTxtInfo } from "./types.js";
|
|
3
|
+
export declare function parseSecurityTxt(raw: string, url: URL): SecurityTxtInfo;
|
|
4
|
+
export declare function fetchSecurityTxt(finalUrl: URL, requestText: RequestTextFn): Promise<SecurityTxtInfo>;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { headerValue } from "./utils.js";
|
|
2
|
+
export function parseSecurityTxt(raw, url) {
|
|
3
|
+
const fields = {
|
|
4
|
+
contact: [],
|
|
5
|
+
policy: null,
|
|
6
|
+
acknowledgments: null,
|
|
7
|
+
encryption: [],
|
|
8
|
+
hiring: [],
|
|
9
|
+
preferredLanguages: null,
|
|
10
|
+
canonical: [],
|
|
11
|
+
expires: null,
|
|
12
|
+
};
|
|
13
|
+
const issues = [];
|
|
14
|
+
const strengths = [];
|
|
15
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
16
|
+
const trimmed = line.trim();
|
|
17
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const match = trimmed.match(/^([^:]+):\s*(.+)$/);
|
|
21
|
+
if (!match) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const [, key, value] = match;
|
|
25
|
+
const normalizedKey = key.toLowerCase();
|
|
26
|
+
if (normalizedKey === "contact")
|
|
27
|
+
fields.contact.push(value);
|
|
28
|
+
if (normalizedKey === "expires")
|
|
29
|
+
fields.expires = value;
|
|
30
|
+
if (normalizedKey === "policy")
|
|
31
|
+
fields.policy ??= value;
|
|
32
|
+
if (normalizedKey === "acknowledgments")
|
|
33
|
+
fields.acknowledgments ??= value;
|
|
34
|
+
if (normalizedKey === "encryption")
|
|
35
|
+
fields.encryption.push(value);
|
|
36
|
+
if (normalizedKey === "hiring")
|
|
37
|
+
fields.hiring.push(value);
|
|
38
|
+
if (normalizedKey === "preferred-languages")
|
|
39
|
+
fields.preferredLanguages ??= value;
|
|
40
|
+
if (normalizedKey === "canonical")
|
|
41
|
+
fields.canonical.push(value);
|
|
42
|
+
}
|
|
43
|
+
if (!fields.contact.length) {
|
|
44
|
+
issues.push("security.txt is present but missing the required Contact field.");
|
|
45
|
+
}
|
|
46
|
+
if (!fields.expires) {
|
|
47
|
+
issues.push("security.txt is present but missing the required Expires field.");
|
|
48
|
+
}
|
|
49
|
+
if (fields.canonical.length && !fields.canonical.includes(url.toString())) {
|
|
50
|
+
issues.push("Canonical field does not include the discovered security.txt URL.");
|
|
51
|
+
}
|
|
52
|
+
const expiresDate = fields.expires ? new Date(fields.expires) : null;
|
|
53
|
+
const expiresValid = Boolean(expiresDate && !Number.isNaN(expiresDate.getTime()));
|
|
54
|
+
const isExpired = expiresValid ? expiresDate.getTime() < Date.now() : false;
|
|
55
|
+
if (fields.expires && !expiresValid) {
|
|
56
|
+
issues.push("security.txt has an Expires field, but it is not a valid date.");
|
|
57
|
+
}
|
|
58
|
+
if (isExpired) {
|
|
59
|
+
issues.push("security.txt is expired and should be refreshed.");
|
|
60
|
+
}
|
|
61
|
+
if (fields.contact.length && fields.expires && expiresValid && !isExpired) {
|
|
62
|
+
strengths.push("security.txt is published with required contact and expiry fields.");
|
|
63
|
+
}
|
|
64
|
+
const status = isExpired
|
|
65
|
+
? "present_expired"
|
|
66
|
+
: issues.length
|
|
67
|
+
? "present_incomplete"
|
|
68
|
+
: "present_valid";
|
|
69
|
+
return {
|
|
70
|
+
status,
|
|
71
|
+
url: url.toString(),
|
|
72
|
+
contact: fields.contact,
|
|
73
|
+
expires: fields.expires,
|
|
74
|
+
isExpired,
|
|
75
|
+
policy: fields.policy,
|
|
76
|
+
acknowledgments: fields.acknowledgments,
|
|
77
|
+
encryption: fields.encryption,
|
|
78
|
+
hiring: fields.hiring,
|
|
79
|
+
preferredLanguages: fields.preferredLanguages,
|
|
80
|
+
canonical: fields.canonical,
|
|
81
|
+
raw: raw.trim() || null,
|
|
82
|
+
issues,
|
|
83
|
+
strengths,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export async function fetchSecurityTxt(finalUrl, requestText) {
|
|
87
|
+
const candidate = new URL("/.well-known/security.txt", finalUrl.origin);
|
|
88
|
+
try {
|
|
89
|
+
const response = await requestText(candidate);
|
|
90
|
+
if (response.statusCode >= 200 && response.statusCode < 300 && response.body.trim()) {
|
|
91
|
+
return parseSecurityTxt(response.body, candidate);
|
|
92
|
+
}
|
|
93
|
+
const location = headerValue(response.headers, "location");
|
|
94
|
+
if ([301, 302, 303, 307, 308].includes(response.statusCode) && location) {
|
|
95
|
+
const redirected = new URL(location, candidate);
|
|
96
|
+
if (redirected.protocol === "https:") {
|
|
97
|
+
const redirectedResponse = await requestText(redirected);
|
|
98
|
+
if (redirectedResponse.statusCode >= 200 && redirectedResponse.statusCode < 300 && redirectedResponse.body.trim()) {
|
|
99
|
+
return parseSecurityTxt(redirectedResponse.body, redirected);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// Missing/unreachable security.txt is represented as a normal passive finding below.
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
status: "missing",
|
|
109
|
+
url: null,
|
|
110
|
+
contact: [],
|
|
111
|
+
expires: null,
|
|
112
|
+
isExpired: false,
|
|
113
|
+
policy: null,
|
|
114
|
+
acknowledgments: null,
|
|
115
|
+
encryption: [],
|
|
116
|
+
hiring: [],
|
|
117
|
+
preferredLanguages: null,
|
|
118
|
+
canonical: [],
|
|
119
|
+
raw: null,
|
|
120
|
+
issues: ["No security.txt found. Publishing one signals responsible disclosure readiness."],
|
|
121
|
+
strengths: [],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type http from "node:http";
|
|
2
|
+
import type { ApiSurfaceInfo, CorsSecurityInfo, ExposureSummary, PublicSignalsInfo } from "./types.js";
|
|
3
|
+
type ResponseHeaders = http.IncomingHttpHeaders;
|
|
4
|
+
interface RequestHeadResult {
|
|
5
|
+
statusCode: number;
|
|
6
|
+
headers: ResponseHeaders;
|
|
7
|
+
elapsedMs: number;
|
|
8
|
+
}
|
|
9
|
+
interface RequestTextResult {
|
|
10
|
+
statusCode: number;
|
|
11
|
+
headers: ResponseHeaders;
|
|
12
|
+
body: string;
|
|
13
|
+
}
|
|
14
|
+
interface RedirectResult {
|
|
15
|
+
response: RequestHeadResult | RequestTextResult;
|
|
16
|
+
finalUrl: URL;
|
|
17
|
+
}
|
|
18
|
+
interface HomepageContext {
|
|
19
|
+
signature?: string | null;
|
|
20
|
+
pageTitle?: string | null;
|
|
21
|
+
}
|
|
22
|
+
interface SurfaceDeps {
|
|
23
|
+
exposureProbes: Array<{
|
|
24
|
+
label: string;
|
|
25
|
+
path: string;
|
|
26
|
+
}>;
|
|
27
|
+
apiSurfaceProbes: Array<{
|
|
28
|
+
label: string;
|
|
29
|
+
path: string;
|
|
30
|
+
}>;
|
|
31
|
+
requestOnce: (targetUrl: URL, method?: string) => Promise<RequestHeadResult>;
|
|
32
|
+
requestText: (targetUrl: URL, extraHeaders?: Record<string, string>) => Promise<RequestTextResult>;
|
|
33
|
+
requestWithHeaders: (targetUrl: URL, method: string, extraHeaders?: Record<string, string>) => Promise<RequestHeadResult>;
|
|
34
|
+
fetchWithRedirects: (initialUrl: URL, redirectLimit?: number) => Promise<RedirectResult>;
|
|
35
|
+
headerValue: (headers: ResponseHeaders, name: string) => string | null;
|
|
36
|
+
formatErrorMessage: (error: unknown) => string;
|
|
37
|
+
isAccessDeniedHtml: (headers: ResponseHeaders, body: string) => boolean;
|
|
38
|
+
classifyHtmlApiFallback: (probePath: string, finalUrl: URL, resolvedUrl: URL, body: string, homepageSignature: string, homepageTitle: string | null) => boolean;
|
|
39
|
+
}
|
|
40
|
+
export declare const fetchPublicSignals: (host: string, deps: Pick<SurfaceDeps, "requestText">) => Promise<PublicSignalsInfo>;
|
|
41
|
+
export declare const analyzeExposure: (finalUrl: URL, homepageContext: HomepageContext | null | undefined, deps: Pick<SurfaceDeps, "exposureProbes" | "requestOnce" | "requestText" | "fetchWithRedirects" | "headerValue" | "formatErrorMessage" | "isAccessDeniedHtml" | "classifyHtmlApiFallback">) => Promise<ExposureSummary>;
|
|
42
|
+
export declare const analyzeCorsSecurity: (finalUrl: URL, responseHeaders: ResponseHeaders, deps: Pick<SurfaceDeps, "requestWithHeaders" | "headerValue">) => Promise<CorsSecurityInfo>;
|
|
43
|
+
export declare const analyzeApiSurface: (finalUrl: URL, homepageContext: HomepageContext | null | undefined, deps: Pick<SurfaceDeps, "apiSurfaceProbes" | "requestText" | "fetchWithRedirects" | "headerValue" | "isAccessDeniedHtml" | "classifyHtmlApiFallback">) => Promise<ApiSurfaceInfo>;
|
|
44
|
+
export {};
|