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.
- package/bin/yeknal.js +1320 -125
- 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
|
|
117
|
-
` Bash/zsh: export YEKNAL_GITHUB_TOKEN
|
|
118
|
-
`Then rerun: npx yeknal
|
|
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
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
425
|
-
|
|
444
|
+
// ==========================================
|
|
445
|
+
// SECURITY SCANNER
|
|
446
|
+
// Based on Security-Master.md rules
|
|
447
|
+
// ==========================================
|
|
426
448
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
434
|
-
|
|
513
|
+
for (const entry of entries) {
|
|
514
|
+
const fullPath = path.join(dir, entry.name);
|
|
435
515
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
465
|
-
}
|
|
532
|
+
} catch { /* skip unreadable */ }
|
|
466
533
|
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
467
537
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
509
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
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
|
-
|
|
542
|
-
});
|
|
543
|
-
});
|
|
831
|
+
return { name: "Dependencies", checks };
|
|
544
832
|
}
|
|
545
833
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
const
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
556
|
-
|
|
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;
|