bastion-scan 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,3424 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { createRequire } from "module";
5
+
6
+ // src/cli.ts
7
+ import { Command, Option } from "commander";
8
+ import chalk2 from "chalk";
9
+ import ora from "ora";
10
+ import { OUTPUT_FORMATS } from "@bastion/shared";
11
+
12
+ // src/scanner.ts
13
+ import { readdir, readFile as readFile9, stat } from "fs/promises";
14
+ import { resolve, join as join10, relative } from "path";
15
+
16
+ // src/checks/gitignore.ts
17
+ import { readFile } from "fs/promises";
18
+ import { join } from "path";
19
+ var REQUIRED_ENTRIES = [
20
+ {
21
+ id: "gitignore-env",
22
+ pattern: ".env",
23
+ severity: "critical",
24
+ description: "Environment file (.env) is not gitignored \u2014 secrets may be committed",
25
+ fix: "Add `.env` to your .gitignore file",
26
+ aiPrompt: "My project .gitignore is missing `.env`. Explain the security risk of committing environment files and suggest a comprehensive .gitignore for my project."
27
+ },
28
+ {
29
+ id: "gitignore-env-local",
30
+ pattern: ".env.local",
31
+ severity: "critical",
32
+ description: "Local environment file (.env.local) is not gitignored \u2014 secrets may be committed",
33
+ fix: "Add `.env.local` to your .gitignore (or `.env*` to cover all env variants)",
34
+ aiPrompt: "My project .gitignore is missing `.env.local`. Explain why local environment files should never be committed and how to properly manage environment variables."
35
+ },
36
+ {
37
+ id: "gitignore-node-modules",
38
+ pattern: "node_modules",
39
+ severity: "high",
40
+ description: "node_modules is not gitignored \u2014 bloats repository and may leak internal paths",
41
+ fix: "Add `node_modules` to your .gitignore file",
42
+ aiPrompt: "My project .gitignore is missing `node_modules`. Explain why dependencies should not be committed and the security implications."
43
+ },
44
+ {
45
+ id: "gitignore-pem",
46
+ pattern: "*.pem",
47
+ severity: "high",
48
+ description: "PEM certificate files (*.pem) are not gitignored \u2014 private keys may be committed",
49
+ fix: "Add `*.pem` to your .gitignore file",
50
+ aiPrompt: "My project .gitignore is missing `*.pem`. Explain the risk of committing SSL/TLS certificates and private keys."
51
+ },
52
+ {
53
+ id: "gitignore-key",
54
+ pattern: "*.key",
55
+ severity: "high",
56
+ description: "Key files (*.key) are not gitignored \u2014 private keys may be committed",
57
+ fix: "Add `*.key` to your .gitignore file",
58
+ aiPrompt: "My project .gitignore is missing `*.key`. Explain the risk of committing cryptographic key files to version control."
59
+ },
60
+ {
61
+ id: "gitignore-next",
62
+ pattern: ".next",
63
+ severity: "medium",
64
+ description: "Next.js build output (.next) is not gitignored \u2014 build artifacts bloat the repository",
65
+ fix: "Add `.next` to your .gitignore file",
66
+ aiPrompt: "My project .gitignore is missing `.next`. Explain why build artifacts should not be committed."
67
+ },
68
+ {
69
+ id: "gitignore-dist",
70
+ pattern: "dist",
71
+ severity: "medium",
72
+ description: "Build output (dist) is not gitignored \u2014 compiled files bloat the repository",
73
+ fix: "Add `dist` to your .gitignore file",
74
+ aiPrompt: "My project .gitignore is missing `dist`. Explain why build output should be gitignored."
75
+ },
76
+ {
77
+ id: "gitignore-build",
78
+ pattern: "build",
79
+ severity: "medium",
80
+ description: "Build output (build) is not gitignored \u2014 compiled files bloat the repository",
81
+ fix: "Add `build` to your .gitignore file",
82
+ aiPrompt: "My project .gitignore is missing `build`. Explain why build output directories should be gitignored."
83
+ },
84
+ {
85
+ id: "gitignore-ds-store",
86
+ pattern: ".DS_Store",
87
+ severity: "medium",
88
+ description: "macOS metadata files (.DS_Store) are not gitignored \u2014 OS-specific files should not be committed",
89
+ fix: "Add `.DS_Store` to your .gitignore file",
90
+ aiPrompt: "My project .gitignore is missing `.DS_Store`. Explain why OS-specific metadata files should be gitignored."
91
+ }
92
+ ];
93
+ function parseGitignore(content) {
94
+ return content.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
95
+ }
96
+ function normalizeLine(line) {
97
+ let result = line;
98
+ if (result.startsWith("/")) result = result.slice(1);
99
+ if (result.endsWith("/")) result = result.slice(0, -1);
100
+ return result;
101
+ }
102
+ function escapeRegex(s) {
103
+ return s.replace(/[.+^${}()|[\]\\?]/g, "\\$&");
104
+ }
105
+ function globMatches(pattern, text) {
106
+ const regexStr = pattern.split("*").map(escapeRegex).join("[^/]*");
107
+ return new RegExp(`^${regexStr}$`).test(text);
108
+ }
109
+ function isEntryCovered(entry, lines) {
110
+ for (const raw of lines) {
111
+ const line = normalizeLine(raw);
112
+ if (line.startsWith("!")) continue;
113
+ if (line === entry) return true;
114
+ if (entry.includes("*")) continue;
115
+ if (line.includes("*") && globMatches(line, entry)) return true;
116
+ }
117
+ return false;
118
+ }
119
+ var gitignoreCheck = async (context) => {
120
+ let content;
121
+ try {
122
+ content = await readFile(join(context.projectPath, ".gitignore"), "utf-8");
123
+ } catch (error) {
124
+ const isNotFound = error instanceof Error && "code" in error && error.code === "ENOENT";
125
+ if (isNotFound) {
126
+ return [
127
+ {
128
+ id: "gitignore-missing",
129
+ name: ".gitignore coverage",
130
+ status: "fail",
131
+ severity: "critical",
132
+ category: "configuration",
133
+ description: "No .gitignore file found \u2014 sensitive files, dependencies, and build artifacts may be committed",
134
+ fix: "Create a .gitignore file with entries for .env, node_modules, build outputs, and key files",
135
+ aiPrompt: `My ${context.stack.language} project has no .gitignore file. Generate a comprehensive .gitignore that covers environment files, dependencies, build outputs, IDE files, OS files, and cryptographic keys.`
136
+ }
137
+ ];
138
+ }
139
+ return [
140
+ {
141
+ id: "gitignore-error",
142
+ name: ".gitignore coverage",
143
+ status: "skip",
144
+ severity: "info",
145
+ category: "configuration",
146
+ description: `Could not read .gitignore: ${error instanceof Error ? error.message : String(error)}`
147
+ }
148
+ ];
149
+ }
150
+ const lines = parseGitignore(content);
151
+ const results = [];
152
+ for (const entry of REQUIRED_ENTRIES) {
153
+ if (!isEntryCovered(entry.pattern, lines)) {
154
+ results.push({
155
+ id: entry.id,
156
+ name: ".gitignore coverage",
157
+ status: "fail",
158
+ severity: entry.severity,
159
+ category: "configuration",
160
+ location: ".gitignore",
161
+ description: entry.description,
162
+ fix: entry.fix,
163
+ aiPrompt: entry.aiPrompt
164
+ });
165
+ }
166
+ }
167
+ if (results.length === 0) {
168
+ return [
169
+ {
170
+ id: "gitignore-coverage",
171
+ name: ".gitignore coverage",
172
+ status: "pass",
173
+ severity: "info",
174
+ category: "configuration",
175
+ location: ".gitignore",
176
+ description: "All essential entries are present in .gitignore"
177
+ }
178
+ ];
179
+ }
180
+ return results;
181
+ };
182
+ var gitignore_default = gitignoreCheck;
183
+
184
+ // src/checks/secrets.ts
185
+ import { readFile as readFile2 } from "fs/promises";
186
+ import { join as join2, basename } from "path";
187
+ var SCANNABLE_EXTENSIONS = /* @__PURE__ */ new Set([
188
+ ".ts",
189
+ ".js",
190
+ ".tsx",
191
+ ".jsx",
192
+ ".env",
193
+ ".json",
194
+ ".yaml",
195
+ ".yml"
196
+ ]);
197
+ var IGNORED_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "tests", "__tests__", "test", "fixtures"]);
198
+ var SECRET_PATTERNS = [
199
+ {
200
+ name: "OpenAI API key",
201
+ regex: /sk-[A-Za-z0-9]{20,}/,
202
+ description: "OpenAI API key detected"
203
+ },
204
+ {
205
+ name: "Stripe secret key",
206
+ regex: /sk_live_[A-Za-z0-9]{20,}/,
207
+ description: "Stripe secret key detected"
208
+ },
209
+ {
210
+ name: "Stripe publishable key",
211
+ regex: /pk_live_[A-Za-z0-9]{20,}/,
212
+ description: "Stripe publishable live key detected"
213
+ },
214
+ {
215
+ name: "AWS access key",
216
+ regex: /AKIA[0-9A-Z]{16}/,
217
+ description: "AWS access key ID detected"
218
+ },
219
+ {
220
+ name: "Generic API key assignment",
221
+ regex: /(?:api[_-]?key|apikey|api[_-]?secret)\s*[:=]\s*['"][A-Za-z0-9_\-/.]{8,}['"]/i,
222
+ description: "Hardcoded API key assignment detected"
223
+ },
224
+ {
225
+ name: "Bearer token",
226
+ regex: /['"]Bearer\s+[A-Za-z0-9_\-/.+]{20,}['"]/,
227
+ description: "Hardcoded Bearer token detected"
228
+ },
229
+ {
230
+ name: "Database connection string",
231
+ regex: /(?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|redis):\/\/[^:]+:[^@\s]+@/i,
232
+ description: "Database connection string with embedded password detected"
233
+ }
234
+ ];
235
+ var AI_PROMPT = "I found a hardcoded secret in my source code. Help me move it to an environment variable. Show me: (1) how to add it to .env, (2) how to read it with process.env or the equivalent for my framework, (3) how to add the key name to .env.example with a placeholder value, and (4) how to validate at startup that the variable is set.";
236
+ function isScannableFile(relativePath) {
237
+ const segments = relativePath.split("/");
238
+ const fileName = segments[segments.length - 1] ?? "";
239
+ const ext = fileName.includes(".") ? "." + (fileName.split(".").pop() ?? "") : "";
240
+ if (!SCANNABLE_EXTENSIONS.has(ext)) return false;
241
+ if (segments.some((s) => IGNORED_DIRS.has(s))) return false;
242
+ if (basename(relativePath) === ".env.example") return false;
243
+ if (/\.(?:test|spec)\.[jt]sx?$/.test(fileName)) return false;
244
+ return true;
245
+ }
246
+ function scanContent(content, relativePath) {
247
+ const lines = content.split("\n");
248
+ const results = [];
249
+ for (let i = 0; i < lines.length; i++) {
250
+ const line = lines[i];
251
+ if (line === void 0) continue;
252
+ const trimmed = line.trim();
253
+ if (trimmed.startsWith("//") || trimmed.startsWith("#") || trimmed.startsWith("*")) {
254
+ continue;
255
+ }
256
+ if (trimmed.startsWith("'") || trimmed.startsWith('"') || trimmed.startsWith("`") || trimmed.startsWith("/")) {
257
+ continue;
258
+ }
259
+ if (/^\w+\s*:\s*['"`/([]/.test(trimmed)) {
260
+ continue;
261
+ }
262
+ for (const pattern of SECRET_PATTERNS) {
263
+ if (pattern.regex.test(line)) {
264
+ results.push({
265
+ id: "secrets",
266
+ name: `Hardcoded secret: ${pattern.name}`,
267
+ status: "fail",
268
+ severity: "critical",
269
+ category: "Secrets",
270
+ location: `${relativePath}:${i + 1}`,
271
+ description: pattern.description,
272
+ fix: `Remove the hardcoded value and load it from an environment variable instead. Add the key to .env (gitignored) and a placeholder to .env.example.`,
273
+ aiPrompt: AI_PROMPT
274
+ });
275
+ }
276
+ }
277
+ }
278
+ return results;
279
+ }
280
+ var secretsCheck = async (context) => {
281
+ const filesToScan = context.files.filter(isScannableFile);
282
+ if (filesToScan.length === 0) {
283
+ return [
284
+ {
285
+ id: "secrets",
286
+ name: "Hardcoded secrets",
287
+ status: "skip",
288
+ severity: "info",
289
+ description: "No scannable files found"
290
+ }
291
+ ];
292
+ }
293
+ const allResults = [];
294
+ const settled = await Promise.allSettled(
295
+ filesToScan.map(async (file) => {
296
+ const content = await readFile2(join2(context.projectPath, file), "utf-8");
297
+ return scanContent(content, file);
298
+ })
299
+ );
300
+ for (const outcome of settled) {
301
+ if (outcome.status === "fulfilled") {
302
+ allResults.push(...outcome.value);
303
+ }
304
+ }
305
+ if (allResults.length === 0) {
306
+ return [
307
+ {
308
+ id: "secrets",
309
+ name: "Hardcoded secrets",
310
+ status: "pass",
311
+ severity: "info",
312
+ description: "No hardcoded secrets detected"
313
+ }
314
+ ];
315
+ }
316
+ return allResults;
317
+ };
318
+ var secrets_default = secretsCheck;
319
+
320
+ // src/checks/dependencies.ts
321
+ import { execSync } from "child_process";
322
+ import { existsSync } from "fs";
323
+ import { join as join3 } from "path";
324
+ var CHECK_ID = "dep-vuln";
325
+ var CHECK_NAME = "Dependency vulnerabilities";
326
+ var CATEGORY = "dependencies";
327
+ var SEVERITY_MAP = {
328
+ critical: "critical",
329
+ high: "high",
330
+ moderate: "medium",
331
+ low: "low"
332
+ };
333
+ function mapSeverity(npmSeverity) {
334
+ return SEVERITY_MAP[npmSeverity] ?? "info";
335
+ }
336
+ function findAdvisory(via) {
337
+ return via.find((v) => typeof v !== "string");
338
+ }
339
+ function runNpmAudit(cwd) {
340
+ try {
341
+ return execSync("npm audit --json", { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
342
+ } catch (error) {
343
+ if (error !== null && typeof error === "object" && "stdout" in error) {
344
+ const stdout = String(error.stdout);
345
+ if (stdout.trim()) return stdout;
346
+ }
347
+ throw error;
348
+ }
349
+ }
350
+ function toCheckResult(vuln) {
351
+ const advisory = findAdvisory(vuln.via);
352
+ const severity = mapSeverity(vuln.severity);
353
+ const title = advisory?.title ?? "Vulnerability";
354
+ const url = advisory?.url;
355
+ const description = url ? `${vuln.name}: ${title} (${url})` : `${vuln.name}: ${title}`;
356
+ return {
357
+ id: `${CHECK_ID}-${vuln.name}`,
358
+ name: CHECK_NAME,
359
+ status: "fail",
360
+ severity,
361
+ category: CATEGORY,
362
+ description,
363
+ fix: `Run \`npm audit fix\` or update the package: \`npm install ${vuln.name}@latest\``,
364
+ aiPrompt: `I have a ${vuln.severity}-severity vulnerability in the npm package "${vuln.name}".${url ? ` Advisory: ${url}.` : ""} Help me fix this by running \`npm update ${vuln.name}\` or \`npm install ${vuln.name}@latest\`. If this requires a major version upgrade, help me understand what breaking changes to expect and how to migrate my code.`
365
+ };
366
+ }
367
+ var dependencyCheck = async (context) => {
368
+ if (!context.packageJson) {
369
+ return [{
370
+ id: CHECK_ID,
371
+ name: CHECK_NAME,
372
+ status: "skip",
373
+ severity: "info",
374
+ category: CATEGORY,
375
+ description: "No package.json found \u2014 skipping dependency audit"
376
+ }];
377
+ }
378
+ if (!existsSync(join3(context.projectPath, "node_modules"))) {
379
+ return [{
380
+ id: CHECK_ID,
381
+ name: CHECK_NAME,
382
+ status: "warn",
383
+ severity: "medium",
384
+ category: CATEGORY,
385
+ description: "node_modules not found \u2014 run npm install before scanning",
386
+ fix: "Run `npm install` to install dependencies before scanning."
387
+ }];
388
+ }
389
+ let output;
390
+ try {
391
+ output = runNpmAudit(context.projectPath);
392
+ } catch {
393
+ return [{
394
+ id: CHECK_ID,
395
+ name: CHECK_NAME,
396
+ status: "skip",
397
+ severity: "info",
398
+ category: CATEGORY,
399
+ description: "npm audit could not be run \u2014 ensure npm is installed and available"
400
+ }];
401
+ }
402
+ let report;
403
+ try {
404
+ report = JSON.parse(output);
405
+ } catch {
406
+ return [{
407
+ id: CHECK_ID,
408
+ name: CHECK_NAME,
409
+ status: "skip",
410
+ severity: "info",
411
+ category: CATEGORY,
412
+ description: "Failed to parse npm audit output"
413
+ }];
414
+ }
415
+ const vulns = report.vulnerabilities;
416
+ if (!vulns || Object.keys(vulns).length === 0) {
417
+ return [{
418
+ id: CHECK_ID,
419
+ name: CHECK_NAME,
420
+ status: "pass",
421
+ severity: "info",
422
+ category: CATEGORY,
423
+ description: "No known vulnerabilities found in dependencies"
424
+ }];
425
+ }
426
+ return Object.values(vulns).map(toCheckResult);
427
+ };
428
+ var dependencies_default = dependencyCheck;
429
+
430
+ // src/checks/env-example.ts
431
+ import { readFile as readFile3 } from "fs/promises";
432
+ import { join as join4 } from "path";
433
+ var CHECK_ID2 = "env-example";
434
+ var CHECK_NAME2 = ".env.example exists";
435
+ var STRONG_PREFIXES = [
436
+ "AKIA",
437
+ "ghp_",
438
+ "gho_",
439
+ "github_pat_",
440
+ "xoxb-",
441
+ "xoxp-",
442
+ "glpat-"
443
+ ];
444
+ var WEAK_PREFIXES = [
445
+ "sk-",
446
+ "sk_live_",
447
+ "sk_test_",
448
+ "pk_live_",
449
+ "pk_test_"
450
+ ];
451
+ var PLACEHOLDER_WORDS = [
452
+ "your_",
453
+ "your-",
454
+ "changeme",
455
+ "change_me",
456
+ "change-me",
457
+ "replace",
458
+ "xxx",
459
+ "placeholder",
460
+ "example",
461
+ "sample",
462
+ "todo",
463
+ "fixme",
464
+ "insert",
465
+ "<",
466
+ ">"
467
+ ];
468
+ function matchesEnvPattern(line) {
469
+ const trimmed = line.trim();
470
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("!")) {
471
+ return false;
472
+ }
473
+ const clean = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed;
474
+ return clean === ".env" || clean === ".env*";
475
+ }
476
+ function isEnvGitignored(content) {
477
+ return content.split(/\r?\n/).some(matchesEnvPattern);
478
+ }
479
+ function looksLikeRealSecret(value) {
480
+ const trimmed = value.trim().replace(/^["']|["']$/g, "");
481
+ if (!trimmed) return false;
482
+ if (STRONG_PREFIXES.some((prefix) => trimmed.startsWith(prefix))) return true;
483
+ const lower = trimmed.toLowerCase();
484
+ if (PLACEHOLDER_WORDS.some((w) => lower.includes(w))) return false;
485
+ if (WEAK_PREFIXES.some((prefix) => trimmed.startsWith(prefix))) return true;
486
+ if (/^[a-zA-Z0-9+/=_-]{40,}$/.test(trimmed)) return true;
487
+ return false;
488
+ }
489
+ function findRealSecrets(content) {
490
+ const secrets = [];
491
+ for (const line of content.split(/\r?\n/)) {
492
+ const trimmed = line.trim();
493
+ if (!trimmed || trimmed.startsWith("#")) continue;
494
+ const eqIndex = trimmed.indexOf("=");
495
+ if (eqIndex === -1) continue;
496
+ const key = trimmed.slice(0, eqIndex);
497
+ const value = trimmed.slice(eqIndex + 1);
498
+ if (looksLikeRealSecret(value)) {
499
+ secrets.push(key);
500
+ }
501
+ }
502
+ return secrets;
503
+ }
504
+ async function readProjectFile(projectPath, filename) {
505
+ try {
506
+ return await readFile3(join4(projectPath, filename), "utf-8");
507
+ } catch {
508
+ return void 0;
509
+ }
510
+ }
511
+ var envExampleCheck = async (context) => {
512
+ try {
513
+ const gitignoreContent = await readProjectFile(context.projectPath, ".gitignore");
514
+ if (gitignoreContent === void 0) {
515
+ return [
516
+ {
517
+ id: CHECK_ID2,
518
+ name: CHECK_NAME2,
519
+ status: "skip",
520
+ severity: "info",
521
+ description: "No .gitignore found \u2014 cannot determine if .env is excluded"
522
+ }
523
+ ];
524
+ }
525
+ if (!isEnvGitignored(gitignoreContent)) {
526
+ return [
527
+ {
528
+ id: CHECK_ID2,
529
+ name: CHECK_NAME2,
530
+ status: "skip",
531
+ severity: "info",
532
+ description: ".env is not in .gitignore \u2014 see gitignore check for coverage"
533
+ }
534
+ ];
535
+ }
536
+ const hasExample = context.files.includes(".env.example");
537
+ const hasSample = context.files.includes(".env.sample");
538
+ if (!hasExample && !hasSample) {
539
+ return [
540
+ {
541
+ id: CHECK_ID2,
542
+ name: CHECK_NAME2,
543
+ status: "fail",
544
+ severity: "high",
545
+ category: "configuration",
546
+ description: ".env is gitignored but no .env.example or .env.sample exists. New developers won\u2019t know which environment variables are required, leading to broken setups and wasted onboarding time.",
547
+ fix: "Create a .env.example file listing every required environment variable with placeholder values (e.g. DATABASE_URL=your_database_url_here). Commit it to the repository so new team members can copy it to .env and fill in their own values.",
548
+ aiPrompt: "I have a project with a .env file that is gitignored. Generate a .env.example file based on my .env file. Replace all real values with descriptive placeholders (e.g. YOUR_API_KEY_HERE, your_database_url_here). Add comments explaining what each variable is for and where to get the value. Keep the structure identical to the original .env."
549
+ }
550
+ ];
551
+ }
552
+ const templateFile = hasExample ? ".env.example" : ".env.sample";
553
+ const templateContent = await readProjectFile(context.projectPath, templateFile);
554
+ if (templateContent === void 0) {
555
+ return [
556
+ {
557
+ id: CHECK_ID2,
558
+ name: CHECK_NAME2,
559
+ status: "pass",
560
+ severity: "info",
561
+ category: "configuration",
562
+ description: `${templateFile} exists (content could not be verified)`
563
+ }
564
+ ];
565
+ }
566
+ const realSecrets = findRealSecrets(templateContent);
567
+ if (realSecrets.length > 0) {
568
+ return [
569
+ {
570
+ id: CHECK_ID2,
571
+ name: CHECK_NAME2,
572
+ status: "fail",
573
+ severity: "high",
574
+ category: "configuration",
575
+ location: templateFile,
576
+ description: `${templateFile} appears to contain real secret values for: ${realSecrets.join(", ")}. Template files should only contain placeholders, not actual credentials.`,
577
+ fix: `Replace real values in ${templateFile} with descriptive placeholders like YOUR_API_KEY_HERE or empty strings. Never commit actual secrets to version control, even in example files.`,
578
+ aiPrompt: `Review my ${templateFile} file and replace any real-looking secret values with safe placeholders. Keep the variable names but replace values with descriptive placeholders like YOUR_API_KEY_HERE. Add a comment above each variable explaining what it\u2019s for.`
579
+ }
580
+ ];
581
+ }
582
+ return [
583
+ {
584
+ id: CHECK_ID2,
585
+ name: CHECK_NAME2,
586
+ status: "pass",
587
+ severity: "info",
588
+ category: "configuration",
589
+ description: `${templateFile} exists with placeholder values`
590
+ }
591
+ ];
592
+ } catch (error) {
593
+ return [
594
+ {
595
+ id: CHECK_ID2,
596
+ name: CHECK_NAME2,
597
+ status: "skip",
598
+ severity: "info",
599
+ description: `Check failed: ${error instanceof Error ? error.message : String(error)}`
600
+ }
601
+ ];
602
+ }
603
+ };
604
+ var env_example_default = envExampleCheck;
605
+
606
+ // src/checks/security-txt.ts
607
+ import { readFile as readFile4 } from "fs/promises";
608
+ import { join as join5 } from "path";
609
+ var SECURITY_TXT_PATHS = [".well-known/security.txt", "security.txt"];
610
+ var SECURITY_MD = "SECURITY.md";
611
+ var CHECK_ID3 = "security-txt";
612
+ function parseFields(content) {
613
+ const fields = /* @__PURE__ */ new Map();
614
+ for (const line of content.split("\n")) {
615
+ const trimmed = line.trim();
616
+ if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("-----")) continue;
617
+ const colonIndex = trimmed.indexOf(":");
618
+ if (colonIndex === -1) continue;
619
+ const key = trimmed.slice(0, colonIndex).trim().toLowerCase();
620
+ const value = trimmed.slice(colonIndex + 1).trim();
621
+ if (value === "") continue;
622
+ const existing = fields.get(key);
623
+ fields.set(key, existing ? [...existing, value] : [value]);
624
+ }
625
+ return fields;
626
+ }
627
+ function isExpired(value) {
628
+ const date = new Date(value);
629
+ return !isNaN(date.getTime()) && date.getTime() < Date.now();
630
+ }
631
+ function validateSecurityTxt(fields, location) {
632
+ const results = [];
633
+ const hasContact = (fields.get("contact") ?? []).length > 0;
634
+ const firstExpires = (fields.get("expires") ?? [])[0];
635
+ const hasExpires = firstExpires !== void 0;
636
+ if (!hasContact) {
637
+ results.push({
638
+ id: `${CHECK_ID3}-contact`,
639
+ name: "security.txt missing Contact field",
640
+ status: "fail",
641
+ severity: "medium",
642
+ category: "disclosure",
643
+ location,
644
+ description: "security.txt is missing the required Contact field. Security researchers need a way to reach you.",
645
+ fix: "Add a Contact field to your security.txt. Example: Contact: mailto:security@example.com",
646
+ aiPrompt: "My security.txt file is missing the required Contact field. Add a Contact field with my security email address using mailto: URI format per RFC 9116."
647
+ });
648
+ }
649
+ if (!hasExpires) {
650
+ results.push({
651
+ id: `${CHECK_ID3}-expires`,
652
+ name: "security.txt missing Expires field",
653
+ status: "fail",
654
+ severity: "medium",
655
+ category: "disclosure",
656
+ location,
657
+ description: "security.txt is missing the required Expires field. This field ensures the information stays current.",
658
+ fix: "Add an Expires field with a date no more than 1 year in the future. Example: Expires: 2027-12-31T23:59:59.000Z",
659
+ aiPrompt: "My security.txt file is missing the required Expires field. Add an Expires field set to one year from today in ISO 8601 date-time format per RFC 9116."
660
+ });
661
+ } else if (isExpired(firstExpires)) {
662
+ results.push({
663
+ id: `${CHECK_ID3}-expired`,
664
+ name: "security.txt has expired",
665
+ status: "warn",
666
+ severity: "medium",
667
+ category: "disclosure",
668
+ location,
669
+ description: `security.txt Expires date (${firstExpires}) is in the past. An expired security.txt signals the contact information may be stale.`,
670
+ fix: "Update the Expires field to a future date, no more than 1 year ahead. Use the generator at https://securitytxt.org/",
671
+ aiPrompt: "My security.txt Expires field is in the past. Update it to one year from today in ISO 8601 date-time format. Also review the Contact field to ensure it is still accurate."
672
+ });
673
+ }
674
+ if (hasContact && hasExpires && !isExpired(firstExpires)) {
675
+ results.push({
676
+ id: CHECK_ID3,
677
+ name: "security.txt is valid",
678
+ status: "pass",
679
+ severity: "info",
680
+ category: "disclosure",
681
+ location,
682
+ description: "security.txt exists with required Contact and Expires fields."
683
+ });
684
+ }
685
+ return results;
686
+ }
687
+ var securityTxtCheck = async (context) => {
688
+ const securityTxtFile = SECURITY_TXT_PATHS.find((p) => context.files.includes(p));
689
+ const hasSecurityMd = context.files.includes(SECURITY_MD);
690
+ if (!securityTxtFile && !hasSecurityMd) {
691
+ return [
692
+ {
693
+ id: CHECK_ID3,
694
+ name: "Security policy missing",
695
+ status: "fail",
696
+ severity: "medium",
697
+ category: "disclosure",
698
+ description: "No security.txt or SECURITY.md found. A security disclosure policy tells researchers how to report vulnerabilities.",
699
+ fix: "Create .well-known/security.txt with Contact and Expires fields. Use the generator at https://securitytxt.org/",
700
+ aiPrompt: "Generate a security.txt file for my project following RFC 9116. Include Contact (use my email: [YOUR_EMAIL]), Expires (1 year from now), and Preferred-Languages fields. Place it at .well-known/security.txt. Also create a SECURITY.md with a vulnerability disclosure policy."
701
+ }
702
+ ];
703
+ }
704
+ const results = [];
705
+ if (securityTxtFile) {
706
+ try {
707
+ const content = await readFile4(join5(context.projectPath, securityTxtFile), "utf-8");
708
+ results.push(...validateSecurityTxt(parseFields(content), securityTxtFile));
709
+ } catch {
710
+ results.push({
711
+ id: CHECK_ID3,
712
+ name: "security.txt unreadable",
713
+ status: "skip",
714
+ severity: "info",
715
+ category: "disclosure",
716
+ location: securityTxtFile,
717
+ description: `Could not read ${securityTxtFile}. Skipping validation.`
718
+ });
719
+ }
720
+ }
721
+ if (hasSecurityMd) {
722
+ results.push({
723
+ id: `${CHECK_ID3}-md`,
724
+ name: "SECURITY.md exists",
725
+ status: "pass",
726
+ severity: "info",
727
+ category: "disclosure",
728
+ location: SECURITY_MD,
729
+ description: "SECURITY.md found \u2014 vulnerability disclosure policy is documented."
730
+ });
731
+ }
732
+ return results;
733
+ };
734
+ var security_txt_default = securityTxtCheck;
735
+
736
+ // src/utils/fetch-with-retry.ts
737
+ var RETRY_DELAY_MS = 2e3;
738
+ var TIMEOUT_PER_ATTEMPT_MS = 1e4;
739
+ var CONNECTION_ERROR_PATTERNS = [
740
+ "ECONNREFUSED",
741
+ "ETIMEDOUT",
742
+ "ENOTFOUND"
743
+ ];
744
+ function isConnectionError(error) {
745
+ if (error instanceof DOMException && error.name === "AbortError") {
746
+ return true;
747
+ }
748
+ const message = error instanceof Error ? error.message : "";
749
+ const causeMessage = error instanceof Error && error.cause instanceof Error ? error.cause.message : "";
750
+ const combined = `${message} ${causeMessage}`;
751
+ return CONNECTION_ERROR_PATTERNS.some((code) => combined.includes(code));
752
+ }
753
+ function delay(ms) {
754
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
755
+ }
756
+ async function fetchWithRetry(url, init) {
757
+ for (let attempt = 1; attempt <= 2; attempt++) {
758
+ try {
759
+ const response = await fetch(url, {
760
+ ...init,
761
+ signal: AbortSignal.timeout(TIMEOUT_PER_ATTEMPT_MS)
762
+ });
763
+ return { response, attempts: attempt };
764
+ } catch (error) {
765
+ if (attempt === 1 && isConnectionError(error)) {
766
+ await delay(RETRY_DELAY_MS);
767
+ continue;
768
+ }
769
+ return { error, attempts: attempt };
770
+ }
771
+ }
772
+ return { error: new Error("Unexpected retry exhaustion"), attempts: 2 };
773
+ }
774
+
775
+ // src/checks/headers.ts
776
+ var CHECK_ID4 = "headers";
777
+ var CATEGORY2 = "headers";
778
+ var REQUIRED_HEADERS = [
779
+ {
780
+ header: "content-security-policy",
781
+ label: "Content-Security-Policy",
782
+ description: "Content-Security-Policy header is missing. CSP prevents cross-site scripting (XSS) and data injection attacks by controlling which resources the browser is allowed to load.",
783
+ fix: "Add a Content-Security-Policy header. Start with a restrictive policy like `default-src 'self'` and loosen as needed."
784
+ },
785
+ {
786
+ header: "strict-transport-security",
787
+ label: "Strict-Transport-Security",
788
+ description: "Strict-Transport-Security header is missing. HSTS forces browsers to use HTTPS, preventing protocol downgrade attacks and cookie hijacking.",
789
+ fix: "Add `Strict-Transport-Security: max-age=31536000; includeSubDomains` to enforce HTTPS for one year."
790
+ },
791
+ {
792
+ header: "x-content-type-options",
793
+ label: "X-Content-Type-Options",
794
+ description: "X-Content-Type-Options header is missing. This header prevents MIME-type sniffing, which can turn non-executable MIME types into executable ones.",
795
+ fix: "Add `X-Content-Type-Options: nosniff` to prevent browsers from MIME-sniffing the response."
796
+ },
797
+ {
798
+ header: "x-frame-options",
799
+ label: "X-Frame-Options",
800
+ description: "X-Frame-Options header is missing. This header prevents your site from being embedded in iframes, protecting against clickjacking attacks.",
801
+ fix: "Add `X-Frame-Options: DENY` (or SAMEORIGIN if you need to embed your own pages)."
802
+ },
803
+ {
804
+ header: "referrer-policy",
805
+ label: "Referrer-Policy",
806
+ description: "Referrer-Policy header is missing. Without it, the browser may leak the full URL (including query parameters with sensitive data) to third-party sites.",
807
+ fix: "Add `Referrer-Policy: strict-origin-when-cross-origin` to limit referrer information sent to other origins."
808
+ },
809
+ {
810
+ header: "permissions-policy",
811
+ label: "Permissions-Policy",
812
+ description: "Permissions-Policy header is missing. This header controls which browser features (camera, microphone, geolocation) your site can use, reducing attack surface.",
813
+ fix: "Add a Permissions-Policy header disabling unused features. Example: `Permissions-Policy: camera=(), microphone=(), geolocation=()`."
814
+ }
815
+ ];
816
+ function buildAiPrompt(missing, context) {
817
+ const headerList = missing.join(", ");
818
+ const framework = context.stack.framework?.toLowerCase();
819
+ if (framework === "express" || framework === "fastify" || framework === "koa") {
820
+ return `My ${framework} app is missing these security headers: ${headerList}. Install and configure helmet.js to add all recommended security headers. Show the middleware setup.`;
821
+ }
822
+ if (framework === "next" || framework === "nextjs") {
823
+ return `My Next.js app is missing these security headers: ${headerList}. Add them using the headers() function in next.config.js. Show the full configuration.`;
824
+ }
825
+ return `My web app is missing these security headers: ${headerList}. Show how to configure my web server or framework to add each header with recommended values.`;
826
+ }
827
+ var headersCheck = async (context) => {
828
+ if (!context.url) {
829
+ return [
830
+ {
831
+ id: CHECK_ID4,
832
+ name: "Security headers skipped",
833
+ status: "skip",
834
+ severity: "info",
835
+ category: CATEGORY2,
836
+ description: "No URL provided \u2014 skipping HTTP security header check. Pass --url to enable."
837
+ }
838
+ ];
839
+ }
840
+ const { response } = await fetchWithRetry(context.url);
841
+ if (!response) {
842
+ const hostname = safeHostname(context.url);
843
+ return [
844
+ {
845
+ id: CHECK_ID4,
846
+ name: "Security headers skipped",
847
+ status: "skip",
848
+ severity: "info",
849
+ category: CATEGORY2,
850
+ description: `Could not connect to ${hostname} after 2 attempts. Skipping header check.`
851
+ }
852
+ ];
853
+ }
854
+ if (response.status !== 200) {
855
+ return [
856
+ {
857
+ id: CHECK_ID4,
858
+ name: "Security headers skipped",
859
+ status: "skip",
860
+ severity: "info",
861
+ category: CATEGORY2,
862
+ description: `Received HTTP ${response.status} from ${safeHostname(context.url)}. Skipping header check (expected 200).`
863
+ }
864
+ ];
865
+ }
866
+ const missing = [];
867
+ for (const spec of REQUIRED_HEADERS) {
868
+ if (!response.headers.has(spec.header)) {
869
+ missing.push(spec);
870
+ }
871
+ }
872
+ if (missing.length === 0) {
873
+ return [
874
+ {
875
+ id: CHECK_ID4,
876
+ name: "All security headers present",
877
+ status: "pass",
878
+ severity: "info",
879
+ category: CATEGORY2,
880
+ description: `All ${REQUIRED_HEADERS.length} recommended security headers are present.`
881
+ }
882
+ ];
883
+ }
884
+ const aiPrompt = buildAiPrompt(
885
+ missing.map((s) => s.label),
886
+ context
887
+ );
888
+ const results = missing.map((spec) => ({
889
+ id: `${CHECK_ID4}-${spec.header}`,
890
+ name: `Missing ${spec.label}`,
891
+ status: "fail",
892
+ severity: "high",
893
+ category: CATEGORY2,
894
+ location: context.url,
895
+ description: spec.description,
896
+ fix: spec.fix,
897
+ aiPrompt
898
+ }));
899
+ return results;
900
+ };
901
+ function safeHostname(url) {
902
+ try {
903
+ return new URL(url).hostname;
904
+ } catch {
905
+ return url;
906
+ }
907
+ }
908
+ var headers_default = headersCheck;
909
+
910
+ // src/checks/security-txt-url.ts
911
+ var CHECK_ID5 = "security-txt-url";
912
+ var PATHS = ["/.well-known/security.txt", "/security.txt"];
913
+ function parseFields2(content) {
914
+ const fields = /* @__PURE__ */ new Map();
915
+ for (const line of content.split("\n")) {
916
+ const trimmed = line.trim();
917
+ if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("-----")) continue;
918
+ const colonIndex = trimmed.indexOf(":");
919
+ if (colonIndex === -1) continue;
920
+ const key = trimmed.slice(0, colonIndex).trim().toLowerCase();
921
+ const value = trimmed.slice(colonIndex + 1).trim();
922
+ if (value === "") continue;
923
+ const existing = fields.get(key);
924
+ fields.set(key, existing ? [...existing, value] : [value]);
925
+ }
926
+ return fields;
927
+ }
928
+ function isExpired2(value) {
929
+ const date = new Date(value);
930
+ return !isNaN(date.getTime()) && date.getTime() < Date.now();
931
+ }
932
+ async function fetchSecurityTxt(url) {
933
+ const { response } = await fetchWithRetry(url);
934
+ if (!response || !response.ok) return null;
935
+ const contentType = response.headers.get("content-type") ?? "";
936
+ if (!contentType.includes("text/plain")) return null;
937
+ return await response.text();
938
+ }
939
+ function validateFields(fields, location) {
940
+ const results = [];
941
+ const hasContact = (fields.get("contact") ?? []).length > 0;
942
+ const firstExpires = (fields.get("expires") ?? [])[0];
943
+ const hasExpires = firstExpires !== void 0;
944
+ if (!hasContact) {
945
+ results.push({
946
+ id: `${CHECK_ID5}-contact`,
947
+ name: "security.txt missing Contact field",
948
+ status: "fail",
949
+ severity: "medium",
950
+ category: "disclosure",
951
+ location,
952
+ description: "security.txt is missing the required Contact field. Security researchers need a way to reach you.",
953
+ fix: "Add a Contact field to your security.txt. Example: Contact: mailto:security@example.com. See https://securitytxt.org/",
954
+ aiPrompt: "My security.txt file is missing the required Contact field. Add a Contact field with my security email address using mailto: URI format per RFC 9116."
955
+ });
956
+ }
957
+ if (!hasExpires) {
958
+ results.push({
959
+ id: `${CHECK_ID5}-expires`,
960
+ name: "security.txt missing Expires field",
961
+ status: "fail",
962
+ severity: "medium",
963
+ category: "disclosure",
964
+ location,
965
+ description: "security.txt is missing the required Expires field. This field ensures the information stays current.",
966
+ fix: "Add an Expires field with a date no more than 1 year in the future. Example: Expires: 2027-12-31T23:59:59.000Z. See https://securitytxt.org/",
967
+ aiPrompt: "My security.txt file is missing the required Expires field. Add an Expires field set to one year from today in ISO 8601 date-time format per RFC 9116."
968
+ });
969
+ } else if (isExpired2(firstExpires)) {
970
+ results.push({
971
+ id: `${CHECK_ID5}-expired`,
972
+ name: "security.txt has expired",
973
+ status: "warn",
974
+ severity: "medium",
975
+ category: "disclosure",
976
+ location,
977
+ description: `security.txt Expires date (${firstExpires}) is in the past. An expired security.txt signals the contact information may be stale.`,
978
+ fix: "Update the Expires field to a future date, no more than 1 year ahead. Use the generator at https://securitytxt.org/",
979
+ aiPrompt: "My security.txt Expires field is in the past. Update it to one year from today in ISO 8601 date-time format. Also review the Contact field to ensure it is still accurate."
980
+ });
981
+ }
982
+ if (hasContact && hasExpires && !isExpired2(firstExpires)) {
983
+ results.push({
984
+ id: CHECK_ID5,
985
+ name: "security.txt is valid (via URL)",
986
+ status: "pass",
987
+ severity: "info",
988
+ category: "disclosure",
989
+ location,
990
+ description: "security.txt found via URL with required Contact and Expires fields."
991
+ });
992
+ }
993
+ return results;
994
+ }
995
+ var securityTxtUrlCheck = async (context) => {
996
+ if (!context.url) {
997
+ return [
998
+ {
999
+ id: CHECK_ID5,
1000
+ name: "security.txt URL check skipped",
1001
+ status: "skip",
1002
+ severity: "info",
1003
+ category: "disclosure",
1004
+ description: "No URL provided \u2014 skipping remote security.txt check."
1005
+ }
1006
+ ];
1007
+ }
1008
+ const base = context.url.replace(/\/+$/, "");
1009
+ for (const path of PATHS) {
1010
+ const url = `${base}${path}`;
1011
+ const body = await fetchSecurityTxt(url);
1012
+ if (body !== null) {
1013
+ return validateFields(parseFields2(body), url);
1014
+ }
1015
+ }
1016
+ return [
1017
+ {
1018
+ id: CHECK_ID5,
1019
+ name: "security.txt not found via URL",
1020
+ status: "fail",
1021
+ severity: "medium",
1022
+ category: "disclosure",
1023
+ description: `No security.txt found at ${base}${PATHS[0]} or ${base}${PATHS[1]}. A security.txt file tells researchers how to report vulnerabilities.`,
1024
+ fix: "Create a security.txt file and serve it at /.well-known/security.txt. Use the generator at https://securitytxt.org/",
1025
+ aiPrompt: "Generate a security.txt file for my project following RFC 9116. Include Contact (use my email: [YOUR_EMAIL]), Expires (1 year from now), and Preferred-Languages fields. Serve it at /.well-known/security.txt."
1026
+ }
1027
+ ];
1028
+ };
1029
+ var security_txt_url_default = securityTxtUrlCheck;
1030
+
1031
+ // src/checks/ssl.ts
1032
+ import { connect as tlsConnect } from "tls";
1033
+ import { request as httpRequest } from "http";
1034
+ var CHECK_ID6 = "ssl";
1035
+ var CATEGORY3 = "transport";
1036
+ var TIMEOUT_MS = 1e4;
1037
+ var CERT_ERROR_MESSAGES = {
1038
+ CERT_HAS_EXPIRED: "SSL certificate has expired",
1039
+ DEPTH_ZERO_SELF_SIGNED_CERT: "Self-signed certificate (not trusted by browsers)",
1040
+ SELF_SIGNED_CERT_IN_CHAIN: "Self-signed certificate in the certificate chain",
1041
+ UNABLE_TO_VERIFY_LEAF_SIGNATURE: "Unable to verify the certificate",
1042
+ ERR_TLS_CERT_ALTNAME_INVALID: "Certificate hostname does not match the domain"
1043
+ };
1044
+ var CONNECTION_ERRORS = /* @__PURE__ */ new Set([
1045
+ "ECONNREFUSED",
1046
+ "ECONNRESET",
1047
+ "ETIMEDOUT",
1048
+ "ENOTFOUND",
1049
+ "EHOSTUNREACH"
1050
+ ]);
1051
+ function verifyCertificate(hostname, port) {
1052
+ return new Promise((resolve2, reject) => {
1053
+ let settled = false;
1054
+ const socket = tlsConnect(
1055
+ { host: hostname, port, servername: hostname, rejectUnauthorized: true },
1056
+ () => {
1057
+ if (settled) return;
1058
+ settled = true;
1059
+ socket.destroy();
1060
+ resolve2();
1061
+ }
1062
+ );
1063
+ socket.setTimeout(TIMEOUT_MS);
1064
+ socket.on("timeout", () => {
1065
+ if (settled) return;
1066
+ settled = true;
1067
+ socket.destroy();
1068
+ reject(Object.assign(new Error("Connection timed out"), { code: "ETIMEDOUT" }));
1069
+ });
1070
+ socket.on("error", (err) => {
1071
+ if (settled) return;
1072
+ settled = true;
1073
+ socket.destroy();
1074
+ reject(err);
1075
+ });
1076
+ });
1077
+ }
1078
+ function checkHttpsRedirect(hostname) {
1079
+ return new Promise((resolve2) => {
1080
+ let settled = false;
1081
+ const req = httpRequest(
1082
+ { hostname, port: 80, method: "HEAD", path: "/" },
1083
+ (res) => {
1084
+ if (settled) return;
1085
+ settled = true;
1086
+ const status = res.statusCode ?? 0;
1087
+ const location = res.headers.location ?? "";
1088
+ const isRedirect = status >= 300 && status < 400;
1089
+ resolve2(isRedirect && location.startsWith("https://"));
1090
+ }
1091
+ );
1092
+ req.setTimeout(TIMEOUT_MS);
1093
+ req.on("timeout", () => {
1094
+ if (settled) return;
1095
+ settled = true;
1096
+ resolve2(false);
1097
+ req.destroy();
1098
+ });
1099
+ req.on("error", () => {
1100
+ if (settled) return;
1101
+ settled = true;
1102
+ resolve2(false);
1103
+ });
1104
+ req.end();
1105
+ });
1106
+ }
1107
+ var RETRY_DELAY_MS2 = 2e3;
1108
+ function delay2(ms) {
1109
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1110
+ }
1111
+ async function verifyCertificateWithRetry(hostname, port) {
1112
+ try {
1113
+ await verifyCertificate(hostname, port);
1114
+ } catch (error) {
1115
+ const code = error instanceof Error ? error.code ?? "" : "";
1116
+ if (CONNECTION_ERRORS.has(code)) {
1117
+ await delay2(RETRY_DELAY_MS2);
1118
+ await verifyCertificate(hostname, port);
1119
+ } else {
1120
+ throw error;
1121
+ }
1122
+ }
1123
+ }
1124
+ function buildCertErrorResult(error, hostname) {
1125
+ const code = error.code ?? "";
1126
+ if (CONNECTION_ERRORS.has(code)) {
1127
+ return {
1128
+ id: `${CHECK_ID6}-cert`,
1129
+ name: "SSL certificate could not be verified",
1130
+ status: "skip",
1131
+ severity: "info",
1132
+ category: CATEGORY3,
1133
+ location: hostname,
1134
+ description: `Could not connect to ${hostname} after 2 attempts. Skipping SSL certificate check.`
1135
+ };
1136
+ }
1137
+ const message = CERT_ERROR_MESSAGES[code] ?? `Certificate verification failed: ${error.message}`;
1138
+ return {
1139
+ id: `${CHECK_ID6}-cert`,
1140
+ name: "Invalid SSL certificate",
1141
+ status: "fail",
1142
+ severity: "critical",
1143
+ category: CATEGORY3,
1144
+ location: hostname,
1145
+ description: `${message} for ${hostname}. Visitors will see a browser warning and their data may be intercepted.`,
1146
+ fix: "Obtain a valid TLS certificate. Let's Encrypt provides free certificates via Certbot.",
1147
+ aiPrompt: `My website ${hostname} has an SSL/TLS certificate error: "${message}". Help me fix this by setting up a valid certificate using Let's Encrypt and Certbot. Show me how to install Certbot, obtain a certificate for my domain, and configure automatic renewal.`
1148
+ };
1149
+ }
1150
+ var sslCheck = async (context) => {
1151
+ if (!context.url) {
1152
+ return [
1153
+ {
1154
+ id: CHECK_ID6,
1155
+ name: "SSL/TLS check",
1156
+ status: "skip",
1157
+ severity: "info",
1158
+ category: CATEGORY3,
1159
+ description: "No URL provided \u2014 skipping SSL/TLS verification."
1160
+ }
1161
+ ];
1162
+ }
1163
+ let parsed;
1164
+ try {
1165
+ parsed = new URL(context.url);
1166
+ } catch {
1167
+ return [
1168
+ {
1169
+ id: CHECK_ID6,
1170
+ name: "SSL/TLS check",
1171
+ status: "skip",
1172
+ severity: "info",
1173
+ category: CATEGORY3,
1174
+ description: `Invalid URL "${context.url}" \u2014 skipping SSL/TLS verification.`
1175
+ }
1176
+ ];
1177
+ }
1178
+ const { hostname } = parsed;
1179
+ const isHttps = parsed.protocol === "https:";
1180
+ const results = [];
1181
+ if (!isHttps) {
1182
+ results.push({
1183
+ id: `${CHECK_ID6}-no-https`,
1184
+ name: "URL does not use HTTPS",
1185
+ status: "fail",
1186
+ severity: "critical",
1187
+ category: CATEGORY3,
1188
+ location: context.url,
1189
+ description: `${context.url} uses plain HTTP. All traffic is unencrypted, exposing user data to interception.`,
1190
+ fix: "Configure your server to use HTTPS. Let's Encrypt provides free TLS certificates via Certbot.",
1191
+ aiPrompt: "My website is served over HTTP without SSL/TLS encryption. Help me set up HTTPS using Let's Encrypt and Certbot. Show me the steps to obtain a free TLS certificate, configure my web server (Nginx/Apache/Node.js), and enable automatic renewal."
1192
+ });
1193
+ }
1194
+ if (isHttps) {
1195
+ const port = parsed.port ? parseInt(parsed.port, 10) : 443;
1196
+ try {
1197
+ await verifyCertificateWithRetry(hostname, port);
1198
+ results.push({
1199
+ id: `${CHECK_ID6}-cert`,
1200
+ name: "SSL certificate is valid",
1201
+ status: "pass",
1202
+ severity: "info",
1203
+ category: CATEGORY3,
1204
+ location: hostname,
1205
+ description: `SSL/TLS certificate for ${hostname} is valid and trusted.`
1206
+ });
1207
+ } catch (error) {
1208
+ results.push(
1209
+ error instanceof Error ? buildCertErrorResult(error, hostname) : {
1210
+ id: `${CHECK_ID6}-cert`,
1211
+ name: "SSL certificate verification failed",
1212
+ status: "skip",
1213
+ severity: "info",
1214
+ category: CATEGORY3,
1215
+ location: hostname,
1216
+ description: `Could not verify SSL certificate for ${hostname}.`
1217
+ }
1218
+ );
1219
+ }
1220
+ }
1221
+ const redirects = await checkHttpsRedirect(hostname);
1222
+ if (redirects) {
1223
+ results.push({
1224
+ id: `${CHECK_ID6}-redirect`,
1225
+ name: "HTTP redirects to HTTPS",
1226
+ status: "pass",
1227
+ severity: "info",
1228
+ category: CATEGORY3,
1229
+ location: hostname,
1230
+ description: `HTTP requests to ${hostname} are redirected to HTTPS.`
1231
+ });
1232
+ } else {
1233
+ results.push({
1234
+ id: `${CHECK_ID6}-redirect`,
1235
+ name: "No HTTP to HTTPS redirect",
1236
+ status: "fail",
1237
+ severity: "medium",
1238
+ category: CATEGORY3,
1239
+ location: hostname,
1240
+ description: `HTTP requests to ${hostname} are not redirected to HTTPS. Users who type the URL without https:// will use an insecure connection.`,
1241
+ fix: "Configure your web server to return a 301 redirect from HTTP to HTTPS for all requests.",
1242
+ aiPrompt: `My website ${hostname} does not redirect HTTP to HTTPS. Help me configure a permanent 301 redirect from HTTP to HTTPS on my web server. Show me configurations for Nginx, Apache, and common hosting platforms.`
1243
+ });
1244
+ }
1245
+ return results;
1246
+ };
1247
+ var ssl_default = sslCheck;
1248
+
1249
+ // src/checks/code-patterns.ts
1250
+ import { readFile as readFile5 } from "fs/promises";
1251
+ import { join as join6 } from "path";
1252
+ var CODE_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".js", ".tsx", ".jsx"]);
1253
+ var IGNORED_DIRS2 = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "tests", "__tests__", "test", "fixtures"]);
1254
+ var PATTERN_DEFS = [
1255
+ {
1256
+ id: "eval",
1257
+ name: "eval() or new Function()",
1258
+ severity: "high",
1259
+ regex: /\b(?:eval\s*\(|new\s+Function\s*\()/,
1260
+ fix: "Replace eval() with a safe parser (e.g. JSON.parse for data, a sandboxed interpreter for expressions). Never pass user input to eval() or new Function().",
1261
+ aiPromptTemplate: (stack) => `I have an eval() or new Function() call in my ${stack?.framework ?? "JavaScript"} project. Help me replace it with a safe alternative. Show me: (1) why eval is dangerous (code injection), (2) what safe alternative to use for my specific case (JSON.parse, a schema validator, or a sandboxed parser), and (3) the refactored code.`
1262
+ },
1263
+ {
1264
+ id: "innerHTML",
1265
+ name: "innerHTML assignment with variable",
1266
+ severity: "medium",
1267
+ regex: /\.innerHTML\s*=\s*(?:[a-zA-Z_$`]|['"][^'"]*['"]\s*\+)/,
1268
+ fix: "Use textContent for plain text, or use a DOM API (createElement, appendChild) to build markup safely. In React/Vue, use framework-provided rendering instead of innerHTML.",
1269
+ aiPromptTemplate: (stack) => `I'm assigning to innerHTML with a variable in my ${stack?.framework ?? "JavaScript"} project. Help me replace it safely. Show me: (1) why innerHTML with variables enables XSS, (2) how to use textContent or DOM APIs instead, ${stack?.framework ? `(3) the idiomatic ${stack.framework} approach (e.g. JSX, template syntax)` : "(3) how to sanitize if HTML is truly needed (DOMPurify)"}.`
1270
+ },
1271
+ {
1272
+ id: "sql-injection",
1273
+ name: "SQL string concatenation",
1274
+ severity: "critical",
1275
+ regex: /\b(?:SELECT\s+|INSERT\s+INTO\s+|UPDATE\s+|DELETE\s+FROM\s+)[^;]*?(?:\$\{|['"]\s*\+)/i,
1276
+ fix: "Use parameterized queries or a query builder. Never concatenate user input into SQL strings.",
1277
+ aiPromptTemplate: (stack) => {
1278
+ const db = stack?.database;
1279
+ const dbHint = db ? `I'm using ${db}. Show me the ${db} way to use parameterized queries.` : "Show me how to use parameterized queries with my database driver.";
1280
+ return `I have SQL string concatenation in my ${stack?.framework ?? "Node.js"} project. This is vulnerable to SQL injection. ${dbHint} Show me: (1) how the current code is exploitable, (2) the safe version with parameterized queries or a query builder, and (3) how to validate/sanitize input as defense-in-depth.`;
1281
+ }
1282
+ },
1283
+ {
1284
+ id: "document-write",
1285
+ name: "document.write()",
1286
+ severity: "medium",
1287
+ regex: /\bdocument\.write(?:ln)?\s*\(/,
1288
+ fix: "Use DOM APIs (createElement, appendChild, textContent) instead of document.write(). document.write() can overwrite the entire page and enables XSS if used with untrusted input.",
1289
+ aiPromptTemplate: (stack) => `I have document.write() in my ${stack?.framework ?? "JavaScript"} project. Help me replace it. Show me: (1) why document.write() is dangerous (XSS, page overwrite), (2) the safe DOM API alternative (createElement, textContent), ${stack?.framework ? `(3) the idiomatic ${stack.framework} approach` : "(3) how to safely inject content into the DOM"}.`
1290
+ },
1291
+ {
1292
+ id: "exec-injection",
1293
+ name: "child_process.exec with dynamic input",
1294
+ severity: "high",
1295
+ regex: /\b(?:exec|execSync)\s*\(\s*(?:`[^`]*\$\{|['"][^'"]*['"]\s*\+)/,
1296
+ fix: "Use execFile() or spawn() with an argument array instead of exec() with string concatenation. This prevents shell injection by separating the command from its arguments.",
1297
+ aiPromptTemplate: (stack) => `I have a child_process.exec() call with dynamic input in my ${stack?.framework ?? "Node.js"} project. Help me fix this command injection vulnerability. Show me: (1) why exec with string interpolation is dangerous, (2) how to refactor to execFile() or spawn() with an argument array, and (3) how to validate the input before passing it to any shell command.`
1298
+ },
1299
+ {
1300
+ id: "math-random-security",
1301
+ name: "Math.random() for tokens/IDs",
1302
+ severity: "medium",
1303
+ regex: /\bMath\.random\(\)\.toString\(/,
1304
+ fix: "Use crypto.randomUUID() or crypto.getRandomValues() for security-sensitive random values. Math.random() is not cryptographically secure.",
1305
+ aiPromptTemplate: (stack) => `I'm using Math.random().toString() to generate tokens or IDs in my ${stack?.framework ?? "JavaScript"} project. Help me replace it with a cryptographically secure alternative. Show me: (1) why Math.random() is predictable and unsuitable for security, (2) how to use crypto.randomUUID() for IDs, and (3) how to use crypto.getRandomValues() for custom token formats.`
1306
+ }
1307
+ ];
1308
+ var DISPLAY_VAR_PATTERN = /\b(?:const|let|var)\s+(?:example|code|snippet|demo|preview|diff|before|after|label|text|content|description|title|display|render|show|mock|sample|placeholder)\w*\s*=/i;
1309
+ function isSqlDisplayContext(trimmed) {
1310
+ if (DISPLAY_VAR_PATTERN.test(trimmed)) return true;
1311
+ if (trimmed.startsWith("{") || trimmed.startsWith("<")) return true;
1312
+ return false;
1313
+ }
1314
+ function isCodeFile(relativePath) {
1315
+ const segments = relativePath.split("/");
1316
+ const fileName = segments[segments.length - 1] ?? "";
1317
+ const ext = fileName.includes(".") ? "." + (fileName.split(".").pop() ?? "") : "";
1318
+ if (!CODE_EXTENSIONS.has(ext)) return false;
1319
+ if (segments.some((s) => IGNORED_DIRS2.has(s))) return false;
1320
+ if (/\.(?:test|spec)\.[jt]sx?$/.test(fileName)) return false;
1321
+ return true;
1322
+ }
1323
+ function scanFileContent(content, relativePath, stack) {
1324
+ const lines = content.split("\n");
1325
+ const results = [];
1326
+ for (let i = 0; i < lines.length; i++) {
1327
+ const line = lines[i];
1328
+ if (line === void 0) continue;
1329
+ const trimmed = line.trim();
1330
+ if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) {
1331
+ continue;
1332
+ }
1333
+ if (trimmed.startsWith("'") || trimmed.startsWith('"') || trimmed.startsWith("`") || trimmed.startsWith("/")) {
1334
+ continue;
1335
+ }
1336
+ if (/^\w+\s*:\s*['"`/([]/.test(trimmed)) {
1337
+ continue;
1338
+ }
1339
+ for (const pattern of PATTERN_DEFS) {
1340
+ if (pattern.regex.test(line)) {
1341
+ if (pattern.id === "sql-injection" && isSqlDisplayContext(trimmed)) {
1342
+ continue;
1343
+ }
1344
+ results.push({
1345
+ id: "code-patterns",
1346
+ name: `Insecure pattern: ${pattern.name}`,
1347
+ status: "fail",
1348
+ severity: pattern.severity,
1349
+ category: "Code Patterns",
1350
+ location: `${relativePath}:${i + 1}`,
1351
+ description: `${pattern.name} detected \u2014 ${pattern.fix.split(".")[0]}.`,
1352
+ fix: pattern.fix,
1353
+ aiPrompt: pattern.aiPromptTemplate(stack)
1354
+ });
1355
+ }
1356
+ }
1357
+ }
1358
+ return results;
1359
+ }
1360
+ var codePatternCheck = async (context) => {
1361
+ const filesToScan = context.files.filter(isCodeFile);
1362
+ if (filesToScan.length === 0) {
1363
+ return [
1364
+ {
1365
+ id: "code-patterns",
1366
+ name: "Insecure code patterns",
1367
+ status: "skip",
1368
+ severity: "info",
1369
+ description: "No code files (.ts, .js, .tsx, .jsx) found to scan"
1370
+ }
1371
+ ];
1372
+ }
1373
+ const stackHint = {
1374
+ framework: context.stack.framework,
1375
+ database: context.stack.database
1376
+ };
1377
+ const allResults = [];
1378
+ const settled = await Promise.allSettled(
1379
+ filesToScan.map(async (file) => {
1380
+ const content = await readFile5(join6(context.projectPath, file), "utf-8");
1381
+ return scanFileContent(content, file, stackHint);
1382
+ })
1383
+ );
1384
+ for (const outcome of settled) {
1385
+ if (outcome.status === "fulfilled") {
1386
+ allResults.push(...outcome.value);
1387
+ }
1388
+ }
1389
+ if (allResults.length === 0) {
1390
+ return [
1391
+ {
1392
+ id: "code-patterns",
1393
+ name: "Insecure code patterns",
1394
+ status: "pass",
1395
+ severity: "info",
1396
+ description: "No insecure code patterns detected"
1397
+ }
1398
+ ];
1399
+ }
1400
+ return allResults;
1401
+ };
1402
+ var code_patterns_default = codePatternCheck;
1403
+
1404
+ // src/checks/cors.ts
1405
+ import { readFile as readFile6 } from "fs/promises";
1406
+ import { join as join7 } from "path";
1407
+ var CHECK_ID7 = "cors";
1408
+ var CHECK_NAME3 = "CORS configuration";
1409
+ var SCANNABLE_EXTENSIONS2 = /* @__PURE__ */ new Set([".ts", ".js", ".tsx", ".jsx", ".mjs", ".cjs"]);
1410
+ var IGNORED_DIRS3 = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", ".next", "tests", "__tests__", "test", "fixtures"]);
1411
+ var CORS_PATTERNS = [
1412
+ {
1413
+ name: "Access-Control-Allow-Origin: * (header method)",
1414
+ regex: /(?:\.setHeader|\.header|headers\.set|\.append)\s*\(\s*['"]Access-Control-Allow-Origin['"]\s*,\s*['"]\*['"]/,
1415
+ kind: "wildcard-header"
1416
+ },
1417
+ {
1418
+ name: "Access-Control-Allow-Origin: * (object literal)",
1419
+ regex: /['"]Access-Control-Allow-Origin['"]\s*:\s*['"]\*['"]/,
1420
+ kind: "wildcard-header"
1421
+ },
1422
+ {
1423
+ name: "Wildcard origin in CORS config",
1424
+ regex: /\borigin\s*:\s*['"]\*['"]/,
1425
+ kind: "wildcard-config"
1426
+ },
1427
+ {
1428
+ name: "cors() with no configuration",
1429
+ regex: /\bcors\(\s*\)/,
1430
+ kind: "bare-cors"
1431
+ }
1432
+ ];
1433
+ var CREDENTIALS_REGEX = /\bcredentials\s*:\s*true\b/;
1434
+ function isScannableFile2(relativePath) {
1435
+ const segments = relativePath.split("/");
1436
+ const fileName = segments[segments.length - 1] ?? "";
1437
+ const ext = fileName.includes(".") ? "." + (fileName.split(".").pop() ?? "") : "";
1438
+ if (!SCANNABLE_EXTENSIONS2.has(ext)) return false;
1439
+ if (segments.some((s) => IGNORED_DIRS3.has(s))) return false;
1440
+ if (/\.(?:test|spec)\.[jt]sx?$/.test(fileName)) return false;
1441
+ return true;
1442
+ }
1443
+ function hasCredentialsInCode(lines) {
1444
+ return lines.some((line) => {
1445
+ const trimmed = line.trim();
1446
+ if (trimmed.startsWith("//") || trimmed.startsWith("#") || trimmed.startsWith("*")) {
1447
+ return false;
1448
+ }
1449
+ return CREDENTIALS_REGEX.test(line);
1450
+ });
1451
+ }
1452
+ function buildAiPrompt2(stack) {
1453
+ switch (stack.framework) {
1454
+ case "express":
1455
+ return "I found a permissive CORS configuration in my Express app. Help me fix it by: (1) replacing cors() or cors({ origin: '*' }) with cors({ origin: 'https://yourdomain.com' }), (2) listing only trusted origins if multiple are needed, (3) explaining when credentials: true is safe to use, (4) showing how to use a dynamic origin function for multiple allowed domains.";
1456
+ case "next.js":
1457
+ return "I found a permissive CORS configuration in my Next.js app. Help me fix it by: (1) replacing Access-Control-Allow-Origin: * with my specific domain in API routes or middleware, (2) showing the correct way to handle CORS in Next.js App Router and Pages Router, (3) explaining how to handle OPTIONS preflight requests, (4) showing how to use next.config.js headers for static CORS if needed.";
1458
+ case "fastify":
1459
+ return "I found a permissive CORS configuration in my Fastify app. Help me fix it by: (1) replacing origin: '*' with my specific domain in the @fastify/cors plugin options, (2) showing how to configure @fastify/cors with a whitelist of allowed origins, (3) explaining the difference between origin: true and origin: '*', (4) showing how to handle credentials safely with Fastify CORS.";
1460
+ default:
1461
+ return "I found a permissive CORS configuration in my project. Help me fix it by: (1) replacing Access-Control-Allow-Origin: * with my specific domain, (2) listing only trusted origins, (3) explaining when credentials: true is safe and how it interacts with wildcard origins, (4) showing the correct CORS configuration for my framework.";
1462
+ }
1463
+ }
1464
+ function scanContent2(content, relativePath, stack) {
1465
+ const lines = content.split("\n");
1466
+ const hasCredentials = hasCredentialsInCode(lines);
1467
+ const results = [];
1468
+ const aiPrompt = buildAiPrompt2(stack);
1469
+ for (let i = 0; i < lines.length; i++) {
1470
+ const line = lines[i];
1471
+ if (line === void 0) continue;
1472
+ const trimmed = line.trim();
1473
+ if (trimmed.startsWith("//") || trimmed.startsWith("#") || trimmed.startsWith("*")) {
1474
+ continue;
1475
+ }
1476
+ if (trimmed.startsWith("'") || trimmed.startsWith('"') || trimmed.startsWith("`") || trimmed.startsWith("/")) {
1477
+ continue;
1478
+ }
1479
+ if (/^(?!origin\b)\w+\s*:\s*['"`/([]/.test(trimmed)) {
1480
+ continue;
1481
+ }
1482
+ for (const pattern of CORS_PATTERNS) {
1483
+ if (!pattern.regex.test(line)) continue;
1484
+ if (pattern.kind === "bare-cors") {
1485
+ results.push({
1486
+ id: CHECK_ID7,
1487
+ name: `CORS: ${pattern.name}`,
1488
+ status: "fail",
1489
+ severity: "medium",
1490
+ category: "CORS",
1491
+ location: `${relativePath}:${i + 1}`,
1492
+ description: "cors() called with no configuration allows all origins by default. Any website can make requests to your API.",
1493
+ fix: "Pass an options object with a restrictive origin: cors({ origin: 'https://yourdomain.com' })",
1494
+ aiPrompt
1495
+ });
1496
+ } else {
1497
+ const severity = hasCredentials ? "critical" : "high";
1498
+ const credentialsNote = hasCredentials ? " Combined with credentials: true, this allows any site to make authenticated requests \u2014 a serious security risk." : "";
1499
+ const description = pattern.kind === "wildcard-header" ? `Access-Control-Allow-Origin is set to "*", allowing any website to read responses from your API.${credentialsNote}` : `CORS origin is configured as "*", allowing any website to make requests to your API.${credentialsNote}`;
1500
+ const fix = hasCredentials ? 'Set a specific origin instead of "*" and only enable credentials for trusted origins. Browsers block credentials with wildcard origins, but misconfigured proxies or non-browser clients can still exploit this.' : `Set a specific origin instead of "*" \u2014 e.g., origin: 'https://yourdomain.com'. Only allow origins you trust.`;
1501
+ results.push({
1502
+ id: CHECK_ID7,
1503
+ name: hasCredentials ? "CORS: Credentials with wildcard origin" : `CORS: ${pattern.name}`,
1504
+ status: "fail",
1505
+ severity,
1506
+ category: "CORS",
1507
+ location: `${relativePath}:${i + 1}`,
1508
+ description,
1509
+ fix,
1510
+ aiPrompt
1511
+ });
1512
+ }
1513
+ }
1514
+ }
1515
+ return results;
1516
+ }
1517
+ var corsCheck = async (context) => {
1518
+ const filesToScan = context.files.filter(isScannableFile2);
1519
+ if (filesToScan.length === 0) {
1520
+ return [
1521
+ {
1522
+ id: CHECK_ID7,
1523
+ name: CHECK_NAME3,
1524
+ status: "skip",
1525
+ severity: "info",
1526
+ description: "No scannable source files found"
1527
+ }
1528
+ ];
1529
+ }
1530
+ const allResults = [];
1531
+ const settled = await Promise.allSettled(
1532
+ filesToScan.map(async (file) => {
1533
+ const content = await readFile6(join7(context.projectPath, file), "utf-8");
1534
+ return scanContent2(content, file, context.stack);
1535
+ })
1536
+ );
1537
+ for (const outcome of settled) {
1538
+ if (outcome.status === "fulfilled") {
1539
+ allResults.push(...outcome.value);
1540
+ }
1541
+ }
1542
+ if (allResults.length === 0) {
1543
+ return [
1544
+ {
1545
+ id: CHECK_ID7,
1546
+ name: CHECK_NAME3,
1547
+ status: "pass",
1548
+ severity: "info",
1549
+ description: "No permissive CORS configurations detected"
1550
+ }
1551
+ ];
1552
+ }
1553
+ return allResults;
1554
+ };
1555
+ var cors_default = corsCheck;
1556
+
1557
+ // src/checks/rate-limit.ts
1558
+ import { readFile as readFile7 } from "fs/promises";
1559
+ import { join as join8 } from "path";
1560
+ var RATE_LIMIT_PACKAGES = [
1561
+ "express-rate-limit",
1562
+ "@upstash/ratelimit",
1563
+ "rate-limiter-flexible",
1564
+ "@fastify/rate-limit",
1565
+ "fastify-rate-limit"
1566
+ ];
1567
+ var RATE_LIMIT_PATTERNS = [
1568
+ // Package imports (ESM)
1569
+ /from\s+['"]express-rate-limit['"]/,
1570
+ /from\s+['"]@upstash\/ratelimit['"]/,
1571
+ /from\s+['"]rate-limiter-flexible['"]/,
1572
+ /from\s+['"]@fastify\/rate-limit['"]/,
1573
+ /from\s+['"]hono\/rate-limit['"]/,
1574
+ // Package imports (CJS)
1575
+ /require\s*\(\s*['"]express-rate-limit['"]\s*\)/,
1576
+ /require\s*\(\s*['"]@upstash\/ratelimit['"]\s*\)/,
1577
+ /require\s*\(\s*['"]rate-limiter-flexible['"]\s*\)/,
1578
+ /require\s*\(\s*['"]@fastify\/rate-limit['"]\s*\)/,
1579
+ // Common function call patterns
1580
+ /rateLimit\s*\(/,
1581
+ // rate-limiter-flexible class constructors
1582
+ /RateLimiterMemory|RateLimiterRedis|RateLimiterMongo/
1583
+ ];
1584
+ var SCANNABLE_EXTENSIONS3 = /* @__PURE__ */ new Set([".ts", ".js", ".tsx", ".jsx"]);
1585
+ var IGNORED_DIRS4 = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git"]);
1586
+ function getRecommendation(stack) {
1587
+ switch (stack.framework) {
1588
+ case "express":
1589
+ return {
1590
+ packageName: "express-rate-limit",
1591
+ install: "npm install express-rate-limit"
1592
+ };
1593
+ case "fastify":
1594
+ return {
1595
+ packageName: "@fastify/rate-limit",
1596
+ install: "npm install @fastify/rate-limit"
1597
+ };
1598
+ case "hono":
1599
+ return {
1600
+ packageName: "hono (built-in)",
1601
+ install: 'import { rateLimiter } from "hono/rate-limit"'
1602
+ };
1603
+ case "next.js":
1604
+ return {
1605
+ packageName: "@upstash/ratelimit",
1606
+ install: "npm install @upstash/ratelimit @upstash/redis"
1607
+ };
1608
+ default:
1609
+ return {
1610
+ packageName: "rate-limiter-flexible",
1611
+ install: "npm install rate-limiter-flexible"
1612
+ };
1613
+ }
1614
+ }
1615
+ function buildAiPrompt3(stack) {
1616
+ const rec = getRecommendation(stack);
1617
+ const framework = stack.framework ?? "my web application";
1618
+ return `I need to add rate limiting to my ${framework} project. The recommended package is ${rec.packageName}. Show me: (1) how to install and configure it, (2) how to apply it to my API routes with sensible defaults, (3) recommended limits for login and API endpoints (e.g. 5 login attempts per 15 minutes, 100 API requests per minute), and (4) how to return proper 429 Too Many Requests responses with Retry-After headers.`;
1619
+ }
1620
+ function isScannableFile3(relativePath) {
1621
+ const segments = relativePath.split("/");
1622
+ const fileName = segments[segments.length - 1] ?? "";
1623
+ const ext = fileName.includes(".") ? "." + (fileName.split(".").pop() ?? "") : "";
1624
+ if (!SCANNABLE_EXTENSIONS3.has(ext)) return false;
1625
+ if (segments.some((s) => IGNORED_DIRS4.has(s))) return false;
1626
+ return true;
1627
+ }
1628
+ function findRateLimitDependency(dependencies) {
1629
+ return dependencies.find((dep) => RATE_LIMIT_PACKAGES.includes(dep));
1630
+ }
1631
+ function hasRateLimitPattern(content) {
1632
+ return RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(content));
1633
+ }
1634
+ var rateLimitCheck = async (context) => {
1635
+ const dependencies = context.stack.dependencies ?? [];
1636
+ const foundPackage = findRateLimitDependency(dependencies);
1637
+ if (foundPackage) {
1638
+ return [
1639
+ {
1640
+ id: "rate-limit",
1641
+ name: "Rate limiting",
1642
+ status: "pass",
1643
+ severity: "info",
1644
+ category: "API Security",
1645
+ description: `Rate limiting package detected: ${foundPackage}`
1646
+ }
1647
+ ];
1648
+ }
1649
+ const filesToScan = context.files.filter(isScannableFile3);
1650
+ if (filesToScan.length > 0) {
1651
+ const settled = await Promise.allSettled(
1652
+ filesToScan.map(async (file) => {
1653
+ const content = await readFile7(
1654
+ join8(context.projectPath, file),
1655
+ "utf-8"
1656
+ );
1657
+ return hasRateLimitPattern(content);
1658
+ })
1659
+ );
1660
+ const foundInSource = settled.some(
1661
+ (outcome) => outcome.status === "fulfilled" && outcome.value
1662
+ );
1663
+ if (foundInSource) {
1664
+ return [
1665
+ {
1666
+ id: "rate-limit",
1667
+ name: "Rate limiting",
1668
+ status: "pass",
1669
+ severity: "info",
1670
+ category: "API Security",
1671
+ description: "Rate limiting middleware detected in source code"
1672
+ }
1673
+ ];
1674
+ }
1675
+ }
1676
+ const rec = getRecommendation(context.stack);
1677
+ return [
1678
+ {
1679
+ id: "rate-limit",
1680
+ name: "Rate limiting",
1681
+ status: "fail",
1682
+ severity: "high",
1683
+ category: "API Security",
1684
+ description: "No rate limiting middleware detected \u2014 brute-force and denial-of-service attacks are trivial without it",
1685
+ fix: `Install a rate limiting package: ${rec.install}. Apply it to all API routes, especially authentication endpoints.`,
1686
+ aiPrompt: buildAiPrompt3(context.stack)
1687
+ }
1688
+ ];
1689
+ };
1690
+ var rate_limit_default = rateLimitCheck;
1691
+
1692
+ // src/checks/auth.ts
1693
+ import { readFile as readFile8 } from "fs/promises";
1694
+ import { join as join9 } from "path";
1695
+ var PROVIDER_NAMES = {
1696
+ clerk: "Clerk",
1697
+ auth0: "Auth0",
1698
+ "next-auth": "NextAuth.js",
1699
+ "supabase-auth": "Supabase Auth",
1700
+ passport: "Passport.js",
1701
+ lucia: "Lucia"
1702
+ };
1703
+ var CUSTOM_AUTH_PACKAGES = [
1704
+ "bcrypt",
1705
+ "bcryptjs",
1706
+ "argon2",
1707
+ "jsonwebtoken"
1708
+ ];
1709
+ var AUTH_FILE_PATTERNS = [
1710
+ { name: "crypto.scrypt", regex: /crypto\.scrypt(?:Sync)?\s*\(/ },
1711
+ { name: "crypto.pbkdf2", regex: /crypto\.pbkdf2(?:Sync)?\s*\(/ },
1712
+ { name: "jwt.sign", regex: /jwt\.sign\s*\(/ },
1713
+ { name: "jwt.verify", regex: /jwt\.verify\s*\(/ },
1714
+ { name: "password hashing", regex: /(?:hashPassword|comparePassword|verifyPassword|passwordHash)\s*[=(]/ }
1715
+ ];
1716
+ var SCANNABLE_EXTENSIONS4 = /* @__PURE__ */ new Set([".ts", ".js", ".tsx", ".jsx"]);
1717
+ var IGNORED_DIRS5 = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git"]);
1718
+ var USER_FACING_DIRS = /* @__PURE__ */ new Set(["api", "routes", "pages", "views"]);
1719
+ function findCustomAuthDeps(dependencies) {
1720
+ return dependencies.filter((dep) => CUSTOM_AUTH_PACKAGES.includes(dep));
1721
+ }
1722
+ function scanFileForAuthPatterns(content) {
1723
+ const found = [];
1724
+ for (const pattern of AUTH_FILE_PATTERNS) {
1725
+ if (pattern.regex.test(content)) {
1726
+ found.push(pattern.name);
1727
+ }
1728
+ }
1729
+ return found;
1730
+ }
1731
+ function hasUserFacingFeatures(context) {
1732
+ if (context.stack.framework) return true;
1733
+ return context.files.some(
1734
+ (f) => f.split("/").some((segment) => USER_FACING_DIRS.has(segment))
1735
+ );
1736
+ }
1737
+ function isLibraryOrCli(context) {
1738
+ if (!context.packageJson) return false;
1739
+ if (context.packageJson["bin"]) return true;
1740
+ if (context.packageJson["private"] && context.packageJson["workspaces"]) return true;
1741
+ if (!context.stack.framework && (context.packageJson["main"] || context.packageJson["exports"])) {
1742
+ return true;
1743
+ }
1744
+ return false;
1745
+ }
1746
+ function getRecommendedProvider(stack) {
1747
+ switch (stack.framework) {
1748
+ case "next.js":
1749
+ return stack.database === "supabase" ? "Supabase Auth" : "Clerk or NextAuth.js";
1750
+ case "express":
1751
+ case "fastify":
1752
+ case "hono":
1753
+ return "Auth0 or Passport.js";
1754
+ case "remix":
1755
+ return "Auth0 or Lucia";
1756
+ case "sveltekit":
1757
+ return "Lucia or Auth.js";
1758
+ case "nuxt":
1759
+ return "Auth0 or Sidebase Auth";
1760
+ case "astro":
1761
+ return "Lucia or Auth.js";
1762
+ default:
1763
+ return "Clerk or Auth0";
1764
+ }
1765
+ }
1766
+ function isScannableFile4(relativePath) {
1767
+ const segments = relativePath.split("/");
1768
+ const fileName = segments[segments.length - 1] ?? "";
1769
+ const ext = fileName.includes(".") ? "." + (fileName.split(".").pop() ?? "") : "";
1770
+ if (!SCANNABLE_EXTENSIONS4.has(ext)) return false;
1771
+ if (segments.some((s) => IGNORED_DIRS5.has(s))) return false;
1772
+ return true;
1773
+ }
1774
+ var authCheck = async (context) => {
1775
+ const recommended = getRecommendedProvider(context.stack);
1776
+ const stackLabel = context.stack.framework ?? context.stack.language;
1777
+ if (context.stack.auth) {
1778
+ const providerName = PROVIDER_NAMES[context.stack.auth] ?? context.stack.auth;
1779
+ return [
1780
+ {
1781
+ id: "auth",
1782
+ name: "Authentication method",
1783
+ status: "pass",
1784
+ severity: "info",
1785
+ category: "Authentication",
1786
+ description: `Established auth provider detected: ${providerName}`,
1787
+ fix: `Ensure ${providerName} is configured securely with proper session management and CSRF protection.`,
1788
+ aiPrompt: `I'm using ${providerName} for authentication in my ${stackLabel} project. Help me ensure it's configured securely. Show me: (1) how to protect all API routes, (2) how to set up role-based access control, (3) how to implement secure session management, (4) best practices for ${providerName} specifically.`
1789
+ }
1790
+ ];
1791
+ }
1792
+ const deps = context.stack.dependencies ?? [];
1793
+ const customAuthDeps = findCustomAuthDeps(deps);
1794
+ if (customAuthDeps.length > 0) {
1795
+ return [
1796
+ {
1797
+ id: "auth",
1798
+ name: "Authentication method",
1799
+ status: "warn",
1800
+ severity: "medium",
1801
+ category: "Authentication",
1802
+ description: `Custom auth implementation detected via dependencies: ${customAuthDeps.join(", ")}. Consider using an established auth provider.`,
1803
+ fix: `Replace custom auth with ${recommended}. Established providers handle password hashing, session management, CSRF protection, and security updates automatically.`,
1804
+ aiPrompt: `I'm using custom authentication (${customAuthDeps.join(", ")}) in my ${stackLabel} project. Help me migrate to ${recommended}. Show me: (1) how to install and configure it, (2) how to protect API routes, (3) how to handle user sessions, (4) how to implement sign-up and login flows.`
1805
+ }
1806
+ ];
1807
+ }
1808
+ const filesToScan = context.files.filter(isScannableFile4);
1809
+ const allPatterns = [];
1810
+ if (filesToScan.length > 0) {
1811
+ const settled = await Promise.allSettled(
1812
+ filesToScan.map(async (file) => {
1813
+ const content = await readFile8(join9(context.projectPath, file), "utf-8");
1814
+ return scanFileForAuthPatterns(content);
1815
+ })
1816
+ );
1817
+ for (const outcome of settled) {
1818
+ if (outcome.status === "fulfilled" && outcome.value.length > 0) {
1819
+ allPatterns.push(...outcome.value);
1820
+ }
1821
+ }
1822
+ }
1823
+ if (allPatterns.length > 0) {
1824
+ const unique = [...new Set(allPatterns)];
1825
+ return [
1826
+ {
1827
+ id: "auth",
1828
+ name: "Authentication method",
1829
+ status: "warn",
1830
+ severity: "medium",
1831
+ category: "Authentication",
1832
+ description: `Custom auth patterns detected: ${unique.join(", ")}. Consider using an established auth provider.`,
1833
+ fix: `Replace custom auth with ${recommended}. Established providers handle password hashing, session management, CSRF protection, and security updates automatically.`,
1834
+ aiPrompt: `I'm using custom authentication patterns (${unique.join(", ")}) in my ${stackLabel} project. Help me migrate to ${recommended}. Show me: (1) how to install and configure it, (2) how to protect API routes, (3) how to handle user sessions, (4) how to implement sign-up and login flows.`
1835
+ }
1836
+ ];
1837
+ }
1838
+ if (isLibraryOrCli(context)) {
1839
+ return [
1840
+ {
1841
+ id: "auth",
1842
+ name: "Authentication method",
1843
+ status: "skip",
1844
+ severity: "info",
1845
+ category: "Authentication",
1846
+ description: "Project appears to be a library or CLI tool \u2014 authentication check not applicable."
1847
+ }
1848
+ ];
1849
+ }
1850
+ if (hasUserFacingFeatures(context)) {
1851
+ return [
1852
+ {
1853
+ id: "auth",
1854
+ name: "Authentication method",
1855
+ status: "fail",
1856
+ severity: "high",
1857
+ category: "Authentication",
1858
+ description: "No authentication detected in a project with API routes or user-facing features.",
1859
+ fix: `Add authentication using ${recommended}. This protects your API routes and user data from unauthorized access.`,
1860
+ aiPrompt: `My ${stackLabel} project has no authentication. Help me add authentication using ${recommended}. Show me: (1) how to install and configure it, (2) how to protect API routes and pages, (3) how to implement sign-up, login, and logout flows, (4) how to manage user sessions securely.`
1861
+ }
1862
+ ];
1863
+ }
1864
+ return [
1865
+ {
1866
+ id: "auth",
1867
+ name: "Authentication method",
1868
+ status: "skip",
1869
+ severity: "info",
1870
+ category: "Authentication",
1871
+ description: "No user-facing features detected \u2014 authentication check not applicable."
1872
+ }
1873
+ ];
1874
+ };
1875
+ var auth_default = authCheck;
1876
+
1877
+ // src/checks/cookies.ts
1878
+ var CHECK_ID8 = "cookies";
1879
+ var CATEGORY4 = "cookies";
1880
+ function parseCookie(raw) {
1881
+ const name = raw.split("=")[0].trim();
1882
+ const lower = raw.toLowerCase();
1883
+ return {
1884
+ name,
1885
+ httpOnly: lower.includes("httponly"),
1886
+ secure: lower.includes("secure"),
1887
+ sameSite: lower.includes("samesite")
1888
+ };
1889
+ }
1890
+ var cookieCheck = async (context) => {
1891
+ if (!context.url) {
1892
+ return [{
1893
+ id: CHECK_ID8,
1894
+ name: "Cookie security flags",
1895
+ status: "skip",
1896
+ severity: "info",
1897
+ category: CATEGORY4,
1898
+ description: "No URL provided \u2014 skipping cookie flag check."
1899
+ }];
1900
+ }
1901
+ const target = context.url.startsWith("http") ? context.url : `https://${context.url}`;
1902
+ const { response, error } = await fetchWithRetry(target, { redirect: "follow" });
1903
+ if (!response) {
1904
+ return [{
1905
+ id: CHECK_ID8,
1906
+ name: "Cookie security flags",
1907
+ status: "skip",
1908
+ severity: "info",
1909
+ category: CATEGORY4,
1910
+ description: `Could not reach ${target}: ${error instanceof Error ? error.message : "unknown error"}`
1911
+ }];
1912
+ }
1913
+ const setCookies = [];
1914
+ response.headers.forEach((value, key) => {
1915
+ if (key.toLowerCase() === "set-cookie") {
1916
+ setCookies.push(value);
1917
+ }
1918
+ });
1919
+ const expanded = [];
1920
+ for (const raw of setCookies) {
1921
+ const parts = raw.split(/,\s*(?=[a-zA-Z_][a-zA-Z0-9_]*=)/);
1922
+ expanded.push(...parts);
1923
+ }
1924
+ if (expanded.length === 0) {
1925
+ return [{
1926
+ id: CHECK_ID8,
1927
+ name: "Cookie security flags",
1928
+ status: "pass",
1929
+ severity: "info",
1930
+ category: CATEGORY4,
1931
+ location: target,
1932
+ description: "No cookies set \u2014 nothing to check."
1933
+ }];
1934
+ }
1935
+ const results = [];
1936
+ for (const raw of expanded) {
1937
+ const cookie = parseCookie(raw);
1938
+ if (!cookie.name) continue;
1939
+ const missing = [];
1940
+ if (!cookie.httpOnly) missing.push("HttpOnly");
1941
+ if (!cookie.secure) missing.push("Secure");
1942
+ if (!cookie.sameSite) missing.push("SameSite");
1943
+ if (missing.length === 0) {
1944
+ results.push({
1945
+ id: CHECK_ID8,
1946
+ name: `Cookie: ${cookie.name}`,
1947
+ status: "pass",
1948
+ severity: "info",
1949
+ category: CATEGORY4,
1950
+ location: target,
1951
+ description: `Cookie "${cookie.name}" has all recommended security flags.`
1952
+ });
1953
+ } else {
1954
+ const hasHttpOnlyOrSecure = missing.includes("HttpOnly") || missing.includes("Secure");
1955
+ results.push({
1956
+ id: CHECK_ID8,
1957
+ name: `Cookie: ${cookie.name}`,
1958
+ status: "warn",
1959
+ severity: hasHttpOnlyOrSecure ? "medium" : "low",
1960
+ category: CATEGORY4,
1961
+ location: target,
1962
+ description: `Cookie "${cookie.name}" is missing: ${missing.join(", ")}. ${!cookie.httpOnly ? "Without HttpOnly, JavaScript can access this cookie (XSS risk). " : ""}${!cookie.secure ? "Without Secure, the cookie may be sent over plain HTTP. " : ""}${!cookie.sameSite ? "Without SameSite, the cookie is vulnerable to CSRF attacks." : ""}`,
1963
+ fix: `Add the missing flags: Set-Cookie: ${cookie.name}=...; ${missing.join("; ")}`
1964
+ });
1965
+ }
1966
+ }
1967
+ if (results.length === 0) {
1968
+ return [{
1969
+ id: CHECK_ID8,
1970
+ name: "Cookie security flags",
1971
+ status: "pass",
1972
+ severity: "info",
1973
+ category: CATEGORY4,
1974
+ location: target,
1975
+ description: "No cookies set \u2014 nothing to check."
1976
+ }];
1977
+ }
1978
+ return results;
1979
+ };
1980
+ var cookies_default = cookieCheck;
1981
+
1982
+ // src/checks/server-disclosure.ts
1983
+ var CHECK_ID9 = "server-disclosure";
1984
+ var CATEGORY5 = "headers";
1985
+ var VERSION_PATTERN = /\b(?:apache|nginx|iis|litespeed|openresty|tomcat|jetty|caddy|gunicorn|uwsgi|express|kestrel)\b.*?\d+/i;
1986
+ var GENERIC_SERVERS = /* @__PURE__ */ new Set([
1987
+ "cloudflare",
1988
+ "cloudfront",
1989
+ "vercel",
1990
+ "netlify",
1991
+ "akamaighost",
1992
+ "fastly",
1993
+ "gws",
1994
+ "gse",
1995
+ "fly.io"
1996
+ ]);
1997
+ var serverDisclosureCheck = async (context) => {
1998
+ if (!context.url) {
1999
+ return [{
2000
+ id: CHECK_ID9,
2001
+ name: "Server header disclosure",
2002
+ status: "skip",
2003
+ severity: "info",
2004
+ category: CATEGORY5,
2005
+ description: "No URL provided \u2014 skipping server header check."
2006
+ }];
2007
+ }
2008
+ const target = context.url.startsWith("http") ? context.url : `https://${context.url}`;
2009
+ const { response, error } = await fetchWithRetry(target, { redirect: "follow" });
2010
+ if (!response) {
2011
+ return [{
2012
+ id: CHECK_ID9,
2013
+ name: "Server header disclosure",
2014
+ status: "skip",
2015
+ severity: "info",
2016
+ category: CATEGORY5,
2017
+ description: `Could not reach ${target}: ${error instanceof Error ? error.message : "unknown error"}`
2018
+ }];
2019
+ }
2020
+ const server = response.headers.get("server");
2021
+ if (!server) {
2022
+ return [{
2023
+ id: CHECK_ID9,
2024
+ name: "Server header disclosure",
2025
+ status: "pass",
2026
+ severity: "info",
2027
+ category: CATEGORY5,
2028
+ location: target,
2029
+ description: "No Server header present \u2014 no information disclosed."
2030
+ }];
2031
+ }
2032
+ const lower = server.toLowerCase().trim();
2033
+ if (GENERIC_SERVERS.has(lower)) {
2034
+ return [{
2035
+ id: CHECK_ID9,
2036
+ name: "Server header disclosure",
2037
+ status: "pass",
2038
+ severity: "info",
2039
+ category: CATEGORY5,
2040
+ location: target,
2041
+ description: `Server header is generic: "${server}". No version disclosed.`
2042
+ }];
2043
+ }
2044
+ if (VERSION_PATTERN.test(server)) {
2045
+ return [{
2046
+ id: CHECK_ID9,
2047
+ name: "Server header disclosure",
2048
+ status: "warn",
2049
+ severity: "low",
2050
+ category: CATEGORY5,
2051
+ location: target,
2052
+ description: `Server header reveals software and version: "${server}". Attackers can use this to target known vulnerabilities for that version.`,
2053
+ fix: 'Remove or genericize the Server header. In nginx: `server_tokens off;`. In Apache: `ServerTokens Prod`. In Express: `app.disable("x-powered-by")`.'
2054
+ }];
2055
+ }
2056
+ return [{
2057
+ id: CHECK_ID9,
2058
+ name: "Server header disclosure",
2059
+ status: "pass",
2060
+ severity: "info",
2061
+ category: CATEGORY5,
2062
+ location: target,
2063
+ description: `Server header present ("${server}") but no specific version disclosed.`
2064
+ }];
2065
+ };
2066
+ var server_disclosure_default = serverDisclosureCheck;
2067
+
2068
+ // src/checks/dmarc.ts
2069
+ import { resolveTxt } from "dns/promises";
2070
+ var CHECK_ID10 = "dmarc";
2071
+ var CATEGORY6 = "email";
2072
+ function extractDomain(url) {
2073
+ try {
2074
+ const parsed = new URL(url.startsWith("http") ? url : `https://${url}`);
2075
+ return parsed.hostname;
2076
+ } catch {
2077
+ return url;
2078
+ }
2079
+ }
2080
+ var dmarcCheck = async (context) => {
2081
+ if (!context.url) {
2082
+ return [{
2083
+ id: CHECK_ID10,
2084
+ name: "DMARC record",
2085
+ status: "skip",
2086
+ severity: "info",
2087
+ category: CATEGORY6,
2088
+ description: "No URL provided \u2014 skipping DMARC check."
2089
+ }];
2090
+ }
2091
+ const domain = extractDomain(context.url);
2092
+ const dmarcHost = `_dmarc.${domain}`;
2093
+ let records;
2094
+ try {
2095
+ records = await resolveTxt(dmarcHost);
2096
+ } catch (err) {
2097
+ const code = err.code;
2098
+ if (code === "ENODATA" || code === "ENOTFOUND" || code === "SERVFAIL") {
2099
+ return [{
2100
+ id: CHECK_ID10,
2101
+ name: "DMARC record",
2102
+ status: "warn",
2103
+ severity: "medium",
2104
+ category: CATEGORY6,
2105
+ location: dmarcHost,
2106
+ description: `No DMARC record found for ${domain}. Without DMARC, attackers can spoof emails from your domain to phish your users.`,
2107
+ fix: `Add a TXT record at _dmarc.${domain} with value: "v=DMARC1; p=reject; rua=mailto:dmarc@${domain}"`
2108
+ }];
2109
+ }
2110
+ return [{
2111
+ id: CHECK_ID10,
2112
+ name: "DMARC record",
2113
+ status: "skip",
2114
+ severity: "info",
2115
+ category: CATEGORY6,
2116
+ description: `DNS lookup failed for ${dmarcHost}: ${err instanceof Error ? err.message : "unknown error"}`
2117
+ }];
2118
+ }
2119
+ const flat = records.map((chunks) => chunks.join(""));
2120
+ const dmarc = flat.find((r) => r.toLowerCase().startsWith("v=dmarc1"));
2121
+ if (!dmarc) {
2122
+ return [{
2123
+ id: CHECK_ID10,
2124
+ name: "DMARC record",
2125
+ status: "warn",
2126
+ severity: "medium",
2127
+ category: CATEGORY6,
2128
+ location: dmarcHost,
2129
+ description: `TXT records exist at ${dmarcHost} but no valid DMARC record (must start with "v=DMARC1").`,
2130
+ fix: `Add a TXT record at _dmarc.${domain} with value: "v=DMARC1; p=reject; rua=mailto:dmarc@${domain}"`
2131
+ }];
2132
+ }
2133
+ const policyMatch = dmarc.match(/;\s*p\s*=\s*(reject|quarantine|none)/i);
2134
+ const policy = policyMatch ? policyMatch[1].toLowerCase() : "unknown";
2135
+ if (policy === "reject") {
2136
+ return [{
2137
+ id: CHECK_ID10,
2138
+ name: "DMARC record",
2139
+ status: "pass",
2140
+ severity: "info",
2141
+ category: CATEGORY6,
2142
+ location: dmarcHost,
2143
+ description: `DMARC record found with p=reject \u2014 the strongest policy. Spoofed emails from ${domain} will be rejected.`
2144
+ }];
2145
+ }
2146
+ if (policy === "quarantine") {
2147
+ return [{
2148
+ id: CHECK_ID10,
2149
+ name: "DMARC record",
2150
+ status: "pass",
2151
+ severity: "info",
2152
+ category: CATEGORY6,
2153
+ location: dmarcHost,
2154
+ description: `DMARC record found with p=quarantine. Spoofed emails may be delivered to spam. Consider upgrading to p=reject.`
2155
+ }];
2156
+ }
2157
+ return [{
2158
+ id: CHECK_ID10,
2159
+ name: "DMARC record",
2160
+ status: "warn",
2161
+ severity: "low",
2162
+ category: CATEGORY6,
2163
+ location: dmarcHost,
2164
+ description: `DMARC record found but policy is p=${policy}. This only monitors \u2014 it does not prevent email spoofing.`,
2165
+ fix: `Upgrade your DMARC policy to p=quarantine or p=reject once you have verified legitimate email sources are aligned.`
2166
+ }];
2167
+ };
2168
+ var dmarc_default = dmarcCheck;
2169
+
2170
+ // src/checks/index.ts
2171
+ function getAllChecks() {
2172
+ return [
2173
+ gitignore_default,
2174
+ secrets_default,
2175
+ dependencies_default,
2176
+ env_example_default,
2177
+ security_txt_default,
2178
+ headers_default,
2179
+ security_txt_url_default,
2180
+ ssl_default,
2181
+ code_patterns_default,
2182
+ cors_default,
2183
+ rate_limit_default,
2184
+ auth_default,
2185
+ cookies_default,
2186
+ server_disclosure_default,
2187
+ dmarc_default
2188
+ ];
2189
+ }
2190
+ function getUrlOnlyChecks() {
2191
+ return [headers_default, ssl_default, security_txt_url_default, cookies_default, server_disclosure_default, dmarc_default];
2192
+ }
2193
+ function getStaticSiteSkippableChecks() {
2194
+ return [
2195
+ { fn: rate_limit_default, id: "rate-limit", name: "Rate limiting" },
2196
+ { fn: auth_default, id: "auth", name: "Authentication" },
2197
+ { fn: cors_default, id: "cors", name: "CORS configuration" },
2198
+ { fn: env_example_default, id: "env-example", name: ".env.example exists" }
2199
+ ];
2200
+ }
2201
+
2202
+ // src/detectors/stack.ts
2203
+ var FRAMEWORK_MATCHERS = [
2204
+ { packages: ["next"], value: "next.js" },
2205
+ { packages: ["express"], value: "express" },
2206
+ { packages: ["fastify"], value: "fastify" },
2207
+ { packages: ["@remix-run/node", "@remix-run/react"], value: "remix" },
2208
+ { packages: ["astro"], value: "astro" },
2209
+ { packages: ["nuxt"], value: "nuxt" },
2210
+ { packages: ["@sveltejs/kit"], value: "sveltekit" },
2211
+ { packages: ["hono"], value: "hono" }
2212
+ ];
2213
+ var DATABASE_MATCHERS = [
2214
+ { packages: ["@supabase/supabase-js"], value: "supabase" },
2215
+ { packages: ["@prisma/client", "prisma"], value: "prisma" },
2216
+ { packages: ["drizzle-orm"], value: "drizzle" },
2217
+ { packages: ["mongoose"], value: "mongoose" },
2218
+ { packages: ["typeorm"], value: "typeorm" },
2219
+ { packages: ["sequelize"], value: "sequelize" }
2220
+ ];
2221
+ var AUTH_MATCHERS = [
2222
+ { packages: ["@clerk/nextjs", "@clerk/clerk-sdk-node", "@clerk/express"], value: "clerk" },
2223
+ { packages: ["auth0", "@auth0/nextjs-auth0", "@auth0/auth0-react"], value: "auth0" },
2224
+ { packages: ["next-auth", "@auth/core"], value: "next-auth" },
2225
+ { packages: ["@supabase/auth-helpers-nextjs", "@supabase/auth-helpers-react", "@supabase/ssr"], value: "supabase-auth" },
2226
+ { packages: ["passport"], value: "passport" },
2227
+ { packages: ["lucia"], value: "lucia" }
2228
+ ];
2229
+ function findMatch(matchers, deps) {
2230
+ for (const matcher of matchers) {
2231
+ if (matcher.packages.some((pkg2) => deps.has(pkg2))) {
2232
+ return matcher.value;
2233
+ }
2234
+ }
2235
+ return void 0;
2236
+ }
2237
+ function detectHosting(fileSet) {
2238
+ if (fileSet.has("vercel.json")) return "vercel";
2239
+ if (fileSet.has("netlify.toml")) return "netlify";
2240
+ if (fileSet.has("Dockerfile") || fileSet.has("dockerfile")) return "docker";
2241
+ return void 0;
2242
+ }
2243
+ function detectLanguage(files, hasPackageJson) {
2244
+ if (files.some((f) => f === "tsconfig.json" || f.endsWith("/tsconfig.json"))) {
2245
+ return "typescript";
2246
+ }
2247
+ return hasPackageJson ? "javascript" : "unknown";
2248
+ }
2249
+ function detectPackageManager(fileSet, hasPackageJson) {
2250
+ if (fileSet.has("pnpm-lock.yaml")) return "pnpm";
2251
+ if (fileSet.has("yarn.lock")) return "yarn";
2252
+ if (fileSet.has("bun.lockb") || fileSet.has("bun.lock")) return "bun";
2253
+ if (fileSet.has("package-lock.json")) return "npm";
2254
+ return hasPackageJson ? "npm" : void 0;
2255
+ }
2256
+ function extractDependencies(packageJson) {
2257
+ const deps = packageJson["dependencies"];
2258
+ const devDeps = packageJson["devDependencies"];
2259
+ const names = [];
2260
+ if (deps !== null && typeof deps === "object" && !Array.isArray(deps)) {
2261
+ names.push(...Object.keys(deps));
2262
+ }
2263
+ if (devDeps !== null && typeof devDeps === "object" && !Array.isArray(devDeps)) {
2264
+ names.push(...Object.keys(devDeps));
2265
+ }
2266
+ return names;
2267
+ }
2268
+ function detectStack(packageJson, files) {
2269
+ const hasPackageJson = packageJson !== void 0;
2270
+ const dependencies = hasPackageJson ? extractDependencies(packageJson) : [];
2271
+ const depSet = new Set(dependencies);
2272
+ const fileSet = new Set(files);
2273
+ return {
2274
+ language: detectLanguage(files, hasPackageJson),
2275
+ framework: findMatch(FRAMEWORK_MATCHERS, depSet),
2276
+ database: findMatch(DATABASE_MATCHERS, depSet),
2277
+ auth: findMatch(AUTH_MATCHERS, depSet),
2278
+ hosting: detectHosting(fileSet),
2279
+ packageManager: detectPackageManager(fileSet, hasPackageJson),
2280
+ dependencies
2281
+ };
2282
+ }
2283
+
2284
+ // src/education/prompts.ts
2285
+ function buildStackDescription(stack) {
2286
+ const parts = [];
2287
+ if (stack.framework) {
2288
+ parts.push(stack.framework);
2289
+ } else if (stack.language !== "unknown") {
2290
+ parts.push(`a ${stack.language} project`);
2291
+ }
2292
+ if (stack.database) parts.push(stack.database);
2293
+ if (stack.auth) parts.push(`${stack.auth} for authentication`);
2294
+ if (stack.hosting) parts.push(`deployed on ${stack.hosting}`);
2295
+ if (parts.length === 0) return "";
2296
+ return `I'm using ${parts.join(" with ")}.`;
2297
+ }
2298
+ function locationHint(location) {
2299
+ if (!location) return "";
2300
+ return ` The issue is in \`${location}\`.`;
2301
+ }
2302
+ function getRateLimitPackage(stack) {
2303
+ switch (stack.framework) {
2304
+ case "express":
2305
+ return "express-rate-limit";
2306
+ case "fastify":
2307
+ return "@fastify/rate-limit";
2308
+ case "next.js":
2309
+ return "@upstash/ratelimit";
2310
+ case "hono":
2311
+ return "the built-in hono rate limiter";
2312
+ default:
2313
+ return "rate-limiter-flexible";
2314
+ }
2315
+ }
2316
+ function getRecommendedAuth(stack) {
2317
+ switch (stack.framework) {
2318
+ case "next.js":
2319
+ return stack.database === "supabase" ? "Supabase Auth" : "Clerk";
2320
+ case "express":
2321
+ case "fastify":
2322
+ return "Passport.js with Auth0";
2323
+ case "remix":
2324
+ return "Lucia";
2325
+ default:
2326
+ return "Clerk or Auth0";
2327
+ }
2328
+ }
2329
+ var gitignorePrompt = (result, context) => {
2330
+ const stack = buildStackDescription(context.stack);
2331
+ const fw = context.stack.framework ?? context.stack.language;
2332
+ if (result.id === "gitignore-missing") {
2333
+ return `${stack} My project has no .gitignore file. Generate a comprehensive .gitignore for ${fw} that covers: environment files (.env*), dependencies (node_modules), build outputs (dist, build${context.stack.framework === "next.js" ? ", .next" : ""}), IDE files, OS files (.DS_Store), and cryptographic keys (*.pem, *.key). Show me the complete file.`;
2334
+ }
2335
+ const pattern = result.description.match(/\(([^)]+)\)/)?.[1] ?? result.id.replace("gitignore-", ".");
2336
+ return `${stack} My .gitignore is missing \`${pattern}\`. Show me: (1) the exact line to add to .gitignore, (2) how to remove it from git history if already committed (\`git rm --cached\`), and (3) any related patterns I should also add for a ${fw} project.`;
2337
+ };
2338
+ var secretsPrompt = (result, context) => {
2339
+ const stack = buildStackDescription(context.stack);
2340
+ const loc = locationHint(result.location);
2341
+ const secretType = result.name.replace("Hardcoded secret: ", "");
2342
+ return `${stack}${loc} I found a hardcoded ${secretType} in my source code. Help me fix this by: (1) moving the value to a .env file (gitignored), (2) loading it via process.env in my ${context.stack.framework ?? "Node.js"} code, (3) adding a placeholder to .env.example, and (4) adding a startup check that validates the variable is set. Show me the complete code changes.`;
2343
+ };
2344
+ var depVulnPrompt = (result, context) => {
2345
+ const stack = buildStackDescription(context.stack);
2346
+ return `${stack} ${result.description} Help me fix this by: (1) updating the package safely (\`npm audit fix\` or \`npm install pkg@latest\`), (2) identifying any breaking changes in the new version, and (3) verifying the fix doesn't break my application. If the vulnerability can't be fixed by updating, suggest alternative packages.`;
2347
+ };
2348
+ var envExamplePrompt = (result, context) => {
2349
+ const stack = buildStackDescription(context.stack);
2350
+ const loc = locationHint(result.location);
2351
+ if (result.description.includes("real secret values") || result.description.includes("contain real")) {
2352
+ return `${stack}${loc} My .env.example file contains what appear to be real secret values. Help me replace every real-looking value with a safe descriptive placeholder (e.g. YOUR_API_KEY_HERE). Add a comment above each variable explaining what it's for and where to get the value.`;
2353
+ }
2354
+ const db = context.stack.database ? ` with ${context.stack.database}` : "";
2355
+ const auth = context.stack.auth ? ` and ${context.stack.auth}` : "";
2356
+ return `${stack} My project has a .env file (gitignored) but no .env.example template. Generate a .env.example that lists every required environment variable for a ${context.stack.framework ?? context.stack.language} project${db}${auth}. Use descriptive placeholders and add comments explaining each variable.`;
2357
+ };
2358
+ var securityTxtPrompt = (result, context) => {
2359
+ const stack = buildStackDescription(context.stack);
2360
+ const hosting = context.stack.hosting;
2361
+ if (result.description.includes("expired") || result.description.includes("Expires")) {
2362
+ return `${stack} My security.txt has an expired or missing Expires field. Help me update it with a valid Expires date (one year from today in RFC 3339 format) and verify all required fields (Contact, Expires) are present per RFC 9116.`;
2363
+ }
2364
+ if (result.description.includes("Contact")) {
2365
+ return `${stack} My security.txt is missing the required Contact field (RFC 9116). Generate a complete security.txt with Contact (email or URL), Expires (one year from today), and optional fields (Preferred-Languages, Policy). Show me where to place it (/.well-known/security.txt).`;
2366
+ }
2367
+ return `${stack} My project needs a security.txt file. Generate a valid security.txt per RFC 9116 with Contact, Expires, Preferred-Languages, and Policy fields. Show me where to place it in my ${context.stack.framework ?? "web"} project${hosting ? ` deployed on ${hosting}` : ""}.`;
2368
+ };
2369
+ var headersPrompt = (result, context) => {
2370
+ const stack = buildStackDescription(context.stack);
2371
+ const headerName = result.name.replace("Missing ", "");
2372
+ const framework = context.stack.framework;
2373
+ if (framework === "express" || framework === "fastify" || framework === "hono") {
2374
+ return `${stack} My app is missing the ${headerName} security header. Show me how to add it using ${framework === "express" ? "helmet.js middleware" : `the ${framework} equivalent`}. Include the recommended value and explain what attacks it prevents. Show me the complete middleware configuration.`;
2375
+ }
2376
+ if (framework === "next.js") {
2377
+ return `${stack} My app is missing the ${headerName} security header. Show me how to add it in next.config.js using the headers() function. Include the recommended value and explain what attacks it prevents.`;
2378
+ }
2379
+ return `${stack} My web app is missing the ${headerName} security header. ${result.description} Show me how to configure it with the recommended value for my framework.`;
2380
+ };
2381
+ var sslPrompt = (result, context) => {
2382
+ const stack = buildStackDescription(context.stack);
2383
+ const hosting = context.stack.hosting;
2384
+ const managed = hosting === "vercel" || hosting === "netlify";
2385
+ if (result.id.includes("redirect")) {
2386
+ if (managed) {
2387
+ return `${stack} My site does not redirect HTTP to HTTPS. Since I'm on ${hosting}, this should be automatic. Help me verify the redirect is configured correctly and show how to force HTTPS in my ${context.stack.framework ?? "web"} app.`;
2388
+ }
2389
+ return `${stack} My site does not redirect HTTP to HTTPS. Show me how to configure a permanent 301 redirect from HTTP to HTTPS for my web server (Nginx, Apache, or Node.js). Include HSTS header configuration.`;
2390
+ }
2391
+ if (managed) {
2392
+ return `${stack} ${result.description} Since I'm on ${hosting}, SSL should be handled automatically. Help me verify the certificate is configured correctly and troubleshoot the issue.`;
2393
+ }
2394
+ return `${stack} ${result.description} Help me set up a valid SSL certificate using Let's Encrypt and Certbot. Show me: (1) how to install Certbot, (2) how to obtain a certificate, (3) how to configure automatic renewal, and (4) how to set up HTTPS in my web server.`;
2395
+ };
2396
+ var codePatternsPrompt = (result, context) => {
2397
+ const stack = buildStackDescription(context.stack);
2398
+ const loc = locationHint(result.location);
2399
+ const patternName = result.name.replace("Insecure pattern: ", "");
2400
+ const fw = context.stack.framework ?? "my framework";
2401
+ return `${stack}${loc} I have an insecure code pattern: ${patternName}. ${result.fix ?? result.description} Show me: (1) why this is dangerous with an exploit example, (2) the safe replacement code for ${fw}, and (3) an ESLint rule or equivalent to prevent this in the future.`;
2402
+ };
2403
+ var corsPrompt = (result, context) => {
2404
+ const stack = buildStackDescription(context.stack);
2405
+ const loc = locationHint(result.location);
2406
+ const framework = context.stack.framework ?? "my web application";
2407
+ return `${stack}${loc} ${result.description.split(".")[0]}. Help me fix this by: (1) replacing the wildcard origin with my specific domain(s), (2) showing the correct CORS configuration for ${framework}, (3) handling preflight OPTIONS requests properly, and (4) configuring credentials mode safely. Show me the complete working code.`;
2408
+ };
2409
+ var rateLimitPrompt = (result, context) => {
2410
+ const stack = buildStackDescription(context.stack);
2411
+ const framework = context.stack.framework;
2412
+ const pkg2 = getRateLimitPackage(context.stack);
2413
+ return `${stack} My API routes have no rate limiting. Generate middleware using ${pkg2} that: (1) limits requests to 10 per 15 seconds per IP, (2) returns 429 with Retry-After header, (3) applies to all API routes${framework === "next.js" ? " and works with the App Router" : ""}, and (4) has separate stricter limits for auth endpoints (5 per 15 minutes). Show me the complete working code.`;
2414
+ };
2415
+ var authPrompt = (result, context) => {
2416
+ const stack = buildStackDescription(context.stack);
2417
+ const rec = getRecommendedAuth(context.stack);
2418
+ if (result.status === "warn") {
2419
+ return `${stack} I'm using custom authentication instead of an established provider. Help me migrate to ${rec}. Show me: (1) installation and configuration, (2) protecting all API routes and pages, (3) complete sign-up, login, and logout flows, and (4) session management best practices. Show me the complete working code.`;
2420
+ }
2421
+ return `${stack} My project has no authentication but has user-facing features. Help me add ${rec}. Show me: (1) installation and initial setup, (2) protecting API routes and pages, (3) complete sign-up, login, and logout flows, and (4) secure session management. Show me the complete working code.`;
2422
+ };
2423
+ var GENERATOR_MATCHERS = [
2424
+ { prefix: "security-txt-url", generator: securityTxtPrompt },
2425
+ { prefix: "security-txt", generator: securityTxtPrompt },
2426
+ { prefix: "gitignore", generator: gitignorePrompt },
2427
+ { prefix: "headers", generator: headersPrompt },
2428
+ { prefix: "dep-vuln", generator: depVulnPrompt },
2429
+ { prefix: "env-example", generator: envExamplePrompt },
2430
+ { prefix: "code-patterns", generator: codePatternsPrompt },
2431
+ { prefix: "ssl", generator: sslPrompt },
2432
+ { prefix: "cors", generator: corsPrompt },
2433
+ { prefix: "rate-limit", generator: rateLimitPrompt },
2434
+ { prefix: "secrets", generator: secretsPrompt },
2435
+ { prefix: "auth", generator: authPrompt }
2436
+ ];
2437
+ function findGenerator(checkId) {
2438
+ for (const matcher of GENERATOR_MATCHERS) {
2439
+ if (checkId === matcher.prefix || checkId.startsWith(`${matcher.prefix}-`)) {
2440
+ return matcher.generator;
2441
+ }
2442
+ }
2443
+ return void 0;
2444
+ }
2445
+ function buildGenericPrompt(result, context) {
2446
+ const stack = buildStackDescription(context.stack);
2447
+ const loc = locationHint(result.location);
2448
+ return `${stack}${loc} ${result.description} ${result.fix ? `Recommended fix: ${result.fix} ` : ""}Help me fix this security issue. Show me the complete working code with an explanation.`;
2449
+ }
2450
+ function generatePrompt(result, context) {
2451
+ const generator = findGenerator(result.id);
2452
+ if (generator) return generator(result, context);
2453
+ return buildGenericPrompt(result, context);
2454
+ }
2455
+ function enrichWithAiPrompts(results, context) {
2456
+ return results.map((result) => {
2457
+ if (result.status === "pass" || result.status === "skip") {
2458
+ return result;
2459
+ }
2460
+ return { ...result, aiPrompt: generatePrompt(result, context) };
2461
+ });
2462
+ }
2463
+
2464
+ // src/scanner.ts
2465
+ var EXCLUDED_DIRS = /* @__PURE__ */ new Set([
2466
+ "node_modules",
2467
+ ".git",
2468
+ "dist",
2469
+ ".next",
2470
+ "out",
2471
+ "coverage",
2472
+ "__pycache__",
2473
+ ".venv",
2474
+ "build",
2475
+ ".svn",
2476
+ ".hg",
2477
+ "tests",
2478
+ "__tests__",
2479
+ "test",
2480
+ "fixtures"
2481
+ ]);
2482
+ var SERVER_FRAMEWORKS = /* @__PURE__ */ new Set([
2483
+ "express",
2484
+ "fastify",
2485
+ "next",
2486
+ "nuxt",
2487
+ "remix",
2488
+ "@remix-run/node",
2489
+ "hono",
2490
+ "koa",
2491
+ "@nestjs/core",
2492
+ "@adonisjs/core",
2493
+ "sails",
2494
+ "hapi",
2495
+ "@hapi/hapi"
2496
+ ]);
2497
+ var FULLSTACK_FRAMEWORKS = /* @__PURE__ */ new Set([
2498
+ "next",
2499
+ "nuxt",
2500
+ "remix",
2501
+ "@remix-run/node",
2502
+ "@sveltejs/kit"
2503
+ ]);
2504
+ function getAllDependencyNames(pkgJson) {
2505
+ const deps = pkgJson.dependencies;
2506
+ const devDeps = pkgJson.devDependencies;
2507
+ return [...Object.keys(deps ?? {}), ...Object.keys(devDeps ?? {})];
2508
+ }
2509
+ function hasServerSideFiles(files) {
2510
+ return files.some((f) => {
2511
+ const segments = f.split("/");
2512
+ const name = segments.at(-1) ?? "";
2513
+ if (/^server\.(ts|js|mjs|cjs)$/.test(name)) return true;
2514
+ if (segments.includes("api")) return true;
2515
+ if (segments.includes("routes")) return true;
2516
+ return false;
2517
+ });
2518
+ }
2519
+ function detectProjectType(packageJson, files) {
2520
+ const hasCodeFiles = files.some((f) => CODE_EXTENSIONS2.test(f));
2521
+ const hasServerFiles = hasServerSideFiles(files);
2522
+ if (!packageJson) {
2523
+ if (!hasCodeFiles && !hasServerFiles) return "static";
2524
+ return hasServerFiles ? "api" : "unknown";
2525
+ }
2526
+ const allDeps = getAllDependencyNames(packageJson);
2527
+ const hasServerDep = allDeps.some((d) => SERVER_FRAMEWORKS.has(d));
2528
+ if (!hasServerDep && !hasServerFiles) {
2529
+ return "static";
2530
+ }
2531
+ if (allDeps.some((d) => FULLSTACK_FRAMEWORKS.has(d))) {
2532
+ return "fullstack";
2533
+ }
2534
+ const hasFrontendFiles = files.some(
2535
+ (f) => f.endsWith(".tsx") || f.endsWith(".jsx") || f.endsWith(".vue") || f.endsWith(".svelte")
2536
+ );
2537
+ return hasFrontendFiles ? "fullstack" : "api";
2538
+ }
2539
+ async function buildContext(options) {
2540
+ const projectPath = resolve(options.path);
2541
+ const info = await stat(projectPath).catch(() => null);
2542
+ if (!info?.isDirectory()) {
2543
+ throw new Error(`Not a directory: ${projectPath}`);
2544
+ }
2545
+ let packageJson;
2546
+ try {
2547
+ const raw = await readFile9(join10(projectPath, "package.json"), "utf-8");
2548
+ packageJson = JSON.parse(raw);
2549
+ } catch {
2550
+ }
2551
+ const entries = await readdir(projectPath, { recursive: true, withFileTypes: true });
2552
+ const files = entries.filter((e) => e.isFile()).map((e) => relative(projectPath, join10(e.parentPath, e.name))).filter((f) => !f.split("/").some((segment) => EXCLUDED_DIRS.has(segment)));
2553
+ const stack = detectStack(packageJson, files);
2554
+ const typeOverride = options.type ?? "auto";
2555
+ const projectType = typeOverride === "auto" ? detectProjectType(packageJson, files) : typeOverride;
2556
+ const projectTypeSource = typeOverride === "auto" ? "auto" : "manual";
2557
+ return {
2558
+ projectPath,
2559
+ url: options.url,
2560
+ stack,
2561
+ packageJson,
2562
+ files,
2563
+ verbose: options.verbose,
2564
+ projectType,
2565
+ projectTypeSource
2566
+ };
2567
+ }
2568
+ async function runChecks(context, checks) {
2569
+ const start = Date.now();
2570
+ const settled = await Promise.allSettled(checks.map((check) => check(context)));
2571
+ const results = [];
2572
+ for (const outcome of settled) {
2573
+ if (outcome.status === "fulfilled") {
2574
+ results.push(...outcome.value);
2575
+ } else {
2576
+ results.push({
2577
+ id: "error",
2578
+ name: "Check error",
2579
+ status: "skip",
2580
+ severity: "info",
2581
+ description: `Check threw: ${outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason)}`
2582
+ });
2583
+ }
2584
+ }
2585
+ const enrichedResults = enrichWithAiPrompts(results, context);
2586
+ return {
2587
+ results: enrichedResults,
2588
+ score: calculateScore(enrichedResults),
2589
+ summary: summarizeResults(enrichedResults),
2590
+ duration: Date.now() - start
2591
+ };
2592
+ }
2593
+ var PROJECT_INDICATORS = ["package.json", ".gitignore", ".git", "src", "lib"];
2594
+ var CODE_EXTENSIONS2 = /\.(ts|js|tsx|jsx)$/;
2595
+ function hasProjectFiles(files, projectPath, packageJson) {
2596
+ if (packageJson) return true;
2597
+ for (const f of files) {
2598
+ const first = f.split("/")[0];
2599
+ if (PROJECT_INDICATORS.includes(first)) return true;
2600
+ if (CODE_EXTENSIONS2.test(f)) return true;
2601
+ }
2602
+ return false;
2603
+ }
2604
+ async function scan(context) {
2605
+ const isUrlOnly = context.url && !hasProjectFiles(context.files, context.projectPath, context.packageJson);
2606
+ if (isUrlOnly) {
2607
+ const report2 = await runChecks(context, getUrlOnlyChecks());
2608
+ return { ...report2, urlOnly: true, projectType: context.projectType, projectTypeSource: context.projectTypeSource };
2609
+ }
2610
+ const allChecks = getAllChecks();
2611
+ if (context.projectType === "static") {
2612
+ const skipInfos = getStaticSiteSkippableChecks();
2613
+ const hasEnvFile = context.files.some((f) => f === ".env");
2614
+ const skipFns = new Set(
2615
+ skipInfos.filter((s) => s.id === "env-example" ? !hasEnvFile : true).map((s) => s.fn)
2616
+ );
2617
+ const checksToRun = allChecks.filter((c) => !skipFns.has(c));
2618
+ const notApplicableResults = skipInfos.filter((s) => skipFns.has(s.fn)).map((s) => ({
2619
+ id: s.id,
2620
+ name: s.name,
2621
+ status: "not-applicable",
2622
+ severity: "info",
2623
+ category: "static-site",
2624
+ description: "Not applicable \u2014 static site has no server-side code"
2625
+ }));
2626
+ const report2 = await runChecks(context, checksToRun);
2627
+ return {
2628
+ ...report2,
2629
+ results: [...report2.results, ...notApplicableResults],
2630
+ summary: summarizeResults([...report2.results, ...notApplicableResults]),
2631
+ projectType: context.projectType,
2632
+ projectTypeSource: context.projectTypeSource
2633
+ };
2634
+ }
2635
+ const report = await runChecks(context, allChecks);
2636
+ return { ...report, projectType: context.projectType, projectTypeSource: context.projectTypeSource };
2637
+ }
2638
+ function calculateScore(results) {
2639
+ const ran = results.filter((r) => r.status !== "skip" && r.status !== "not-applicable");
2640
+ if (ran.length === 0) return 100;
2641
+ const passed = ran.filter((r) => r.status !== "fail").length;
2642
+ return Math.max(0, Math.round(passed / ran.length * 100));
2643
+ }
2644
+ function summarizeResults(results) {
2645
+ let pass = 0;
2646
+ let fail = 0;
2647
+ let warn = 0;
2648
+ let skip = 0;
2649
+ let notApplicable = 0;
2650
+ for (const r of results) {
2651
+ if (r.status === "pass") pass++;
2652
+ else if (r.status === "fail") fail++;
2653
+ else if (r.status === "warn") warn++;
2654
+ else if (r.status === "not-applicable") notApplicable++;
2655
+ else skip++;
2656
+ }
2657
+ return { pass, fail, warn, skip, notApplicable, checksRun: pass + fail + warn, total: results.length };
2658
+ }
2659
+
2660
+ // src/reporters/terminal.ts
2661
+ import chalk from "chalk";
2662
+ function resultIcon(result) {
2663
+ if (result.status === "pass") return chalk.green("\u2713");
2664
+ if (result.status === "skip") return chalk.dim("\u2013");
2665
+ if (result.status === "not-applicable") return chalk.dim("\u25CB");
2666
+ if (result.status === "warn") return chalk.yellow("\u26A0");
2667
+ switch (result.severity) {
2668
+ case "critical":
2669
+ return chalk.red("\u2715");
2670
+ case "high":
2671
+ return chalk.yellow("\u26A0");
2672
+ case "medium":
2673
+ return chalk.blue("\u25CF");
2674
+ case "low":
2675
+ return chalk.dim("\u25CB");
2676
+ case "info":
2677
+ return chalk.dim("\xB7");
2678
+ }
2679
+ }
2680
+ function formatScore(score) {
2681
+ const text = `${score}/100`;
2682
+ if (score >= 80) return chalk.green(text);
2683
+ if (score >= 50) return chalk.yellow(text);
2684
+ return chalk.red(text);
2685
+ }
2686
+ function groupByCategory(results) {
2687
+ const groups = /* @__PURE__ */ new Map();
2688
+ for (const r of results) {
2689
+ const key = r.category ?? "General";
2690
+ const list = groups.get(key);
2691
+ if (list) {
2692
+ list.push(r);
2693
+ } else {
2694
+ groups.set(key, [r]);
2695
+ }
2696
+ }
2697
+ return groups;
2698
+ }
2699
+ function formatTerminalReport(report, verbose) {
2700
+ if (report.results.length === 0) {
2701
+ return [
2702
+ "",
2703
+ ` ${chalk.dim("No security checks were run. Add checks to get started.")}`,
2704
+ ""
2705
+ ].join("\n");
2706
+ }
2707
+ const lines = [];
2708
+ const grouped = groupByCategory(report.results);
2709
+ for (const [category, results] of grouped) {
2710
+ lines.push("");
2711
+ lines.push(` ${chalk.bold.underline(category)}`);
2712
+ lines.push("");
2713
+ for (const r of results) {
2714
+ if (r.status === "not-applicable") {
2715
+ lines.push(` ${resultIcon(r)} ${chalk.dim(`${r.name} \u2014 not applicable (static site)`)}`);
2716
+ continue;
2717
+ }
2718
+ const loc = r.location ? chalk.dim(` ${r.location}`) : "";
2719
+ lines.push(` ${resultIcon(r)} ${r.name}${loc}`);
2720
+ lines.push(` ${chalk.dim(r.description)}`);
2721
+ if (verbose && r.fix) {
2722
+ lines.push(` ${chalk.cyan("Fix:")} ${r.fix}`);
2723
+ }
2724
+ if (verbose && r.aiPrompt) {
2725
+ lines.push(` ${chalk.magenta("AI:")} ${r.aiPrompt}`);
2726
+ }
2727
+ }
2728
+ }
2729
+ const { pass, fail, warn, skip, notApplicable, checksRun, total } = report.summary;
2730
+ lines.push("");
2731
+ const parts = [`${pass} passed`, `${fail} failed`, `${warn} warnings`, `${skip} skipped`];
2732
+ if (notApplicable > 0) {
2733
+ parts.push(`${notApplicable} N/A`);
2734
+ }
2735
+ parts.push(`Score: ${formatScore(report.score)} (based on ${checksRun} of ${total} checks)`);
2736
+ lines.push(` ${parts.join(" \xB7 ")}`);
2737
+ if (skip > 0 && skip > total / 2) {
2738
+ lines.push(
2739
+ ` ${chalk.yellow("\u26A0")} Score may not be representative \u2014 ${skip} checks could not run. Pass --url to enable HTTP checks.`
2740
+ );
2741
+ }
2742
+ lines.push("");
2743
+ return lines.join("\n");
2744
+ }
2745
+
2746
+ // src/reporters/json.ts
2747
+ function mapResult(result) {
2748
+ return {
2749
+ id: result.id,
2750
+ title: result.name,
2751
+ severity: result.severity,
2752
+ status: result.status,
2753
+ category: result.category ?? "General",
2754
+ location: result.location ?? null,
2755
+ description: result.description,
2756
+ fix: result.fix ?? null,
2757
+ aiPrompt: result.aiPrompt ?? null
2758
+ };
2759
+ }
2760
+ function formatJsonReport(report, metadata) {
2761
+ return JSON.stringify(
2762
+ {
2763
+ score: report.score,
2764
+ summary: report.summary,
2765
+ results: report.results.map(mapResult),
2766
+ metadata
2767
+ },
2768
+ null,
2769
+ 2
2770
+ );
2771
+ }
2772
+
2773
+ // src/generators/config.ts
2774
+ import { writeFile, mkdir } from "fs/promises";
2775
+ import { join as join11 } from "path";
2776
+ function expressHelmetConfig() {
2777
+ return {
2778
+ name: "Helmet.js Security Headers",
2779
+ filename: "helmet-setup.js",
2780
+ language: "javascript",
2781
+ description: "Express middleware that sets security-related HTTP headers via helmet.js",
2782
+ code: `// helmet-setup.js \u2014 Security headers for Express
2783
+ // Install: npm install helmet
2784
+ import helmet from 'helmet';
2785
+
2786
+ /**
2787
+ * Configure helmet.js with recommended security headers.
2788
+ * Add this BEFORE your route definitions.
2789
+ *
2790
+ * Usage:
2791
+ * import { securityHeaders } from './helmet-setup.js';
2792
+ * app.use(securityHeaders);
2793
+ */
2794
+ export const securityHeaders = helmet({
2795
+ // Content-Security-Policy: restricts resource loading sources
2796
+ contentSecurityPolicy: {
2797
+ directives: {
2798
+ defaultSrc: ["'self'"],
2799
+ scriptSrc: ["'self'"],
2800
+ styleSrc: ["'self'", "'unsafe-inline'"],
2801
+ imgSrc: ["'self'", 'data:', 'https:'],
2802
+ connectSrc: ["'self'"],
2803
+ fontSrc: ["'self'"],
2804
+ objectSrc: ["'none'"],
2805
+ frameAncestors: ["'none'"],
2806
+ upgradeInsecureRequests: [],
2807
+ },
2808
+ },
2809
+ // Strict-Transport-Security: force HTTPS
2810
+ hsts: {
2811
+ maxAge: 31536000, // 1 year
2812
+ includeSubDomains: true,
2813
+ preload: true,
2814
+ },
2815
+ // X-Content-Type-Options: prevent MIME sniffing
2816
+ noSniff: true,
2817
+ // X-Frame-Options: prevent clickjacking
2818
+ frameguard: { action: 'deny' },
2819
+ // Referrer-Policy: control referrer information
2820
+ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
2821
+ // Permissions-Policy: restrict browser features
2822
+ permittedCrossDomainPolicies: { permittedPolicies: 'none' },
2823
+ });`
2824
+ };
2825
+ }
2826
+ function expressCorsConfig() {
2827
+ return {
2828
+ name: "CORS Configuration",
2829
+ filename: "cors-setup.js",
2830
+ language: "javascript",
2831
+ description: "Express CORS middleware with secure defaults \u2014 never use wildcard origins in production",
2832
+ code: `// cors-setup.js \u2014 Secure CORS configuration for Express
2833
+ // Install: npm install cors
2834
+ import cors from 'cors';
2835
+
2836
+ /**
2837
+ * Configure CORS with explicit allowed origins.
2838
+ * NEVER use origin: '*' or origin: true in production.
2839
+ *
2840
+ * Usage:
2841
+ * import { corsMiddleware } from './cors-setup.js';
2842
+ * app.use(corsMiddleware);
2843
+ */
2844
+ const ALLOWED_ORIGINS = [
2845
+ process.env.FRONTEND_URL || 'http://localhost:3000',
2846
+ // Add your production domain(s) here:
2847
+ // 'https://yourdomain.com',
2848
+ ];
2849
+
2850
+ export const corsMiddleware = cors({
2851
+ origin: (origin, callback) => {
2852
+ // Allow requests with no origin (server-to-server, curl, etc.)
2853
+ if (!origin || ALLOWED_ORIGINS.includes(origin)) {
2854
+ callback(null, true);
2855
+ } else {
2856
+ callback(new Error('Not allowed by CORS'));
2857
+ }
2858
+ },
2859
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
2860
+ allowedHeaders: ['Content-Type', 'Authorization'],
2861
+ credentials: true,
2862
+ maxAge: 86400, // 24 hours \u2014 browsers cache preflight responses
2863
+ });`
2864
+ };
2865
+ }
2866
+ function expressRateLimitConfig() {
2867
+ return {
2868
+ name: "Rate Limiter Setup",
2869
+ filename: "rate-limit-setup.js",
2870
+ language: "javascript",
2871
+ description: "Express rate limiting middleware to prevent brute-force and DDoS attacks",
2872
+ code: `// rate-limit-setup.js \u2014 Rate limiting for Express
2873
+ // Install: npm install express-rate-limit
2874
+ import rateLimit from 'express-rate-limit';
2875
+
2876
+ /**
2877
+ * General API rate limiter \u2014 100 requests per 15 minutes per IP.
2878
+ *
2879
+ * Usage:
2880
+ * import { apiLimiter, authLimiter } from './rate-limit-setup.js';
2881
+ * app.use('/api/', apiLimiter);
2882
+ * app.use('/api/auth/', authLimiter);
2883
+ */
2884
+ export const apiLimiter = rateLimit({
2885
+ windowMs: 15 * 60 * 1000, // 15 minutes
2886
+ max: 100, // limit each IP to 100 requests per window
2887
+ standardHeaders: true, // Return rate limit info in \`RateLimit-*\` headers
2888
+ legacyHeaders: false, // Disable \`X-RateLimit-*\` headers
2889
+ message: { error: 'Too many requests, please try again later.' },
2890
+ });
2891
+
2892
+ /**
2893
+ * Stricter limiter for auth endpoints \u2014 5 requests per 15 minutes per IP.
2894
+ * Prevents brute-force login/signup attacks.
2895
+ */
2896
+ export const authLimiter = rateLimit({
2897
+ windowMs: 15 * 60 * 1000, // 15 minutes
2898
+ max: 5,
2899
+ standardHeaders: true,
2900
+ legacyHeaders: false,
2901
+ message: { error: 'Too many auth attempts, please try again later.' },
2902
+ });`
2903
+ };
2904
+ }
2905
+ function nextSecurityHeadersConfig() {
2906
+ return {
2907
+ name: "Next.js Security Headers",
2908
+ filename: "next-security-headers.js",
2909
+ language: "javascript",
2910
+ description: "Security headers configuration for next.config.js \u2014 add to your existing config",
2911
+ code: `// next-security-headers.js \u2014 Security headers for Next.js
2912
+ // Copy the headers() function into your next.config.js
2913
+
2914
+ /**
2915
+ * Recommended security headers for Next.js.
2916
+ *
2917
+ * Usage in next.config.js:
2918
+ * import { securityHeaders } from './next-security-headers.js';
2919
+ * export default {
2920
+ * async headers() {
2921
+ * return [{ source: '/(.*)', headers: securityHeaders }];
2922
+ * },
2923
+ * };
2924
+ */
2925
+ export const securityHeaders = [
2926
+ {
2927
+ key: 'Content-Security-Policy',
2928
+ value: [
2929
+ "default-src 'self'",
2930
+ "script-src 'self' 'unsafe-eval' 'unsafe-inline'",
2931
+ "style-src 'self' 'unsafe-inline'",
2932
+ "img-src 'self' data: https:",
2933
+ "connect-src 'self'",
2934
+ "font-src 'self'",
2935
+ "object-src 'none'",
2936
+ "frame-ancestors 'none'",
2937
+ "upgrade-insecure-requests",
2938
+ ].join('; '),
2939
+ },
2940
+ {
2941
+ key: 'Strict-Transport-Security',
2942
+ value: 'max-age=31536000; includeSubDomains; preload',
2943
+ },
2944
+ {
2945
+ key: 'X-Content-Type-Options',
2946
+ value: 'nosniff',
2947
+ },
2948
+ {
2949
+ key: 'X-Frame-Options',
2950
+ value: 'DENY',
2951
+ },
2952
+ {
2953
+ key: 'Referrer-Policy',
2954
+ value: 'strict-origin-when-cross-origin',
2955
+ },
2956
+ {
2957
+ key: 'Permissions-Policy',
2958
+ value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
2959
+ },
2960
+ ];`
2961
+ };
2962
+ }
2963
+ function nextRateLimitMiddlewareConfig() {
2964
+ return {
2965
+ name: "Next.js Rate Limiting Middleware",
2966
+ filename: "middleware-rate-limit.ts",
2967
+ language: "typescript",
2968
+ description: "Edge-compatible rate limiting middleware for Next.js App Router using in-memory store",
2969
+ code: `// middleware-rate-limit.ts \u2014 Rate limiting for Next.js middleware
2970
+ // For production, consider @upstash/ratelimit with Redis for distributed rate limiting.
2971
+ // This in-memory implementation works for single-instance deployments.
2972
+
2973
+ import { NextResponse } from 'next/server';
2974
+ import type { NextRequest } from 'next/server';
2975
+
2976
+ /** Rate limit window configuration */
2977
+ const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
2978
+ const MAX_REQUESTS = 100; // requests per window
2979
+ const AUTH_MAX_REQUESTS = 5; // stricter limit for auth routes
2980
+
2981
+ interface RateLimitEntry {
2982
+ readonly count: number;
2983
+ readonly resetTime: number;
2984
+ }
2985
+
2986
+ /** In-memory store \u2014 use Redis (@upstash/ratelimit) in production */
2987
+ const store = new Map<string, RateLimitEntry>();
2988
+
2989
+ function getClientIp(request: NextRequest): string {
2990
+ return (
2991
+ request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
2992
+ request.headers.get('x-real-ip') ??
2993
+ 'unknown'
2994
+ );
2995
+ }
2996
+
2997
+ function isRateLimited(key: string, max: number): boolean {
2998
+ const now = Date.now();
2999
+ const entry = store.get(key);
3000
+
3001
+ if (!entry || now > entry.resetTime) {
3002
+ store.set(key, { count: 1, resetTime: now + WINDOW_MS });
3003
+ return false;
3004
+ }
3005
+
3006
+ if (entry.count >= max) {
3007
+ return true;
3008
+ }
3009
+
3010
+ store.set(key, { ...entry, count: entry.count + 1 });
3011
+ return false;
3012
+ }
3013
+
3014
+ /**
3015
+ * Next.js middleware with rate limiting.
3016
+ *
3017
+ * Usage: Save as middleware.ts in your project root.
3018
+ */
3019
+ export function middleware(request: NextRequest): NextResponse {
3020
+ const ip = getClientIp(request);
3021
+ const isAuthRoute = request.nextUrl.pathname.startsWith('/api/auth');
3022
+ const max = isAuthRoute ? AUTH_MAX_REQUESTS : MAX_REQUESTS;
3023
+ const key = \`\${ip}:\${isAuthRoute ? 'auth' : 'api'}\`;
3024
+
3025
+ if (isRateLimited(key, max)) {
3026
+ return NextResponse.json(
3027
+ { error: 'Too many requests, please try again later.' },
3028
+ { status: 429, headers: { 'Retry-After': String(Math.ceil(WINDOW_MS / 1000)) } },
3029
+ );
3030
+ }
3031
+
3032
+ return NextResponse.next();
3033
+ }
3034
+
3035
+ export const config = {
3036
+ matcher: '/api/:path*',
3037
+ };`
3038
+ };
3039
+ }
3040
+ function fastifyHelmetConfig() {
3041
+ return {
3042
+ name: "Fastify Helmet Plugin",
3043
+ filename: "fastify-helmet-setup.js",
3044
+ language: "javascript",
3045
+ description: "Fastify security headers plugin using @fastify/helmet",
3046
+ code: `// fastify-helmet-setup.js \u2014 Security headers for Fastify
3047
+ // Install: npm install @fastify/helmet
3048
+ import helmet from '@fastify/helmet';
3049
+
3050
+ /**
3051
+ * Register @fastify/helmet with recommended security headers.
3052
+ *
3053
+ * Usage:
3054
+ * import { registerHelmet } from './fastify-helmet-setup.js';
3055
+ * await registerHelmet(fastify);
3056
+ */
3057
+ export async function registerHelmet(fastify) {
3058
+ await fastify.register(helmet, {
3059
+ contentSecurityPolicy: {
3060
+ directives: {
3061
+ defaultSrc: ["'self'"],
3062
+ scriptSrc: ["'self'"],
3063
+ styleSrc: ["'self'", "'unsafe-inline'"],
3064
+ imgSrc: ["'self'", 'data:', 'https:'],
3065
+ connectSrc: ["'self'"],
3066
+ fontSrc: ["'self'"],
3067
+ objectSrc: ["'none'"],
3068
+ frameAncestors: ["'none'"],
3069
+ upgradeInsecureRequests: [],
3070
+ },
3071
+ },
3072
+ hsts: {
3073
+ maxAge: 31536000,
3074
+ includeSubDomains: true,
3075
+ preload: true,
3076
+ },
3077
+ });
3078
+ }`
3079
+ };
3080
+ }
3081
+ function fastifyCorsConfig() {
3082
+ return {
3083
+ name: "Fastify CORS Plugin",
3084
+ filename: "fastify-cors-setup.js",
3085
+ language: "javascript",
3086
+ description: "Fastify CORS plugin with secure defaults \u2014 never use wildcard origins in production",
3087
+ code: `// fastify-cors-setup.js \u2014 Secure CORS configuration for Fastify
3088
+ // Install: npm install @fastify/cors
3089
+ import cors from '@fastify/cors';
3090
+
3091
+ /**
3092
+ * Register @fastify/cors with explicit allowed origins.
3093
+ *
3094
+ * Usage:
3095
+ * import { registerCors } from './fastify-cors-setup.js';
3096
+ * await registerCors(fastify);
3097
+ */
3098
+ const ALLOWED_ORIGINS = [
3099
+ process.env.FRONTEND_URL || 'http://localhost:3000',
3100
+ // Add your production domain(s) here:
3101
+ // 'https://yourdomain.com',
3102
+ ];
3103
+
3104
+ export async function registerCors(fastify) {
3105
+ await fastify.register(cors, {
3106
+ origin: (origin, callback) => {
3107
+ if (!origin || ALLOWED_ORIGINS.includes(origin)) {
3108
+ callback(null, true);
3109
+ } else {
3110
+ callback(new Error('Not allowed by CORS'), false);
3111
+ }
3112
+ },
3113
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
3114
+ allowedHeaders: ['Content-Type', 'Authorization'],
3115
+ credentials: true,
3116
+ maxAge: 86400,
3117
+ });
3118
+ }`
3119
+ };
3120
+ }
3121
+ function fastifyRateLimitConfig() {
3122
+ return {
3123
+ name: "Fastify Rate Limit Plugin",
3124
+ filename: "fastify-rate-limit-setup.js",
3125
+ language: "javascript",
3126
+ description: "Fastify rate limiting plugin to prevent brute-force and DDoS attacks",
3127
+ code: `// fastify-rate-limit-setup.js \u2014 Rate limiting for Fastify
3128
+ // Install: npm install @fastify/rate-limit
3129
+ import rateLimit from '@fastify/rate-limit';
3130
+
3131
+ /**
3132
+ * Register @fastify/rate-limit with default and route-specific limits.
3133
+ *
3134
+ * Usage:
3135
+ * import { registerRateLimit } from './fastify-rate-limit-setup.js';
3136
+ * await registerRateLimit(fastify);
3137
+ *
3138
+ * // Stricter limit on a specific route:
3139
+ * fastify.post('/login', { config: { rateLimit: { max: 5, timeWindow: '15 minutes' } } }, handler);
3140
+ */
3141
+ export async function registerRateLimit(fastify) {
3142
+ await fastify.register(rateLimit, {
3143
+ global: true,
3144
+ max: 100, // 100 requests per window per IP
3145
+ timeWindow: '15 minutes',
3146
+ addHeaders: {
3147
+ 'x-ratelimit-limit': true,
3148
+ 'x-ratelimit-remaining': true,
3149
+ 'x-ratelimit-reset': true,
3150
+ 'retry-after': true,
3151
+ },
3152
+ errorResponseBuilder: () => ({
3153
+ statusCode: 429,
3154
+ error: 'Too Many Requests',
3155
+ message: 'Too many requests, please try again later.',
3156
+ }),
3157
+ });
3158
+ }`
3159
+ };
3160
+ }
3161
+ function gitignoreConfig() {
3162
+ return {
3163
+ name: ".gitignore Security Additions",
3164
+ filename: ".gitignore-additions",
3165
+ language: "gitignore",
3166
+ description: "Essential .gitignore patterns to prevent committing secrets, keys, and build artifacts",
3167
+ code: `# === Security-critical patterns (add to your .gitignore) ===
3168
+
3169
+ # Environment files \u2014 may contain secrets
3170
+ .env
3171
+ .env.local
3172
+ .env.*.local
3173
+ .env.production
3174
+
3175
+ # Cryptographic keys
3176
+ *.pem
3177
+ *.key
3178
+ *.crt
3179
+ *.p12
3180
+ *.pfx
3181
+
3182
+ # Dependency directories
3183
+ node_modules/
3184
+
3185
+ # Build outputs
3186
+ dist/
3187
+ build/
3188
+ .next/
3189
+ out/
3190
+
3191
+ # IDE and OS files
3192
+ .vscode/
3193
+ .idea/
3194
+ .DS_Store
3195
+ Thumbs.db
3196
+
3197
+ # Log files
3198
+ *.log
3199
+ npm-debug.log*
3200
+
3201
+ # Coverage and test reports
3202
+ coverage/
3203
+ .nyc_output/`
3204
+ };
3205
+ }
3206
+ function envExampleConfig() {
3207
+ return {
3208
+ name: ".env.example Template",
3209
+ filename: ".env.example",
3210
+ language: "shell",
3211
+ description: "Environment variable template with safe placeholders \u2014 commit this file, never .env",
3212
+ code: `# .env.example \u2014 Template for required environment variables
3213
+ # Copy this file to .env and fill in real values. NEVER commit .env.
3214
+
3215
+ # \u2500\u2500\u2500 Application \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3216
+ NODE_ENV=development
3217
+ PORT=3000
3218
+
3219
+ # \u2500\u2500\u2500 Database \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3220
+ # DATABASE_URL=postgresql://user:password@localhost:5432/mydb
3221
+
3222
+ # \u2500\u2500\u2500 Authentication \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3223
+ # AUTH_SECRET=generate-a-random-32-char-string-here
3224
+ # NEXTAUTH_URL=http://localhost:3000
3225
+
3226
+ # \u2500\u2500\u2500 Third-Party APIs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3227
+ # OPENAI_API_KEY=sk-your-key-here
3228
+ # STRIPE_SECRET_KEY=sk_test_your-key-here
3229
+ # STRIPE_WEBHOOK_SECRET=whsec_your-secret-here
3230
+
3231
+ # \u2500\u2500\u2500 CORS \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3232
+ FRONTEND_URL=http://localhost:3000`
3233
+ };
3234
+ }
3235
+ var FRAMEWORK_GENERATORS = [
3236
+ {
3237
+ framework: "express",
3238
+ generators: [expressHelmetConfig, expressCorsConfig, expressRateLimitConfig]
3239
+ },
3240
+ {
3241
+ framework: "next.js",
3242
+ generators: [nextSecurityHeadersConfig, nextRateLimitMiddlewareConfig]
3243
+ },
3244
+ {
3245
+ framework: "fastify",
3246
+ generators: [fastifyHelmetConfig, fastifyCorsConfig, fastifyRateLimitConfig]
3247
+ }
3248
+ ];
3249
+ var GENERIC_GENERATORS = [
3250
+ gitignoreConfig,
3251
+ envExampleConfig
3252
+ ];
3253
+ function generateConfigs(stack) {
3254
+ const snippets = [];
3255
+ if (stack.framework) {
3256
+ const match = FRAMEWORK_GENERATORS.find(
3257
+ (fg) => fg.framework === stack.framework
3258
+ );
3259
+ if (match) {
3260
+ for (const gen of match.generators) {
3261
+ snippets.push(gen());
3262
+ }
3263
+ }
3264
+ }
3265
+ for (const gen of GENERIC_GENERATORS) {
3266
+ snippets.push(gen());
3267
+ }
3268
+ return snippets;
3269
+ }
3270
+ function formatConfigSnippet(snippet) {
3271
+ const lines = [];
3272
+ lines.push(`\u2500\u2500 ${snippet.name} \u2500\u2500`);
3273
+ lines.push(snippet.description);
3274
+ lines.push(`File: ${snippet.filename}`);
3275
+ lines.push("");
3276
+ lines.push(`\`\`\`${snippet.language}`);
3277
+ lines.push(snippet.code);
3278
+ lines.push("```");
3279
+ return lines.join("\n");
3280
+ }
3281
+ function formatConfigOutput(snippets) {
3282
+ if (snippets.length === 0) {
3283
+ return "No configuration snippets to generate.";
3284
+ }
3285
+ const sections = snippets.map(formatConfigSnippet);
3286
+ return `
3287
+ Generated Security Configs
3288
+ ${snippets.length} snippet${snippets.length === 1 ? "" : "s"} for your stack
3289
+
3290
+ ` + sections.join("\n\n") + "\n";
3291
+ }
3292
+ async function writeConfigFiles(snippets, outputDir) {
3293
+ await mkdir(outputDir, { recursive: true });
3294
+ const paths = [];
3295
+ for (const snippet of snippets) {
3296
+ const filePath = join11(outputDir, snippet.filename);
3297
+ await writeFile(filePath, snippet.code + "\n", "utf-8");
3298
+ paths.push(filePath);
3299
+ }
3300
+ return paths;
3301
+ }
3302
+
3303
+ // src/cli.ts
3304
+ function createProgram(version) {
3305
+ const program = new Command();
3306
+ program.name("bastion").description("Privacy-first security checker for AI-era builders").version(version);
3307
+ program.command("scan").description("Scan a project for security issues").option("-p, --path <dir>", "path to project directory", ".").option("-f, --format <type>", "output format (terminal, json, markdown)", "terminal").option("-v, --verbose", "show detailed output", false).option("-u, --url <url>", "URL for HTTP-based checks").option("-o, --output <file>", "output file path (for markdown/json formats)").option("--generate-configs", "generate security config snippets for detected stack", false).option("--output-dir <dir>", "write generated config files to directory").addOption(
3308
+ new Option("-t, --type <type>", "project type override").choices(["auto", "static", "api", "fullstack"]).default("auto")
3309
+ ).action(async (options) => {
3310
+ await runScan(options, version);
3311
+ });
3312
+ const generate = program.command("generate").description("Generate security configuration files");
3313
+ generate.command("security-txt").description("Create a valid security.txt file (RFC 9116)").option("-c, --contact <value>", "contact email or URL (enables non-interactive mode)").option("-e, --expires <date>", "expires date in ISO 8601 (default: 1 year from now)").option("-l, --languages <langs>", "preferred languages (default: en)").option("--policy <url>", "policy URL").option("--acknowledgments <url>", "acknowledgments URL").option("-p, --path <dir>", "project directory", ".").action(async (options) => {
3314
+ await runSecurityTxtGenerator(options);
3315
+ });
3316
+ return program;
3317
+ }
3318
+ async function runScan(options, version) {
3319
+ const isJson = options.format === "json";
3320
+ if (!isJson) {
3321
+ printBanner(version);
3322
+ }
3323
+ if (!isValidFormat(options.format)) {
3324
+ console.error(
3325
+ chalk2.red(`
3326
+ Error: Invalid format "${options.format}". Use: ${OUTPUT_FORMATS.join(", ")}`)
3327
+ );
3328
+ process.exitCode = 1;
3329
+ return;
3330
+ }
3331
+ if (!isJson && options.verbose) {
3332
+ const { resolve: resolve2 } = await import("path");
3333
+ console.log(chalk2.dim(` Path: ${resolve2(options.path)}`));
3334
+ console.log(chalk2.dim(` Format: ${options.format}`));
3335
+ if (options.url) {
3336
+ console.log(chalk2.dim(` URL: ${options.url}`));
3337
+ }
3338
+ if (options.type !== "auto") {
3339
+ console.log(chalk2.dim(` Type: ${options.type} (manual override)`));
3340
+ }
3341
+ console.log();
3342
+ }
3343
+ const spinner = isJson ? null : ora({ text: "Scanning...", indent: 2 }).start();
3344
+ try {
3345
+ const context = await buildContext({
3346
+ path: options.path,
3347
+ url: options.url,
3348
+ verbose: options.verbose,
3349
+ type: options.type
3350
+ });
3351
+ const report = await scan(context);
3352
+ if (isJson) {
3353
+ const metadata = {
3354
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3355
+ version,
3356
+ projectPath: context.projectPath,
3357
+ detectedStack: context.stack,
3358
+ projectType: report.projectType,
3359
+ projectTypeSource: report.projectTypeSource
3360
+ };
3361
+ console.log(formatJsonReport(report, metadata));
3362
+ } else {
3363
+ spinner?.succeed(`Scan complete (${report.duration}ms)`);
3364
+ if (report.urlOnly) {
3365
+ console.log(chalk2.yellow("\n URL-only scan \u2014 6 HTTP checks performed."));
3366
+ console.log(chalk2.dim(" Point --path at your source code for a full 15-check audit.\n"));
3367
+ }
3368
+ if (report.projectType && report.projectType !== "unknown") {
3369
+ const source = report.projectTypeSource === "manual" ? "manual" : "auto-detected";
3370
+ console.log(chalk2.dim(`
3371
+ Project type: ${report.projectType} (${source})`));
3372
+ }
3373
+ if (report.projectType === "static" && report.summary.notApplicable > 0) {
3374
+ console.log(chalk2.dim(` Static site detected \u2014 ${report.summary.notApplicable} checks not applicable`));
3375
+ }
3376
+ console.log(formatTerminalReport(report, options.verbose));
3377
+ }
3378
+ if (options.generateConfigs || options.outputDir) {
3379
+ const snippets = generateConfigs(context.stack);
3380
+ if (options.outputDir) {
3381
+ const paths = await writeConfigFiles(snippets, options.outputDir);
3382
+ if (!isJson) {
3383
+ console.log(chalk2.green(`
3384
+ \u2713 Wrote ${paths.length} config file${paths.length === 1 ? "" : "s"} to ${options.outputDir}/`));
3385
+ for (const p of paths) {
3386
+ console.log(chalk2.dim(` ${p}`));
3387
+ }
3388
+ console.log();
3389
+ }
3390
+ } else if (!isJson) {
3391
+ console.log(formatConfigOutput(snippets));
3392
+ }
3393
+ }
3394
+ if (report.summary.fail > 0) {
3395
+ process.exitCode = 1;
3396
+ }
3397
+ } catch (error) {
3398
+ if (isJson) {
3399
+ console.error(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
3400
+ } else {
3401
+ spinner?.fail("Scan failed");
3402
+ console.error(
3403
+ chalk2.red(`
3404
+ ${error instanceof Error ? error.message : String(error)}
3405
+ `)
3406
+ );
3407
+ }
3408
+ process.exitCode = 1;
3409
+ }
3410
+ }
3411
+ function printBanner(version) {
3412
+ console.log();
3413
+ console.log(chalk2.bold.cyan(" Bastion") + chalk2.dim(` v${version}`));
3414
+ console.log(chalk2.dim(" Privacy-first security checker"));
3415
+ console.log();
3416
+ }
3417
+ function isValidFormat(format) {
3418
+ return OUTPUT_FORMATS.includes(format);
3419
+ }
3420
+
3421
+ // src/index.ts
3422
+ var require2 = createRequire(import.meta.url);
3423
+ var pkg = require2("../package.json");
3424
+ createProgram(pkg.version).parse();