yeknal 1.0.8 → 1.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.
Files changed (2) hide show
  1. package/bin/yeknal.js +1320 -125
  2. package/package.json +5 -5
package/bin/yeknal.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * yeknal CLI
5
5
  * - Fetches markdown templates (security, design, seo).
6
6
  * - Syncs skill folders into local AI agent directories (skills command).
7
+ * - Scans project repos for security issues based on Security-Master.md rules.
7
8
  */
8
9
 
9
10
  const fs = require("fs");
@@ -28,11 +29,9 @@ const GITHUB_TOKEN = process.env.YEKNAL_GITHUB_TOKEN || process.env.GITHUB_TOKEN
28
29
 
29
30
  const EXCLUDED_SKILL_FOLDERS = new Set(["Design", "Security", "Security_Raw", "SEO"]);
30
31
 
32
+ const SECURITY_REPO_FOLDERS = ["Security", "Security_Raw"];
33
+
31
34
  const singleFileConfigs = {
32
- security: {
33
- remotePath: "Security/Security-Master.md",
34
- localName: "Security-Master.md",
35
- },
36
35
  design: {
37
36
  remotePath: "Design/SKILL.md",
38
37
  localName: "Design.md",
@@ -45,10 +44,10 @@ const singleFileConfigs = {
45
44
 
46
45
  function usage() {
47
46
  console.log("\nUsage:");
48
- console.log(" npx yeknal security");
49
- console.log(" npx yeknal design");
50
- console.log(" npx yeknal seo");
51
- console.log(" npx yeknal skills\n");
47
+ console.log(" npx yeknal security Sync security skills + scan current project");
48
+ console.log(" npx yeknal design Fetch design guidelines");
49
+ console.log(" npx yeknal seo Fetch SEO guidelines");
50
+ console.log(" npx yeknal skills Sync all skill folders\n");
52
51
  }
53
52
 
54
53
  function isHttpSuccess(statusCode) {
@@ -113,9 +112,9 @@ async function fetchJson(url) {
113
112
  throw new Error(
114
113
  `GitHub API rate limit exceeded.\n` +
115
114
  `Set an auth token to increase limits:\n` +
116
- ` PowerShell: $env:YEKNAL_GITHUB_TOKEN=\"<your_token>\"\n` +
117
- ` Bash/zsh: export YEKNAL_GITHUB_TOKEN=\"<your_token>\"\n` +
118
- `Then rerun: npx yeknal skills`,
115
+ ` PowerShell: $env:YEKNAL_GITHUB_TOKEN="<your_token>"\n` +
116
+ ` Bash/zsh: export YEKNAL_GITHUB_TOKEN="<your_token>"\n` +
117
+ `Then rerun: npx yeknal security`,
119
118
  );
120
119
  }
121
120
  throw new Error(`GitHub API request failed (${response.statusCode}): ${url}\n${bodyText}`);
@@ -202,9 +201,9 @@ async function copyDirRecursive(sourceDir, targetDir) {
202
201
  }
203
202
  }
204
203
 
205
- function execCommand(command) {
204
+ function execCommand(command, options = {}) {
206
205
  return new Promise((resolve, reject) => {
207
- exec(command, (error, stdout, stderr) => {
206
+ exec(command, options, (error, stdout, stderr) => {
208
207
  if (error) {
209
208
  reject(new Error(stderr || error.message));
210
209
  return;
@@ -214,6 +213,14 @@ function execCommand(command) {
214
213
  });
215
214
  }
216
215
 
216
+ function execCommandSafe(command, options = {}) {
217
+ return new Promise((resolve) => {
218
+ exec(command, options, (error, stdout, stderr) => {
219
+ resolve({ error, stdout: stdout || "", stderr: stderr || "" });
220
+ });
221
+ });
222
+ }
223
+
217
224
  async function isDirectory(filePath) {
218
225
  try {
219
226
  const stats = await fsp.stat(filePath);
@@ -223,6 +230,15 @@ async function isDirectory(filePath) {
223
230
  }
224
231
  }
225
232
 
233
+ async function fileExists(filePath) {
234
+ try {
235
+ await fsp.access(filePath, fs.constants.F_OK);
236
+ return true;
237
+ } catch {
238
+ return false;
239
+ }
240
+ }
241
+
226
242
  async function discoverLocalSkillFolders(sourceRoot) {
227
243
  const entries = await fsp.readdir(sourceRoot, { withFileTypes: true });
228
244
  const folders = [];
@@ -415,148 +431,1322 @@ async function runSkillsCommand() {
415
431
  }
416
432
  }
417
433
 
418
- function runSecurityAudit() {
419
- return new Promise((resolve) => {
420
- console.log("Running security audit (this may take a moment)...");
421
- exec("npx secure-repo audit", (error, stdout, stderr) => {
422
- const logPath = path.join(process.cwd(), "security-audit.log");
434
+ async function runSingleFileTemplateCommand(category) {
435
+ const config = singleFileConfigs[category];
436
+ const fileUrl = `${RAW_BASE_URL}/${config.remotePath}`;
437
+ const localDest = path.join(process.cwd(), config.localName);
438
+
439
+ console.log(`\nFetching ${category} guidelines...`);
440
+ await downloadUrlToFile(fileUrl, localDest);
441
+ console.log(`Saved to: ${localDest}\n`);
442
+ }
423
443
 
424
- const outputLines = stdout.split("\n");
425
- const finalLines = [];
444
+ // ==========================================
445
+ // SECURITY SCANNER
446
+ // Based on Security-Master.md rules
447
+ // ==========================================
426
448
 
427
- let inPolicySection = false;
428
- let policyPasses = 0;
429
- let policyWarnings = 0;
430
- let policyIssues = 0;
431
- let policyPoints = 0;
449
+ const SCAN_IGNORE_DIRS = new Set([
450
+ "node_modules", ".git", ".next", "dist", "build", ".cache", ".output",
451
+ "coverage", ".nyc_output", "__pycache__", ".venv", "venv", "env",
452
+ ".terraform", "vendor", "target", "bin", "obj", ".nuxt", ".svelte-kit",
453
+ ".vercel", ".netlify", ".turbo", "out", ".parcel-cache", "tmp",
454
+ ]);
455
+
456
+ const SCAN_SOURCE_EXTENSIONS = new Set([
457
+ ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs",
458
+ ".py", ".rb", ".php", ".go", ".rs", ".java", ".cs",
459
+ ".vue", ".svelte",
460
+ ]);
461
+
462
+ const SCAN_CONFIG_EXTENSIONS = new Set([
463
+ ".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf",
464
+ ]);
465
+
466
+ const SCAN_ALL_EXTENSIONS = new Set([
467
+ ...SCAN_SOURCE_EXTENSIONS, ...SCAN_CONFIG_EXTENSIONS,
468
+ ".env", ".html", ".xml",
469
+ ]);
470
+
471
+ // Secret patterns from Security-Master.md Part 1:
472
+ // "Never commit DB passwords, JWT secrets, API keys, service account keys, admin credentials"
473
+ const SECRET_PATTERNS = [
474
+ { name: "AWS Access Key ID", pattern: /AKIA[0-9A-Z]{16}/g },
475
+ { name: "AWS Secret Access Key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}/gi },
476
+ { name: "Generic API Key assignment", pattern: /(?:api[_-]?key|apikey|api_secret)\s*[=:]\s*['"][a-zA-Z0-9_\-]{20,}['"]/gi },
477
+ { name: "Generic Secret assignment", pattern: /(?:client_secret|app_secret|secret_key)\s*[=:]\s*['"][a-zA-Z0-9_\-]{16,}['"]/gi },
478
+ { name: "Database connection string", pattern: /(?:postgres|postgresql|mysql|mongodb|mongodb\+srv|redis|rediss):\/\/[^\s'"]*:[^\s'"]*@[^\s'"]+/gi },
479
+ { name: "JWT token (hardcoded)", pattern: /['"]eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}['"]/g },
480
+ { name: "Private key", pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g },
481
+ { name: "Hardcoded password", pattern: /(?:password|passwd|pwd|db_pass|DB_PASSWORD)\s*[=:]\s*['"][^'"]{8,}['"]/gi },
482
+ { name: "Slack token", pattern: /xox[baprs]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24,34}/g },
483
+ { name: "GitHub token", pattern: /gh[ps]_[A-Za-z0-9_]{36}/g },
484
+ { name: "Stripe secret key", pattern: /sk_(?:live|test)_[0-9a-zA-Z]{24,}/g },
485
+ { name: "Twilio auth token", pattern: /(?:twilio_auth_token|TWILIO_AUTH)\s*[=:]\s*['"][a-f0-9]{32}['"]/gi },
486
+ { name: "SendGrid API key", pattern: /SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}/g },
487
+ { name: "Firebase private key", pattern: /(?:firebase|FIREBASE).*private_key.*-----BEGIN/gi },
488
+ ];
489
+
490
+ // Files/patterns to exclude from secret scanning (reduce false positives)
491
+ const SECRET_SCAN_EXCLUDE_FILES = new Set([
492
+ "package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
493
+ ".env.example", ".env.sample", ".env.template",
494
+ "tsconfig.json", "jsconfig.json",
495
+ ]);
496
+
497
+ // Walk directory tree collecting scannable files
498
+ async function walkProjectFiles(rootDir) {
499
+ const maxDepth = 12;
500
+ const maxFileSize = 512 * 1024; // 512KB
501
+ const files = [];
502
+
503
+ async function walk(dir, depth) {
504
+ if (depth > maxDepth) return;
505
+
506
+ let entries;
507
+ try {
508
+ entries = await fsp.readdir(dir, { withFileTypes: true });
509
+ } catch {
510
+ return;
511
+ }
432
512
 
433
- for (let i = 0; i < outputLines.length; i++) {
434
- const line = outputLines[i];
513
+ for (const entry of entries) {
514
+ const fullPath = path.join(dir, entry.name);
435
515
 
436
- if (line.match(/^\s*Policy files:/)) {
437
- inPolicySection = true;
438
- continue;
516
+ if (entry.isDirectory()) {
517
+ if (!SCAN_IGNORE_DIRS.has(entry.name)) {
518
+ await walk(fullPath, depth + 1);
439
519
  }
520
+ continue;
521
+ }
440
522
 
441
- if (inPolicySection) {
442
- if (
443
- line.match(/^\s*Environment files:/) ||
444
- line.match(/^\s*Secret scanning:/) ||
445
- line.match(/^\s*Configuration:/)
446
- ) {
447
- inPolicySection = false;
448
- } else {
449
- if (line.includes("[FAIL]")) policyIssues += 1;
450
- if (line.includes("[warn]")) policyWarnings += 1;
451
- if (line.includes("[pass]")) {
452
- policyPasses += 1;
453
- if (
454
- line.includes("SECURITY.md") ||
455
- line.includes("AUTH.md") ||
456
- line.includes("API.md") ||
457
- line.includes("ENV_VARIABLES.md")
458
- ) {
459
- policyPoints += 10;
460
- } else {
461
- policyPoints += 5;
462
- }
523
+ if (entry.isFile()) {
524
+ const ext = path.extname(entry.name).toLowerCase();
525
+ const nameLC = entry.name.toLowerCase();
526
+ if (SCAN_ALL_EXTENSIONS.has(ext) || nameLC.startsWith(".env")) {
527
+ try {
528
+ const stats = await fsp.stat(fullPath);
529
+ if (stats.size <= maxFileSize) {
530
+ files.push(fullPath);
463
531
  }
464
- continue;
465
- }
532
+ } catch { /* skip unreadable */ }
466
533
  }
534
+ }
535
+ }
536
+ }
467
537
 
468
- if (line.match(/^\s*Security Score:\s*\d+\s*\/\s*\d+/)) {
469
- const match = line.match(/^\s*Security Score:\s*(\d+)/);
470
- if (match) {
471
- const oldScore = parseInt(match[1], 10);
472
- const newScore = oldScore - policyPoints;
473
- finalLines.push(` Security Score: ${newScore} / 45`);
474
- } else {
475
- finalLines.push(line);
476
- }
477
- continue;
478
- }
538
+ await walk(rootDir, 0);
539
+ return files;
540
+ }
479
541
 
480
- if (line.match(/^\s*Results:\s*\d+\s*passed,\s*\d+\s*warnings,\s*\d+\s*issues/)) {
481
- const match = line.match(
482
- /Results:\s*(\d+)\s*passed,\s*(\d+)\s*warnings,\s*(\d+)\s*issues/,
483
- );
484
- if (match) {
485
- const newPassed = parseInt(match[1], 10) - policyPasses;
486
- const newWarnings = parseInt(match[2], 10) - policyWarnings;
487
- const newIssues = parseInt(match[3], 10) - policyIssues;
488
- finalLines.push(
489
- ` Results: ${newPassed} passed, ${newWarnings} warnings, ${newIssues} issues`,
490
- );
491
- } else {
492
- finalLines.push(line);
493
- }
494
- continue;
495
- }
542
+ // Read file safely, return null on failure
543
+ async function safeReadFile(filePath) {
544
+ try {
545
+ return await fsp.readFile(filePath, "utf8");
546
+ } catch {
547
+ return null;
548
+ }
549
+ }
496
550
 
497
- if (line.match(/^\s*\d+\s*issue\(s\)\s*found/)) {
498
- const match = line.match(/^\s*(\d+)\s*issue\(s\)/);
499
- if (match) {
500
- const newIssues = parseInt(match[1], 10) - policyIssues;
501
- finalLines.push(` ${newIssues} issue(s) found. Fix these before shipping.`);
502
- } else {
503
- finalLines.push(line);
504
- }
505
- continue;
506
- }
551
+ // Create a check result object
552
+ function checkResult(name, reference, points, earned, status, details, issues) {
553
+ return { name, reference, points, earned, status, details, issues: issues || [] };
554
+ }
507
555
 
508
- if (line.includes("Run: npx secure-repo init") && line.includes("adds missing policy files")) {
509
- continue;
510
- }
556
+ // ---- Category 1: Secrets & Environment (25 pts) ----
557
+ // Security-Master.md Part 1 Rule 1: "No secrets in code"
558
+ // Security-Master.md Phase 10: Environment & Secrets Management
559
+ async function checkSecretsAndEnv(projectDir, fileContents) {
560
+ const checks = [];
561
+
562
+ // Check 1: .gitignore exists (3 pts)
563
+ const gitignorePath = path.join(projectDir, ".gitignore");
564
+ const gitignoreContent = await safeReadFile(gitignorePath);
565
+ if (gitignoreContent !== null) {
566
+ checks.push(checkResult(
567
+ ".gitignore exists", "Phase 10", 3, 3, "pass",
568
+ ".gitignore file found",
569
+ ));
570
+ } else {
571
+ checks.push(checkResult(
572
+ ".gitignore exists", "Phase 10", 3, 0, "fail",
573
+ "No .gitignore file found. Create one to prevent committing secrets.",
574
+ [{ file: ".gitignore", message: "Missing .gitignore file" }],
575
+ ));
576
+ }
511
577
 
512
- if (line.includes("Want deeper coverage? The pro pack adds")) {
513
- i += 2;
514
- continue;
515
- }
578
+ // Check 2: .gitignore covers .env files (4 pts)
579
+ if (gitignoreContent !== null) {
580
+ const envPatterns = [".env", ".env.local", ".env.*.local", ".env.production"];
581
+ const lines = gitignoreContent.split("\n").map((l) => l.trim());
582
+ const coversEnv = envPatterns.some((p) =>
583
+ lines.some((line) => line === p || line === `${p}*` || line === ".env*" || line === ".env.*"),
584
+ );
585
+
586
+ if (coversEnv) {
587
+ checks.push(checkResult(
588
+ ".gitignore covers .env files", "Phase 10", 4, 4, "pass",
589
+ ".gitignore includes .env patterns",
590
+ ));
591
+ } else {
592
+ checks.push(checkResult(
593
+ ".gitignore covers .env files", "Phase 10", 4, 0, "fail",
594
+ ".gitignore does not cover .env files. Add .env* to .gitignore.",
595
+ [{ file: ".gitignore", message: "Missing .env* patterns in .gitignore" }],
596
+ ));
597
+ }
598
+ } else {
599
+ checks.push(checkResult(
600
+ ".gitignore covers .env files", "Phase 10", 4, 0, "fail",
601
+ "Cannot check — .gitignore missing",
602
+ [{ file: ".gitignore", message: ".gitignore missing entirely" }],
603
+ ));
604
+ }
516
605
 
517
- finalLines.push(line);
606
+ // Check 3: No .env files tracked in git (5 pts)
607
+ const gitDir = path.join(projectDir, ".git");
608
+ if (await isDirectory(gitDir)) {
609
+ const result = await execCommandSafe("git ls-files --cached", { cwd: projectDir });
610
+ if (!result.error) {
611
+ const trackedFiles = result.stdout.split("\n").filter(Boolean);
612
+ const trackedEnvFiles = trackedFiles.filter((f) => {
613
+ const base = path.basename(f).toLowerCase();
614
+ return base.startsWith(".env") && !base.includes("example") && !base.includes("sample") && !base.includes("template");
615
+ });
616
+
617
+ if (trackedEnvFiles.length === 0) {
618
+ checks.push(checkResult(
619
+ "No .env files tracked in git", "Phase 10", 5, 5, "pass",
620
+ "No .env files found in git index",
621
+ ));
622
+ } else {
623
+ checks.push(checkResult(
624
+ "No .env files tracked in git", "Phase 10", 5, 0, "fail",
625
+ `${trackedEnvFiles.length} .env file(s) tracked in git`,
626
+ trackedEnvFiles.map((f) => ({ file: f, message: `${f} is tracked in git — contains potential secrets` })),
627
+ ));
628
+ }
629
+ } else {
630
+ checks.push(checkResult(
631
+ "No .env files tracked in git", "Phase 10", 5, 5, "skip",
632
+ "Could not run git ls-files",
633
+ ));
634
+ }
635
+ } else {
636
+ // Not a git repo — check for .env files in directory
637
+ const envFiles = [];
638
+ for (const [filePath] of fileContents) {
639
+ const base = path.basename(filePath).toLowerCase();
640
+ if (base.startsWith(".env") && !base.includes("example") && !base.includes("sample")) {
641
+ envFiles.push(filePath);
518
642
  }
643
+ }
644
+ if (envFiles.length === 0) {
645
+ checks.push(checkResult(
646
+ "No .env files in project", "Phase 10", 5, 5, "pass",
647
+ "No .env files found (not a git repo)",
648
+ ));
649
+ } else {
650
+ checks.push(checkResult(
651
+ "No .env files in project", "Phase 10", 5, 3, "warn",
652
+ `${envFiles.length} .env file(s) found. Not a git repo so tracking status unknown.`,
653
+ envFiles.map((f) => ({ file: relPath(projectDir, f), message: "Ensure this file is not deployed or shared" })),
654
+ ));
655
+ }
656
+ }
657
+
658
+ // Check 4: No hardcoded secrets in source code (8 pts)
659
+ const secretIssues = [];
660
+ for (const [filePath, content] of fileContents) {
661
+ const baseName = path.basename(filePath);
662
+ if (SECRET_SCAN_EXCLUDE_FILES.has(baseName)) continue;
663
+ if (baseName.toLowerCase().startsWith(".env")) continue;
664
+
665
+ const ext = path.extname(filePath).toLowerCase();
666
+ if (!SCAN_SOURCE_EXTENSIONS.has(ext) && ext !== ".json" && ext !== ".yaml" && ext !== ".yml") continue;
667
+
668
+ for (const sp of SECRET_PATTERNS) {
669
+ sp.pattern.lastIndex = 0;
670
+ let match;
671
+ while ((match = sp.pattern.exec(content)) !== null) {
672
+ // Skip obvious false positives
673
+ const context = content.substring(Math.max(0, match.index - 40), match.index + match[0].length + 40);
674
+ if (/process\.env|os\.environ|ENV\[|getenv|env\(|\.env\./i.test(context)) continue;
675
+ if (/example|placeholder|your[_-]|xxx|changeme|TODO|FIXME/i.test(match[0])) continue;
676
+
677
+ const line = content.substring(0, match.index).split("\n").length;
678
+ secretIssues.push({
679
+ file: relPath(projectDir, filePath),
680
+ line,
681
+ message: `${sp.name} found at line ${line}`,
682
+ match: mask(match[0]),
683
+ });
684
+ }
685
+ }
686
+ }
519
687
 
520
- const modifiedStdout = finalLines.join("\n");
521
- const output = `--- Security Audit Log ---\nDate: ${new Date().toISOString()}\n\n${modifiedStdout}\n${
522
- stderr ? `Errors/Warnings:\n${stderr}` : ""
523
- }`;
688
+ if (secretIssues.length === 0) {
689
+ checks.push(checkResult(
690
+ "No hardcoded secrets in source", "Part 1 Rule 1", 8, 8, "pass",
691
+ "No hardcoded secrets detected in source files",
692
+ ));
693
+ } else {
694
+ const earned = secretIssues.length <= 2 ? 4 : 0;
695
+ checks.push(checkResult(
696
+ "No hardcoded secrets in source", "Part 1 Rule 1", 8, earned, secretIssues.length <= 2 ? "warn" : "fail",
697
+ `${secretIssues.length} potential secret(s) found in source code`,
698
+ secretIssues,
699
+ ));
700
+ }
524
701
 
525
- fs.writeFileSync(logPath, output);
702
+ // Check 5: No private keys committed (5 pts)
703
+ const keyIssues = [];
704
+ for (const [filePath, content] of fileContents) {
705
+ if (/-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/.test(content)) {
706
+ keyIssues.push({
707
+ file: relPath(projectDir, filePath),
708
+ message: "Private key found in file",
709
+ });
710
+ }
711
+ }
526
712
 
527
- const totalNewIssuesRegex = /^\s*(\d+)\s*issue\(s\)\s*found/m;
528
- const matchNewIssues = modifiedStdout.match(totalNewIssuesRegex);
529
- let hasIssues = error !== null;
530
- if (matchNewIssues) {
531
- hasIssues = parseInt(matchNewIssues[1], 10) > 0;
713
+ if (keyIssues.length === 0) {
714
+ checks.push(checkResult(
715
+ "No private keys in repo", "Part 1 Rule 1", 5, 5, "pass",
716
+ "No private key files detected",
717
+ ));
718
+ } else {
719
+ checks.push(checkResult(
720
+ "No private keys in repo", "Part 1 Rule 1", 5, 0, "fail",
721
+ `${keyIssues.length} private key(s) found`,
722
+ keyIssues,
723
+ ));
724
+ }
725
+
726
+ return { name: "Secrets & Environment", checks };
727
+ }
728
+
729
+ // ---- Category 2: Dependencies (15 pts) ----
730
+ // Security-Master.md Phase 12: Dependency Security
731
+ async function checkDependencies(projectDir, fileContents) {
732
+ const checks = [];
733
+ const pkgJsonPath = path.join(projectDir, "package.json");
734
+ const hasPkgJson = await fileExists(pkgJsonPath);
735
+
736
+ if (!hasPkgJson) {
737
+ // Check for other lock files (Python, Go, Rust, etc.)
738
+ const otherLocks = ["requirements.txt", "Pipfile.lock", "poetry.lock", "go.sum", "Cargo.lock", "Gemfile.lock", "composer.lock"];
739
+ let foundLock = false;
740
+ for (const lf of otherLocks) {
741
+ if (await fileExists(path.join(projectDir, lf))) {
742
+ foundLock = true;
743
+ checks.push(checkResult(
744
+ "Dependency lock file present", "Phase 12", 5, 5, "pass",
745
+ `Lock file found: ${lf}`,
746
+ ));
747
+ break;
532
748
  }
749
+ }
750
+ if (!foundLock) {
751
+ checks.push(checkResult(
752
+ "Dependency lock file present", "Phase 12", 5, 0, "skip",
753
+ "No recognized package manager detected",
754
+ ));
755
+ }
756
+
757
+ checks.push(checkResult(
758
+ "Dependency audit clean", "Phase 12", 10, 0, "skip",
759
+ "No package.json found — npm audit skipped",
760
+ ));
761
+
762
+ return { name: "Dependencies", checks };
763
+ }
764
+
765
+ // Check 6: Lock file present (5 pts)
766
+ const lockFiles = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"];
767
+ let foundLock = null;
768
+ for (const lf of lockFiles) {
769
+ if (await fileExists(path.join(projectDir, lf))) {
770
+ foundLock = lf;
771
+ break;
772
+ }
773
+ }
533
774
 
534
- if (hasIssues) {
535
- console.log("Security audit found issues (or returned an error code).");
536
- console.log(`See ${logPath} for details.\n`);
775
+ if (foundLock) {
776
+ checks.push(checkResult(
777
+ "Dependency lock file present", "Phase 12", 5, 5, "pass",
778
+ `Lock file found: ${foundLock}`,
779
+ ));
780
+ } else {
781
+ checks.push(checkResult(
782
+ "Dependency lock file present", "Phase 12", 5, 0, "fail",
783
+ "No lock file found. Run npm install to generate package-lock.json.",
784
+ [{ file: "package-lock.json", message: "Missing lock file — builds may not be reproducible" }],
785
+ ));
786
+ }
787
+
788
+ // Check 7: npm audit (10 pts)
789
+ const auditResult = await execCommandSafe("npm audit --json 2>&1", { cwd: projectDir, timeout: 30000 });
790
+ if (auditResult.stdout) {
791
+ try {
792
+ const audit = JSON.parse(auditResult.stdout);
793
+ const vulns = audit.metadata ? audit.metadata.vulnerabilities : (audit.vulnerabilities || {});
794
+ const critical = vulns.critical || 0;
795
+ const high = vulns.high || 0;
796
+ const moderate = vulns.moderate || 0;
797
+ const low = vulns.low || 0;
798
+ const total = critical + high + moderate + low;
799
+
800
+ if (total === 0) {
801
+ checks.push(checkResult(
802
+ "Dependency audit clean", "Phase 12", 10, 10, "pass",
803
+ "npm audit found no vulnerabilities",
804
+ ));
805
+ } else if (critical === 0 && high === 0) {
806
+ checks.push(checkResult(
807
+ "Dependency audit clean", "Phase 12", 10, 7, "warn",
808
+ `npm audit: ${moderate} moderate, ${low} low vulnerability(ies)`,
809
+ [{ file: "package.json", message: `${total} non-critical vulnerability(ies). Run npm audit fix.` }],
810
+ ));
537
811
  } else {
538
- console.log(`Security audit clean. Results saved to: ${logPath}\n`);
812
+ checks.push(checkResult(
813
+ "Dependency audit clean", "Phase 12", 10, 0, "fail",
814
+ `npm audit: ${critical} critical, ${high} high, ${moderate} moderate, ${low} low`,
815
+ [{ file: "package.json", message: `${critical + high} critical/high vulnerability(ies). Run npm audit fix immediately.` }],
816
+ ));
539
817
  }
818
+ } catch {
819
+ checks.push(checkResult(
820
+ "Dependency audit clean", "Phase 12", 10, 0, "skip",
821
+ "Could not parse npm audit output",
822
+ ));
823
+ }
824
+ } else {
825
+ checks.push(checkResult(
826
+ "Dependency audit clean", "Phase 12", 10, 0, "skip",
827
+ "npm audit could not be run (npm not available or no node_modules)",
828
+ ));
829
+ }
540
830
 
541
- resolve();
542
- });
543
- });
831
+ return { name: "Dependencies", checks };
544
832
  }
545
833
 
546
- async function runSingleFileTemplateCommand(category) {
547
- const config = singleFileConfigs[category];
548
- const fileUrl = `${RAW_BASE_URL}/${config.remotePath}`;
549
- const localDest = path.join(process.cwd(), config.localName);
834
+ // ---- Category 3: Authentication & Sessions (15 pts) ----
835
+ // Security-Master.md Part 2: Authentication & Authorization Policy
836
+ async function checkAuthSessions(projectDir, fileContents) {
837
+ const checks = [];
838
+ let hasAuthCode = false;
550
839
 
551
- console.log(`\nFetching ${category} guidelines...`);
552
- await downloadUrlToFile(fileUrl, localDest);
553
- console.log(`Saved to: ${localDest}\n`);
840
+ for (const [, content] of fileContents) {
841
+ if (/(?:login|signIn|authenticate|session|passport|jwt|jsonwebtoken|bcrypt|argon2)/i.test(content)) {
842
+ hasAuthCode = true;
843
+ break;
844
+ }
845
+ }
846
+
847
+ if (!hasAuthCode) {
848
+ checks.push(checkResult("No tokens in localStorage", "Part 2 Rule 4", 5, 0, "skip", "No auth-related code detected"));
849
+ checks.push(checkResult("Strong password hashing", "Part 2 Rule 6", 5, 0, "skip", "No auth-related code detected"));
850
+ checks.push(checkResult("Secure cookie configuration", "Part 2 Rule 4", 5, 0, "skip", "No auth-related code detected"));
851
+ return { name: "Auth & Sessions", checks };
852
+ }
853
+
854
+ // Check 8: No tokens stored in localStorage (5 pts)
855
+ // Part 2 Rule 4: "Do not store tokens in localStorage"
856
+ const localStorageIssues = [];
857
+ for (const [filePath, content] of fileContents) {
858
+ const ext = path.extname(filePath).toLowerCase();
859
+ if (!SCAN_SOURCE_EXTENSIONS.has(ext)) continue;
860
+
861
+ const tokenStoragePattern = /localStorage\.setItem\s*\(\s*['"](?:token|auth|jwt|session|access_token|refresh_token|id_token|bearer)['"]/gi;
862
+ let match;
863
+ while ((match = tokenStoragePattern.exec(content)) !== null) {
864
+ const line = content.substring(0, match.index).split("\n").length;
865
+ localStorageIssues.push({
866
+ file: relPath(projectDir, filePath),
867
+ line,
868
+ message: `Token stored in localStorage at line ${line}`,
869
+ });
870
+ }
871
+ }
872
+
873
+ if (localStorageIssues.length === 0) {
874
+ checks.push(checkResult(
875
+ "No tokens in localStorage", "Part 2 Rule 4", 5, 5, "pass",
876
+ "No localStorage token storage detected",
877
+ ));
878
+ } else {
879
+ checks.push(checkResult(
880
+ "No tokens in localStorage", "Part 2 Rule 4", 5, 0, "fail",
881
+ `${localStorageIssues.length} instance(s) of token storage in localStorage. Use httpOnly cookies instead.`,
882
+ localStorageIssues,
883
+ ));
884
+ }
885
+
886
+ // Check 9: Strong password hashing (5 pts)
887
+ // Part 2 Rule 6: "bcrypt/scrypt/argon2 only"
888
+ let usesStrongHash = false;
889
+ let usesWeakHash = false;
890
+ const weakHashIssues = [];
891
+
892
+ for (const [filePath, content] of fileContents) {
893
+ const ext = path.extname(filePath).toLowerCase();
894
+ if (!SCAN_SOURCE_EXTENSIONS.has(ext)) continue;
895
+
896
+ if (/(?:bcrypt|argon2|scrypt)/i.test(content)) usesStrongHash = true;
897
+
898
+ const weakPatterns = [
899
+ { name: "MD5 for passwords", pattern: /(?:md5|createHash\s*\(\s*['"]md5).*(?:password|passwd|pwd)/gis },
900
+ { name: "SHA1 for passwords", pattern: /(?:sha1|createHash\s*\(\s*['"]sha1).*(?:password|passwd|pwd)/gis },
901
+ { name: "SHA256 for passwords (use bcrypt)", pattern: /(?:sha256|createHash\s*\(\s*['"]sha256).*(?:password|passwd|pwd)/gis },
902
+ ];
903
+
904
+ for (const wp of weakPatterns) {
905
+ if (wp.pattern.test(content)) {
906
+ usesWeakHash = true;
907
+ weakHashIssues.push({
908
+ file: relPath(projectDir, filePath),
909
+ message: wp.name,
910
+ });
911
+ }
912
+ }
913
+ }
914
+
915
+ if (usesStrongHash && !usesWeakHash) {
916
+ checks.push(checkResult(
917
+ "Strong password hashing", "Part 2 Rule 6", 5, 5, "pass",
918
+ "Strong hashing algorithm detected (bcrypt/argon2/scrypt)",
919
+ ));
920
+ } else if (usesWeakHash) {
921
+ checks.push(checkResult(
922
+ "Strong password hashing", "Part 2 Rule 6", 5, 0, "fail",
923
+ "Weak hashing algorithm used for passwords. Use bcrypt (cost >= 12), argon2, or scrypt.",
924
+ weakHashIssues,
925
+ ));
926
+ } else {
927
+ checks.push(checkResult(
928
+ "Strong password hashing", "Part 2 Rule 6", 5, 3, "warn",
929
+ "Auth code found but no explicit password hashing detected. Verify hashing is handled by auth provider.",
930
+ ));
931
+ }
932
+
933
+ // Check 10: Secure cookie flags (5 pts)
934
+ // Part 2 Rule 4: "httpOnly, Secure, SameSite=Strict cookies"
935
+ let hasCookieConfig = false;
936
+ let hasHttpOnly = false;
937
+ let hasSecure = false;
938
+ const cookieIssues = [];
939
+
940
+ for (const [filePath, content] of fileContents) {
941
+ const ext = path.extname(filePath).toLowerCase();
942
+ if (!SCAN_SOURCE_EXTENSIONS.has(ext)) continue;
943
+
944
+ if (/(?:cookie|setCookie|set-cookie|cookies\.set)/i.test(content)) {
945
+ hasCookieConfig = true;
946
+ if (/httpOnly\s*:\s*true/i.test(content) || /httponly/i.test(content)) hasHttpOnly = true;
947
+ if (/secure\s*:\s*true/i.test(content)) hasSecure = true;
948
+
949
+ if (/httpOnly\s*:\s*false/i.test(content)) {
950
+ const line = content.split("\n").findIndex((l) => /httpOnly\s*:\s*false/i.test(l)) + 1;
951
+ cookieIssues.push({ file: relPath(projectDir, filePath), line, message: "httpOnly set to false" });
952
+ }
953
+ }
954
+ }
955
+
956
+ if (!hasCookieConfig) {
957
+ checks.push(checkResult(
958
+ "Secure cookie configuration", "Part 2 Rule 4", 5, 0, "skip",
959
+ "No cookie configuration detected",
960
+ ));
961
+ } else if (hasHttpOnly && hasSecure && cookieIssues.length === 0) {
962
+ checks.push(checkResult(
963
+ "Secure cookie configuration", "Part 2 Rule 4", 5, 5, "pass",
964
+ "Cookies configured with httpOnly and Secure flags",
965
+ ));
966
+ } else {
967
+ const missing = [];
968
+ if (!hasHttpOnly) missing.push("httpOnly");
969
+ if (!hasSecure) missing.push("Secure");
970
+ checks.push(checkResult(
971
+ "Secure cookie configuration", "Part 2 Rule 4", 5, hasHttpOnly ? 3 : 0, cookieIssues.length > 0 ? "fail" : "warn",
972
+ `Cookie configuration missing: ${missing.join(", ") || "none"}. ${cookieIssues.length} issue(s).`,
973
+ cookieIssues,
974
+ ));
975
+ }
976
+
977
+ return { name: "Auth & Sessions", checks };
978
+ }
979
+
980
+ // ---- Category 4: Input & API Security (15 pts) ----
981
+ // Security-Master.md Part 3: API Standards
982
+ async function checkInputApi(projectDir, fileContents) {
983
+ const checks = [];
984
+ const pkgJsonPath = path.join(projectDir, "package.json");
985
+ const pkgContent = await safeReadFile(pkgJsonPath);
986
+ let pkgJson = null;
987
+ try { pkgJson = pkgContent ? JSON.parse(pkgContent) : null; } catch { /* ignore */ }
988
+
989
+ const allDeps = pkgJson
990
+ ? Object.keys(pkgJson.dependencies || {}).concat(Object.keys(pkgJson.devDependencies || {}))
991
+ : [];
992
+
993
+ // Check 11: Input validation library present (5 pts)
994
+ // Part 3 Rule 1: "Validate all input — use zod/yup/joi"
995
+ const validationLibs = ["zod", "yup", "joi", "@hapi/joi", "ajv", "superstruct", "valibot", "io-ts", "class-validator"];
996
+ const foundValidation = validationLibs.filter((lib) => allDeps.includes(lib));
997
+
998
+ let hasValidationImport = false;
999
+ if (foundValidation.length === 0) {
1000
+ for (const [, content] of fileContents) {
1001
+ if (/(?:from\s+['"](?:zod|yup|joi|ajv|valibot|superstruct)['"]|require\s*\(\s*['"](?:zod|yup|joi|ajv|valibot)['"])/i.test(content)) {
1002
+ hasValidationImport = true;
1003
+ break;
1004
+ }
1005
+ }
1006
+ }
1007
+
1008
+ if (foundValidation.length > 0 || hasValidationImport) {
1009
+ checks.push(checkResult(
1010
+ "Input validation library present", "Part 3 Rule 1", 5, 5, "pass",
1011
+ `Validation library found: ${foundValidation.join(", ") || "detected in imports"}`,
1012
+ ));
1013
+ } else if (pkgJson) {
1014
+ checks.push(checkResult(
1015
+ "Input validation library present", "Part 3 Rule 1", 5, 0, "fail",
1016
+ "No input validation library detected. Install zod, yup, or joi.",
1017
+ [{ file: "package.json", message: "Add a validation library (zod recommended)" }],
1018
+ ));
1019
+ } else {
1020
+ checks.push(checkResult(
1021
+ "Input validation library present", "Part 3 Rule 1", 5, 0, "skip",
1022
+ "No package.json found",
1023
+ ));
1024
+ }
1025
+
1026
+ // Check 12: Rate limiting configured (5 pts)
1027
+ // Part 3 Rule 2: "Rate limit all public endpoints"
1028
+ const rateLimitLibs = ["express-rate-limit", "rate-limiter-flexible", "@nestjs/throttler", "bottleneck", "p-throttle", "limiter"];
1029
+ const foundRateLimit = rateLimitLibs.filter((lib) => allDeps.includes(lib));
1030
+
1031
+ let hasRateLimitCode = false;
1032
+ if (foundRateLimit.length === 0) {
1033
+ for (const [, content] of fileContents) {
1034
+ if (/(?:rateLimit|rate[_-]?limit|throttle|RateLimiter|rateLimiter|Retry-After)/i.test(content)) {
1035
+ hasRateLimitCode = true;
1036
+ break;
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ if (foundRateLimit.length > 0 || hasRateLimitCode) {
1042
+ checks.push(checkResult(
1043
+ "Rate limiting configured", "Part 3 Rule 2", 5, 5, "pass",
1044
+ `Rate limiting found: ${foundRateLimit.join(", ") || "detected in code"}`,
1045
+ ));
1046
+ } else if (pkgJson) {
1047
+ checks.push(checkResult(
1048
+ "Rate limiting configured", "Part 3 Rule 2", 5, 0, "warn",
1049
+ "No rate limiting detected. Add rate limiting to public endpoints.",
1050
+ [{ file: "package.json", message: "Install express-rate-limit or equivalent" }],
1051
+ ));
1052
+ } else {
1053
+ checks.push(checkResult(
1054
+ "Rate limiting configured", "Part 3 Rule 2", 5, 0, "skip",
1055
+ "No package.json found",
1056
+ ));
1057
+ }
1058
+
1059
+ // Check 13: CORS not using wildcard (5 pts)
1060
+ // Part 3: "CORS: specific origins only (never *)"
1061
+ const corsIssues = [];
1062
+ for (const [filePath, content] of fileContents) {
1063
+ const ext = path.extname(filePath).toLowerCase();
1064
+ if (!SCAN_SOURCE_EXTENSIONS.has(ext) && ext !== ".json") continue;
1065
+
1066
+ const wildcardPatterns = [
1067
+ /(?:origin|Access-Control-Allow-Origin)\s*[=:]\s*['"]\*['"]/gi,
1068
+ /cors\s*\(\s*\)/gi, // cors() with no config defaults to *
1069
+ ];
1070
+
1071
+ for (const wp of wildcardPatterns) {
1072
+ wp.lastIndex = 0;
1073
+ let match;
1074
+ while ((match = wp.exec(content)) !== null) {
1075
+ const line = content.substring(0, match.index).split("\n").length;
1076
+ corsIssues.push({
1077
+ file: relPath(projectDir, filePath),
1078
+ line,
1079
+ message: `CORS wildcard (*) at line ${line}`,
1080
+ });
1081
+ }
1082
+ }
1083
+ }
1084
+
1085
+ if (corsIssues.length === 0) {
1086
+ let hasCors = false;
1087
+ for (const [, content] of fileContents) {
1088
+ if (/cors|Access-Control/i.test(content)) { hasCors = true; break; }
1089
+ }
1090
+ if (hasCors) {
1091
+ checks.push(checkResult(
1092
+ "CORS properly configured", "Part 3", 5, 5, "pass",
1093
+ "CORS found with no wildcard origins",
1094
+ ));
1095
+ } else {
1096
+ checks.push(checkResult(
1097
+ "CORS properly configured", "Part 3", 5, 0, "skip",
1098
+ "No CORS configuration detected",
1099
+ ));
1100
+ }
1101
+ } else {
1102
+ checks.push(checkResult(
1103
+ "CORS properly configured", "Part 3", 5, 0, "fail",
1104
+ `${corsIssues.length} CORS wildcard (*) usage(s). Use specific origin whitelist.`,
1105
+ corsIssues,
1106
+ ));
1107
+ }
1108
+
1109
+ return { name: "Input & API", checks };
1110
+ }
1111
+
1112
+ // ---- Category 5: Security Headers & Transport (15 pts) ----
1113
+ // Security-Master.md Phase 2: Security Headers
1114
+ async function checkHeadersTransport(projectDir, fileContents) {
1115
+ const checks = [];
1116
+ const pkgJsonPath = path.join(projectDir, "package.json");
1117
+ const pkgContent = await safeReadFile(pkgJsonPath);
1118
+ let pkgJson = null;
1119
+ try { pkgJson = pkgContent ? JSON.parse(pkgContent) : null; } catch { /* ignore */ }
1120
+
1121
+ const allDeps = pkgJson
1122
+ ? Object.keys(pkgJson.dependencies || {}).concat(Object.keys(pkgJson.devDependencies || {}))
1123
+ : [];
1124
+
1125
+ // Check 14: Security headers configured (5 pts)
1126
+ // Phase 2: X-XSS-Protection, X-Content-Type-Options, X-Frame-Options, HSTS, CSP
1127
+ let hasHelmet = allDeps.includes("helmet");
1128
+ let hasSecurityHeaders = false;
1129
+ const headerKeywords = [
1130
+ "X-Content-Type-Options", "X-Frame-Options", "X-XSS-Protection",
1131
+ "Strict-Transport-Security", "Content-Security-Policy", "Referrer-Policy",
1132
+ "Permissions-Policy",
1133
+ ];
1134
+
1135
+ for (const [filePath, content] of fileContents) {
1136
+ const ext = path.extname(filePath).toLowerCase();
1137
+ if (!SCAN_SOURCE_EXTENSIONS.has(ext) && ext !== ".json" && ext !== ".js" && ext !== ".mjs") continue;
1138
+
1139
+ if (/helmet/i.test(content)) hasHelmet = true;
1140
+ for (const kw of headerKeywords) {
1141
+ if (content.includes(kw)) { hasSecurityHeaders = true; break; }
1142
+ }
1143
+ if (hasSecurityHeaders) break;
1144
+ }
1145
+
1146
+ // Check next.config.js/mjs for headers
1147
+ for (const cfgName of ["next.config.js", "next.config.mjs", "next.config.ts"]) {
1148
+ const cfgContent = await safeReadFile(path.join(projectDir, cfgName));
1149
+ if (cfgContent && /headers|X-Frame-Options|Content-Security-Policy/i.test(cfgContent)) {
1150
+ hasSecurityHeaders = true;
1151
+ }
1152
+ }
1153
+
1154
+ if (hasHelmet || hasSecurityHeaders) {
1155
+ checks.push(checkResult(
1156
+ "Security headers configured", "Phase 2", 5, 5, "pass",
1157
+ hasHelmet ? "Helmet middleware detected" : "Security headers found in configuration",
1158
+ ));
1159
+ } else if (pkgJson) {
1160
+ checks.push(checkResult(
1161
+ "Security headers configured", "Phase 2", 5, 0, "warn",
1162
+ "No security headers detected. Add helmet or configure headers manually.",
1163
+ [{ file: "package.json", message: "Install helmet or add security headers to your server config" }],
1164
+ ));
1165
+ } else {
1166
+ checks.push(checkResult(
1167
+ "Security headers configured", "Phase 2", 5, 0, "skip",
1168
+ "No server configuration detected",
1169
+ ));
1170
+ }
1171
+
1172
+ // Check 15: No internal error exposure (5 pts)
1173
+ // Part 3 Rule 4: "Never expose internal errors"
1174
+ const errorExposureIssues = [];
1175
+ for (const [filePath, content] of fileContents) {
1176
+ const ext = path.extname(filePath).toLowerCase();
1177
+ if (!SCAN_SOURCE_EXTENSIONS.has(ext)) continue;
1178
+ const baseName = path.basename(filePath).toLowerCase();
1179
+ if (baseName.includes("test") || baseName.includes("spec") || baseName.includes(".d.ts")) continue;
1180
+
1181
+ const patterns = [
1182
+ { name: "Stack trace in response", pattern: /(?:res\.(?:json|send|status)|Response\.json)\s*\([^)]*(?:error\.stack|err\.stack|\.stack)/gi },
1183
+ { name: "SQL error in response", pattern: /(?:res\.(?:json|send)|Response\.json)\s*\([^)]*(?:sql|query|sequelize|prisma).*error/gi },
1184
+ ];
1185
+
1186
+ for (const ep of patterns) {
1187
+ ep.pattern.lastIndex = 0;
1188
+ let match;
1189
+ while ((match = ep.pattern.exec(content)) !== null) {
1190
+ const line = content.substring(0, match.index).split("\n").length;
1191
+ errorExposureIssues.push({
1192
+ file: relPath(projectDir, filePath),
1193
+ line,
1194
+ message: `${ep.name} at line ${line}`,
1195
+ });
1196
+ }
1197
+ }
1198
+ }
1199
+
1200
+ if (errorExposureIssues.length === 0) {
1201
+ checks.push(checkResult(
1202
+ "No internal error exposure", "Part 3 Rule 4", 5, 5, "pass",
1203
+ "No stack traces or internal errors exposed in responses",
1204
+ ));
1205
+ } else {
1206
+ checks.push(checkResult(
1207
+ "No internal error exposure", "Part 3 Rule 4", 5, 0, "fail",
1208
+ `${errorExposureIssues.length} potential internal error exposure(s)`,
1209
+ errorExposureIssues,
1210
+ ));
1211
+ }
1212
+
1213
+ // Check 16: HTTPS enforcement (5 pts)
1214
+ let hasHttpsEnforcement = false;
1215
+ for (const [, content] of fileContents) {
1216
+ if (/(?:Strict-Transport-Security|HSTS|forceSSL|requireHTTPS|redirect.*https|https:\/\/)/i.test(content)) {
1217
+ hasHttpsEnforcement = true;
1218
+ break;
1219
+ }
1220
+ }
1221
+
1222
+ // Check for Vercel/Netlify/cloud deploy (auto-HTTPS)
1223
+ const cloudConfigs = ["vercel.json", "netlify.toml", "fly.toml", "render.yaml", "railway.json", "Procfile"];
1224
+ let hasCloudDeploy = false;
1225
+ for (const cf of cloudConfigs) {
1226
+ if (await fileExists(path.join(projectDir, cf))) {
1227
+ hasCloudDeploy = true;
1228
+ break;
1229
+ }
1230
+ }
1231
+
1232
+ if (hasHttpsEnforcement || hasCloudDeploy) {
1233
+ checks.push(checkResult(
1234
+ "HTTPS enforcement", "Phase 2", 5, 5, "pass",
1235
+ hasCloudDeploy ? "Cloud platform detected (auto-HTTPS)" : "HTTPS enforcement found in code",
1236
+ ));
1237
+ } else {
1238
+ checks.push(checkResult(
1239
+ "HTTPS enforcement", "Phase 2", 5, 3, "warn",
1240
+ "No explicit HTTPS enforcement detected. Ensure HTTPS in production.",
1241
+ ));
1242
+ }
1243
+
1244
+ return { name: "Headers & Transport", checks };
1245
+ }
1246
+
1247
+ // ---- Category 6: Database Security (15 pts) ----
1248
+ // Security-Master.md Part 1 Rule 4: Database access control
1249
+ // Security-Master.md Phase 8: Database Security
1250
+ async function checkDatabase(projectDir, fileContents) {
1251
+ const checks = [];
1252
+ const pkgJsonPath = path.join(projectDir, "package.json");
1253
+ const pkgContent = await safeReadFile(pkgJsonPath);
1254
+ let pkgJson = null;
1255
+ try { pkgJson = pkgContent ? JSON.parse(pkgContent) : null; } catch { /* ignore */ }
1256
+
1257
+ const allDeps = pkgJson
1258
+ ? Object.keys(pkgJson.dependencies || {}).concat(Object.keys(pkgJson.devDependencies || {}))
1259
+ : [];
1260
+
1261
+ let hasDbCode = false;
1262
+ for (const [, content] of fileContents) {
1263
+ if (/(?:prisma|sequelize|typeorm|mongoose|knex|pg|mysql|sqlite|mongodb|supabase|drizzle|\.query\(|\.execute\(|SELECT\s|INSERT\s|UPDATE\s|DELETE\s)/i.test(content)) {
1264
+ hasDbCode = true;
1265
+ break;
1266
+ }
1267
+ }
1268
+
1269
+ if (!hasDbCode) {
1270
+ checks.push(checkResult("ORM or parameterized queries", "Phase 8", 5, 0, "skip", "No database code detected"));
1271
+ checks.push(checkResult("No SQL injection patterns", "Phase 8", 5, 0, "skip", "No database code detected"));
1272
+ checks.push(checkResult("DB credentials not hardcoded", "Phase 8", 5, 0, "skip", "No database code detected"));
1273
+ return { name: "Database", checks };
1274
+ }
1275
+
1276
+ // Check 17: ORM or parameterized queries (5 pts)
1277
+ const ormLibs = ["prisma", "@prisma/client", "sequelize", "typeorm", "mongoose", "knex", "drizzle-orm", "@supabase/supabase-js", "objection", "bookshelf", "mikro-orm"];
1278
+ const foundOrm = ormLibs.filter((lib) => allDeps.includes(lib));
1279
+
1280
+ let hasOrmImport = false;
1281
+ if (foundOrm.length === 0) {
1282
+ for (const [, content] of fileContents) {
1283
+ if (/(?:from\s+['"](?:prisma|sequelize|typeorm|mongoose|knex|drizzle))/i.test(content)) {
1284
+ hasOrmImport = true;
1285
+ break;
1286
+ }
1287
+ }
1288
+ }
1289
+
1290
+ if (foundOrm.length > 0 || hasOrmImport) {
1291
+ checks.push(checkResult(
1292
+ "ORM or parameterized queries", "Phase 8", 5, 5, "pass",
1293
+ `ORM detected: ${foundOrm.join(", ") || "detected in imports"}`,
1294
+ ));
1295
+ } else {
1296
+ checks.push(checkResult(
1297
+ "ORM or parameterized queries", "Phase 8", 5, 0, "warn",
1298
+ "No ORM detected. Ensure parameterized queries are used for all database operations.",
1299
+ ));
1300
+ }
1301
+
1302
+ // Check 18: No SQL injection patterns (5 pts)
1303
+ const sqlInjectionIssues = [];
1304
+ for (const [filePath, content] of fileContents) {
1305
+ const ext = path.extname(filePath).toLowerCase();
1306
+ if (!SCAN_SOURCE_EXTENSIONS.has(ext)) continue;
1307
+ const baseName = path.basename(filePath).toLowerCase();
1308
+ if (baseName.includes("test") || baseName.includes("spec") || baseName.includes("migration")) continue;
1309
+
1310
+ const patterns = [
1311
+ { name: "Template literal in SQL query", pattern: /(?:query|execute|raw)\s*\(\s*`[^`]*(?:SELECT|INSERT|UPDATE|DELETE)[^`]*\$\{/gis },
1312
+ { name: "String concatenation in SQL", pattern: /(?:query|execute|raw)\s*\(\s*['"](?:SELECT|INSERT|UPDATE|DELETE)[^'"]*['"]\s*\+\s*(?!['"])/gis },
1313
+ ];
1314
+
1315
+ for (const sp of patterns) {
1316
+ sp.pattern.lastIndex = 0;
1317
+ let match;
1318
+ while ((match = sp.pattern.exec(content)) !== null) {
1319
+ const line = content.substring(0, match.index).split("\n").length;
1320
+ sqlInjectionIssues.push({
1321
+ file: relPath(projectDir, filePath),
1322
+ line,
1323
+ message: `${sp.name} at line ${line}`,
1324
+ });
1325
+ }
1326
+ }
1327
+ }
1328
+
1329
+ if (sqlInjectionIssues.length === 0) {
1330
+ checks.push(checkResult(
1331
+ "No SQL injection patterns", "Phase 8", 5, 5, "pass",
1332
+ "No SQL injection patterns detected",
1333
+ ));
1334
+ } else {
1335
+ checks.push(checkResult(
1336
+ "No SQL injection patterns", "Phase 8", 5, 0, "fail",
1337
+ `${sqlInjectionIssues.length} potential SQL injection pattern(s). Use parameterized queries.`,
1338
+ sqlInjectionIssues,
1339
+ ));
1340
+ }
1341
+
1342
+ // Check 19: DB credentials not hardcoded (5 pts)
1343
+ const dbCredIssues = [];
1344
+ for (const [filePath, content] of fileContents) {
1345
+ const ext = path.extname(filePath).toLowerCase();
1346
+ if (!SCAN_SOURCE_EXTENSIONS.has(ext)) continue;
1347
+ const baseName = path.basename(filePath);
1348
+ if (SECRET_SCAN_EXCLUDE_FILES.has(baseName)) continue;
1349
+
1350
+ const patterns = [
1351
+ { name: "Hardcoded DB connection string", pattern: /(?:postgres|postgresql|mysql|mongodb|mongodb\+srv|redis):\/\/[a-zA-Z0-9_]+:[^@\s'"]+@[^\s'"]+/gi },
1352
+ { name: "Hardcoded DB password", pattern: /(?:DB_PASSWORD|DATABASE_PASSWORD|MONGO_PASSWORD|PG_PASSWORD|MYSQL_PASSWORD)\s*[=:]\s*['"][^'"]{4,}['"]/gi },
1353
+ ];
1354
+
1355
+ for (const dp of patterns) {
1356
+ dp.pattern.lastIndex = 0;
1357
+ let match;
1358
+ while ((match = dp.pattern.exec(content)) !== null) {
1359
+ const context = content.substring(Math.max(0, match.index - 30), match.index + match[0].length + 30);
1360
+ if (/process\.env|os\.environ|ENV\[/i.test(context)) continue;
1361
+
1362
+ const line = content.substring(0, match.index).split("\n").length;
1363
+ dbCredIssues.push({
1364
+ file: relPath(projectDir, filePath),
1365
+ line,
1366
+ message: `${dp.name} at line ${line}`,
1367
+ });
1368
+ }
1369
+ }
1370
+ }
1371
+
1372
+ if (dbCredIssues.length === 0) {
1373
+ checks.push(checkResult(
1374
+ "DB credentials not hardcoded", "Phase 8", 5, 5, "pass",
1375
+ "No hardcoded database credentials found",
1376
+ ));
1377
+ } else {
1378
+ checks.push(checkResult(
1379
+ "DB credentials not hardcoded", "Phase 8", 5, 0, "fail",
1380
+ `${dbCredIssues.length} hardcoded DB credential(s). Use environment variables.`,
1381
+ dbCredIssues,
1382
+ ));
1383
+ }
1384
+
1385
+ return { name: "Database", checks };
1386
+ }
1387
+
1388
+ // ---- Utility helpers for scanner ----
1389
+
1390
+ function relPath(projectDir, filePath) {
1391
+ return path.relative(projectDir, filePath).replace(/\\/g, "/");
1392
+ }
1393
+
1394
+ function mask(value) {
1395
+ if (value.length <= 12) return value.substring(0, 4) + "****";
1396
+ return value.substring(0, 8) + "****" + value.substring(value.length - 4);
1397
+ }
1398
+
1399
+ function progressBar(percent, width) {
1400
+ const filled = Math.round((percent / 100) * width);
1401
+ const empty = width - filled;
1402
+ return "[" + "=".repeat(filled) + " ".repeat(empty) + "]";
1403
+ }
1404
+
1405
+ function statusLabel(status) {
1406
+ switch (status) {
1407
+ case "pass": return "PASS";
1408
+ case "fail": return "FAIL";
1409
+ case "warn": return "WARN";
1410
+ case "skip": return "SKIP";
1411
+ default: return status.toUpperCase();
1412
+ }
1413
+ }
1414
+
1415
+ function categoryStatus(checks) {
1416
+ if (checks.some((c) => c.status === "fail")) return "fail";
1417
+ if (checks.some((c) => c.status === "warn")) return "warn";
1418
+ if (checks.every((c) => c.status === "skip")) return "skip";
1419
+ return "pass";
1420
+ }
1421
+
1422
+ // ---- Main scanner orchestrator ----
1423
+
1424
+ async function runSecurityScan(projectDir) {
1425
+ // Collect all files
1426
+ const filePaths = await walkProjectFiles(projectDir);
1427
+
1428
+ // Read all files into memory
1429
+ const fileContents = new Map();
1430
+ for (const fp of filePaths) {
1431
+ const content = await safeReadFile(fp);
1432
+ if (content !== null) {
1433
+ fileContents.set(fp, content);
1434
+ }
1435
+ }
1436
+
1437
+ console.log(` Scanned ${fileContents.size} file(s)\n`);
1438
+
1439
+ // Run all category checks
1440
+ const categories = [
1441
+ await checkSecretsAndEnv(projectDir, fileContents),
1442
+ await checkDependencies(projectDir, fileContents),
1443
+ await checkAuthSessions(projectDir, fileContents),
1444
+ await checkInputApi(projectDir, fileContents),
1445
+ await checkHeadersTransport(projectDir, fileContents),
1446
+ await checkDatabase(projectDir, fileContents),
1447
+ ];
1448
+
1449
+ // Calculate scores
1450
+ let totalPoints = 0;
1451
+ let totalEarned = 0;
1452
+ let applicablePoints = 0;
1453
+ let totalIssues = 0;
1454
+ let totalWarnings = 0;
1455
+
1456
+ for (const category of categories) {
1457
+ for (const check of category.checks) {
1458
+ totalPoints += check.points;
1459
+ if (check.status !== "skip") {
1460
+ applicablePoints += check.points;
1461
+ totalEarned += check.earned;
1462
+ }
1463
+ if (check.status === "fail") totalIssues += check.issues.length || 1;
1464
+ if (check.status === "warn") totalWarnings += 1;
1465
+ }
1466
+ }
1467
+
1468
+ const percentage = applicablePoints > 0 ? Math.round((totalEarned / applicablePoints) * 100) : 100;
1469
+
1470
+ return {
1471
+ categories,
1472
+ totalPoints,
1473
+ totalEarned,
1474
+ applicablePoints,
1475
+ percentage,
1476
+ totalIssues,
1477
+ totalWarnings,
1478
+ projectDir,
1479
+ timestamp: new Date().toISOString(),
1480
+ filesScanned: fileContents.size,
1481
+ };
1482
+ }
1483
+
1484
+ // ---- CLI output formatter ----
1485
+
1486
+ function printScanResults(results) {
1487
+ console.log(" YEKNAL SECURITY SCAN");
1488
+ console.log(" ====================\n");
1489
+
1490
+ const idx = { n: 0 };
1491
+ const totalCats = results.categories.length;
1492
+
1493
+ for (const category of results.categories) {
1494
+ idx.n++;
1495
+ const catEarned = category.checks.reduce((sum, c) => sum + (c.status !== "skip" ? c.earned : 0), 0);
1496
+ const catApplicable = category.checks.reduce((sum, c) => sum + (c.status !== "skip" ? c.points : 0), 0);
1497
+ const catStatus = categoryStatus(category.checks);
1498
+
1499
+ if (catApplicable === 0) {
1500
+ console.log(` [${idx.n}/${totalCats}] ${padEnd(category.name, 28)} ${"--/--".padStart(7)} ${statusLabel("skip")}`);
1501
+ } else {
1502
+ const catPct = Math.round((catEarned / catApplicable) * 100);
1503
+ const scoreStr = `${catEarned}/${catApplicable}`.padStart(7);
1504
+ console.log(` [${idx.n}/${totalCats}] ${padEnd(category.name, 28)} ${scoreStr} ${statusLabel(catStatus)}`);
1505
+ }
1506
+ }
1507
+
1508
+ console.log("\n " + "-".repeat(50));
1509
+ console.log(` Overall: ${results.totalEarned}/${results.applicablePoints} applicable points`);
1510
+ console.log(`\n Security Score: ${results.percentage}%`);
1511
+ console.log(" " + progressBar(results.percentage, 30));
1512
+
1513
+ if (results.totalIssues > 0 || results.totalWarnings > 0) {
1514
+ const parts = [];
1515
+ if (results.totalIssues > 0) parts.push(`${results.totalIssues} issue(s)`);
1516
+ if (results.totalWarnings > 0) parts.push(`${results.totalWarnings} warning(s)`);
1517
+ console.log(`\n ${parts.join(", ")} found`);
1518
+ } else {
1519
+ console.log("\n No issues found");
1520
+ }
1521
+ }
554
1522
 
555
- if (category === "security") {
556
- await runSecurityAudit();
1523
+ function padEnd(str, len) {
1524
+ while (str.length < len) str += " ";
1525
+ return str;
1526
+ }
1527
+
1528
+ // ---- Detailed log generator ----
1529
+
1530
+ function generateSecurityLog(results) {
1531
+ const lines = [];
1532
+
1533
+ lines.push("=".repeat(60));
1534
+ lines.push("YEKNAL SECURITY SCAN REPORT");
1535
+ lines.push("=".repeat(60));
1536
+ lines.push("");
1537
+ lines.push(`Date: ${results.timestamp}`);
1538
+ lines.push(`Project: ${results.projectDir}`);
1539
+ lines.push(`Files scanned: ${results.filesScanned}`);
1540
+ lines.push(`Security Score: ${results.percentage}% (${results.totalEarned}/${results.applicablePoints} applicable points)`);
1541
+ lines.push(`Issues: ${results.totalIssues}`);
1542
+ lines.push(`Warnings: ${results.totalWarnings}`);
1543
+ lines.push("");
1544
+ lines.push("Based on: Security-Master.md (yeknal security guidelines)");
1545
+ lines.push("Reference: https://github.com/tryraisins/MD_Files/blob/main/Security/Security-Master.md");
1546
+ lines.push("");
1547
+
1548
+ for (const category of results.categories) {
1549
+ lines.push("-".repeat(60));
1550
+ lines.push(`CATEGORY: ${category.name.toUpperCase()}`);
1551
+ lines.push("-".repeat(60));
1552
+ lines.push("");
1553
+
1554
+ for (const check of category.checks) {
1555
+ const icon = check.status === "pass" ? "[PASS]" : check.status === "fail" ? "[FAIL]" : check.status === "warn" ? "[WARN]" : "[SKIP]";
1556
+ lines.push(` ${icon} ${check.name}`);
1557
+ lines.push(` Points: ${check.earned}/${check.points} | Reference: Security-Master.md ${check.reference}`);
1558
+ lines.push(` ${check.details}`);
1559
+
1560
+ if (check.issues && check.issues.length > 0) {
1561
+ lines.push("");
1562
+ for (const issue of check.issues) {
1563
+ const loc = issue.line ? `${issue.file}:${issue.line}` : issue.file;
1564
+ lines.push(` -> ${loc}`);
1565
+ lines.push(` ${issue.message}`);
1566
+ if (issue.match) {
1567
+ lines.push(` Value: ${issue.match}`);
1568
+ }
1569
+ }
1570
+ }
1571
+
1572
+ lines.push("");
1573
+ }
1574
+ }
1575
+
1576
+ // Recommendations
1577
+ lines.push("=".repeat(60));
1578
+ lines.push("RECOMMENDATIONS");
1579
+ lines.push("=".repeat(60));
1580
+ lines.push("");
1581
+
1582
+ const failedChecks = [];
1583
+ const warnChecks = [];
1584
+ for (const category of results.categories) {
1585
+ for (const check of category.checks) {
1586
+ if (check.status === "fail") failedChecks.push(check);
1587
+ if (check.status === "warn") warnChecks.push(check);
1588
+ }
557
1589
  }
1590
+
1591
+ if (failedChecks.length > 0) {
1592
+ lines.push("CRITICAL (fix before shipping):");
1593
+ for (const check of failedChecks) {
1594
+ lines.push(` - ${check.name}: ${check.details}`);
1595
+ }
1596
+ lines.push("");
1597
+ }
1598
+
1599
+ if (warnChecks.length > 0) {
1600
+ lines.push("WARNINGS (should address):");
1601
+ for (const check of warnChecks) {
1602
+ lines.push(` - ${check.name}: ${check.details}`);
1603
+ }
1604
+ lines.push("");
1605
+ }
1606
+
1607
+ if (failedChecks.length === 0 && warnChecks.length === 0) {
1608
+ lines.push("No critical issues or warnings. Security posture looks good.");
1609
+ lines.push("");
1610
+ }
1611
+
1612
+ lines.push("=".repeat(60));
1613
+ lines.push("END OF REPORT");
1614
+ lines.push("=".repeat(60));
1615
+ lines.push("");
1616
+
1617
+ return lines.join("\n");
558
1618
  }
559
1619
 
1620
+ // ==========================================
1621
+ // SECURITY SKILL SYNC
1622
+ // ==========================================
1623
+
1624
+ async function syncSecuritySkills(targets) {
1625
+ console.log("\nSyncing security skills to agent directories...");
1626
+
1627
+ let repoTree;
1628
+ let useClone = false;
1629
+
1630
+ try {
1631
+ repoTree = await fetchRepoTree();
1632
+ } catch (error) {
1633
+ const message = error && error.message ? error.message : String(error);
1634
+ if (message.includes("GitHub API rate limit exceeded")) {
1635
+ console.log(" GitHub API rate-limited. Falling back to git clone...");
1636
+ useClone = true;
1637
+ } else {
1638
+ throw error;
1639
+ }
1640
+ }
1641
+
1642
+ const tempRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "yeknal-sec-skills-"));
1643
+
1644
+ try {
1645
+ if (useClone) {
1646
+ const cloneRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "yeknal-sec-repo-"));
1647
+ const repoPath = path.join(cloneRoot, "repo");
1648
+ const cloneUrl = `https://github.com/${GITHUB_USERNAME}/${GITHUB_REPO}.git`;
1649
+
1650
+ try {
1651
+ await execCommand(`git clone --depth 1 --branch ${BRANCH} ${cloneUrl} "${repoPath}"`);
1652
+ for (const folder of SECURITY_REPO_FOLDERS) {
1653
+ const src = path.join(repoPath, folder);
1654
+ if (await isDirectory(src)) {
1655
+ await copyDirRecursive(src, path.join(tempRoot, folder));
1656
+ }
1657
+ }
1658
+ } finally {
1659
+ await fsp.rm(cloneRoot, { recursive: true, force: true });
1660
+ }
1661
+ } else {
1662
+ for (const folder of SECURITY_REPO_FOLDERS) {
1663
+ const files = listFilesForFolder(repoTree, folder);
1664
+ for (const repoPath of files) {
1665
+ const relativePath = repoPath.slice(folder.length + 1);
1666
+ const destPath = path.join(tempRoot, folder, relativePath);
1667
+ await downloadUrlToFile(buildRawFileUrl(repoPath), destPath);
1668
+ }
1669
+ }
1670
+ }
1671
+
1672
+ // Install to each target
1673
+ let syncCount = 0;
1674
+ for (const target of targets) {
1675
+ try {
1676
+ await fsp.mkdir(target.skillsPath, { recursive: true });
1677
+ for (const folder of SECURITY_REPO_FOLDERS) {
1678
+ const src = path.join(tempRoot, folder);
1679
+ if (await isDirectory(src)) {
1680
+ const dest = path.join(target.skillsPath, folder);
1681
+ await fsp.rm(dest, { recursive: true, force: true });
1682
+ await copyDirRecursive(src, dest);
1683
+ }
1684
+ }
1685
+ syncCount++;
1686
+ console.log(` [ok] ${target.label}: synced security skills to ${target.skillsPath}`);
1687
+ } catch (error) {
1688
+ console.error(` [error] ${target.label}: ${error.message}`);
1689
+ }
1690
+ }
1691
+
1692
+ return syncCount;
1693
+ } finally {
1694
+ await fsp.rm(tempRoot, { recursive: true, force: true });
1695
+ }
1696
+ }
1697
+
1698
+ // ==========================================
1699
+ // SECURITY COMMAND (COMBINED)
1700
+ // ==========================================
1701
+
1702
+ async function runSecurityCommand() {
1703
+ const projectDir = process.cwd();
1704
+
1705
+ console.log("\n yeknal security");
1706
+ console.log(" ===============\n");
1707
+
1708
+ // Step 1: Download Security-Master.md to current directory
1709
+ const masterUrl = `${RAW_BASE_URL}/Security/Security-Master.md`;
1710
+ const masterDest = path.join(projectDir, "Security-Master.md");
1711
+ console.log(" Downloading Security-Master.md...");
1712
+ try {
1713
+ await downloadUrlToFile(masterUrl, masterDest);
1714
+ console.log(` Saved to: ${masterDest}`);
1715
+ } catch (error) {
1716
+ console.error(` Could not download Security-Master.md: ${error.message}`);
1717
+ }
1718
+
1719
+ // Step 2: Sync security skills to agent directories
1720
+ const targets = await resolveSkillTargets();
1721
+ if (targets.length > 0) {
1722
+ await syncSecuritySkills(targets);
1723
+ } else {
1724
+ console.log("\n No agent directories found for skill sync.");
1725
+ }
1726
+
1727
+ // Step 3: Run security scan on current project
1728
+ console.log(`\n Scanning: ${projectDir}\n`);
1729
+ const results = await runSecurityScan(projectDir);
1730
+
1731
+ // Step 4: Print results to CLI
1732
+ printScanResults(results);
1733
+
1734
+ // Step 5: Write detailed log
1735
+ const logPath = path.join(projectDir, "yeknal-security.log");
1736
+ const logContent = generateSecurityLog(results);
1737
+ fs.writeFileSync(logPath, logContent);
1738
+ console.log(`\n Full report: ${logPath}\n`);
1739
+
1740
+ // Exit with error code if critical issues found
1741
+ if (results.totalIssues > 0) {
1742
+ process.exitCode = 1;
1743
+ }
1744
+ }
1745
+
1746
+ // ==========================================
1747
+ // MAIN
1748
+ // ==========================================
1749
+
560
1750
  async function main() {
561
1751
  const args = process.argv.slice(2);
562
1752
  const command = args[0] ? args[0].toLowerCase() : null;
@@ -572,6 +1762,11 @@ async function main() {
572
1762
  return;
573
1763
  }
574
1764
 
1765
+ if (command === "security") {
1766
+ await runSecurityCommand();
1767
+ return;
1768
+ }
1769
+
575
1770
  if (singleFileConfigs[command]) {
576
1771
  await runSingleFileTemplateCommand(command);
577
1772
  return;