yeknal 1.1.3 → 1.1.5
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 +250 -0
- package/package.json +1 -1
package/bin/yeknal.js
CHANGED
|
@@ -1405,6 +1405,249 @@ async function checkDatabase(projectDir, fileContents) {
|
|
|
1405
1405
|
return { name: "Database", checks };
|
|
1406
1406
|
}
|
|
1407
1407
|
|
|
1408
|
+
// ---- Category 7: Frontend Code Security (15 pts) ----
|
|
1409
|
+
// security-best-practices references: javascript-general, react, vue, next.js
|
|
1410
|
+
async function checkFrontendSecurity(projectDir, fileContents) {
|
|
1411
|
+
const checks = [];
|
|
1412
|
+
|
|
1413
|
+
const jsTsFiles = [...fileContents.entries()].filter(([fp]) => {
|
|
1414
|
+
const ext = path.extname(fp).toLowerCase();
|
|
1415
|
+
return [".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs", ".vue"].includes(ext);
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
if (jsTsFiles.length === 0) {
|
|
1419
|
+
checks.push(checkResult("No dynamic code execution", "security-best-practices/js-general", 5, 0, "skip", "No JS/TS files found"));
|
|
1420
|
+
checks.push(checkResult("No unsafe HTML injection sinks", "security-best-practices/react-vue", 5, 0, "skip", "No JS/TS files found"));
|
|
1421
|
+
checks.push(checkResult("No client-side secret exposure", "security-best-practices/next-react-vue", 5, 0, "skip", "No JS/TS files found"));
|
|
1422
|
+
return { name: "Frontend Security", checks };
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// Check: No eval() / new Function() with dynamic args (5 pts)
|
|
1426
|
+
const evalIssues = [];
|
|
1427
|
+
for (const [filePath, content] of jsTsFiles) {
|
|
1428
|
+
// Exclude test files and comments
|
|
1429
|
+
const lines = content.split("\n");
|
|
1430
|
+
lines.forEach((line, idx) => {
|
|
1431
|
+
const trimmed = line.trim();
|
|
1432
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("*")) return;
|
|
1433
|
+
// eval(variable) — skip eval("literal string") as those are lower risk
|
|
1434
|
+
if (/\beval\s*\(\s*(?!['"`])/.test(line)) {
|
|
1435
|
+
evalIssues.push({ file: relPath(projectDir, filePath), line: idx + 1, message: "eval() with dynamic argument" });
|
|
1436
|
+
}
|
|
1437
|
+
// new Function(variable...) — skip new Function() with all literals
|
|
1438
|
+
if (/new\s+Function\s*\([^)]*(?:req\.|res\.|params|query|body|input|user|data)/.test(line)) {
|
|
1439
|
+
evalIssues.push({ file: relPath(projectDir, filePath), line: idx + 1, message: "new Function() with dynamic argument" });
|
|
1440
|
+
}
|
|
1441
|
+
// setTimeout/setInterval with a string argument containing a variable
|
|
1442
|
+
if (/(?:setTimeout|setInterval)\s*\(\s*(?!['"`])/.test(line) && !/(?:setTimeout|setInterval)\s*\(\s*(?:function|\()/.test(line)) {
|
|
1443
|
+
evalIssues.push({ file: relPath(projectDir, filePath), line: idx + 1, message: "setTimeout/setInterval with string argument" });
|
|
1444
|
+
}
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
if (evalIssues.length === 0) {
|
|
1449
|
+
checks.push(checkResult("No dynamic code execution", "security-best-practices/js-general", 5, 5, "pass", "No eval() or dynamic Function() usage detected"));
|
|
1450
|
+
} else {
|
|
1451
|
+
checks.push(checkResult(
|
|
1452
|
+
"No dynamic code execution", "security-best-practices/js-general", 5, 0, "fail",
|
|
1453
|
+
`${evalIssues.length} instance(s) of dynamic code execution (eval/Function/setTimeout). Refactor to static functions.`,
|
|
1454
|
+
evalIssues,
|
|
1455
|
+
));
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// Check: No unsafe HTML injection sinks — dangerouslySetInnerHTML, v-html (5 pts)
|
|
1459
|
+
const htmlSinkIssues = [];
|
|
1460
|
+
const sanitizers = ["DOMPurify", "sanitizeHtml", "sanitize-html", "xss", "isomorphic-dompurify"];
|
|
1461
|
+
|
|
1462
|
+
for (const [filePath, content] of jsTsFiles) {
|
|
1463
|
+
const hasSanitizer = sanitizers.some((s) => content.includes(s));
|
|
1464
|
+
const lines = content.split("\n");
|
|
1465
|
+
|
|
1466
|
+
lines.forEach((line, idx) => {
|
|
1467
|
+
const trimmed = line.trim();
|
|
1468
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("*")) return;
|
|
1469
|
+
|
|
1470
|
+
// React dangerouslySetInnerHTML
|
|
1471
|
+
if (/dangerouslySetInnerHTML/.test(line) && !hasSanitizer) {
|
|
1472
|
+
htmlSinkIssues.push({ file: relPath(projectDir, filePath), line: idx + 1, message: "dangerouslySetInnerHTML without sanitizer" });
|
|
1473
|
+
}
|
|
1474
|
+
// Vue v-html
|
|
1475
|
+
if (/v-html\s*=/.test(line) && !hasSanitizer) {
|
|
1476
|
+
htmlSinkIssues.push({ file: relPath(projectDir, filePath), line: idx + 1, message: "v-html without sanitizer (DOMPurify etc.)" });
|
|
1477
|
+
}
|
|
1478
|
+
// jQuery .html() with variable
|
|
1479
|
+
if (/\$\(.*\)\.html\s*\(\s*(?!['"`])/.test(line) || /\.html\s*\(\s*(?:req\.|res\.|params|query|body|user|data|input)/.test(line)) {
|
|
1480
|
+
htmlSinkIssues.push({ file: relPath(projectDir, filePath), line: idx + 1, message: "jQuery .html() with dynamic content" });
|
|
1481
|
+
}
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
if (htmlSinkIssues.length === 0) {
|
|
1486
|
+
checks.push(checkResult("No unsafe HTML injection sinks", "security-best-practices/react-vue", 5, 5, "pass", "No unsafe innerHTML/dangerouslySetInnerHTML/v-html sinks detected"));
|
|
1487
|
+
} else {
|
|
1488
|
+
checks.push(checkResult(
|
|
1489
|
+
"No unsafe HTML injection sinks", "security-best-practices/react-vue", 5, 0, "fail",
|
|
1490
|
+
`${htmlSinkIssues.length} unsafe HTML sink(s). Use a sanitizer like DOMPurify before injecting HTML.`,
|
|
1491
|
+
htmlSinkIssues,
|
|
1492
|
+
));
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// Check: No client-side secret exposure via env vars (5 pts)
|
|
1496
|
+
// NEXT_PUBLIC_*, REACT_APP_*, VITE_* should never hold real secrets
|
|
1497
|
+
const clientSecretIssues = [];
|
|
1498
|
+
const clientSecretPattern = /(?:NEXT_PUBLIC_|REACT_APP_|VITE_)(?:SECRET|KEY|TOKEN|PASSWORD|PASS|PWD|API_KEY|PRIVATE|AUTH)\s*[=:]\s*['"][^'"]{8,}['"]/gi;
|
|
1499
|
+
|
|
1500
|
+
for (const [filePath, content] of fileContents) {
|
|
1501
|
+
const base = path.basename(filePath).toLowerCase();
|
|
1502
|
+
// Only check .env files and source files, skip .env.example/.env.sample
|
|
1503
|
+
if (base.includes("example") || base.includes("sample") || base.includes("template")) continue;
|
|
1504
|
+
|
|
1505
|
+
let match;
|
|
1506
|
+
clientSecretPattern.lastIndex = 0;
|
|
1507
|
+
while ((match = clientSecretPattern.exec(content)) !== null) {
|
|
1508
|
+
const line = content.substring(0, match.index).split("\n").length;
|
|
1509
|
+
clientSecretIssues.push({ file: relPath(projectDir, filePath), line, message: `Client-exposed secret: ${match[0].substring(0, 40)}...` });
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
if (clientSecretIssues.length === 0) {
|
|
1514
|
+
checks.push(checkResult("No client-side secret exposure", "security-best-practices/next-react-vue", 5, 5, "pass", "No secrets found in client-exposed env vars (NEXT_PUBLIC_, REACT_APP_, VITE_)"));
|
|
1515
|
+
} else {
|
|
1516
|
+
checks.push(checkResult(
|
|
1517
|
+
"No client-side secret exposure", "security-best-practices/next-react-vue", 5, 0, "fail",
|
|
1518
|
+
`${clientSecretIssues.length} secret(s) exposed via client-side env vars. Move to server-only env vars.`,
|
|
1519
|
+
clientSecretIssues,
|
|
1520
|
+
));
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
return { name: "Frontend Security", checks };
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// ---- Category 8: Framework Configuration Security (13 pts) ----
|
|
1527
|
+
// security-best-practices references: django, flask, express, next.js
|
|
1528
|
+
async function checkFrameworkConfig(projectDir, fileContents) {
|
|
1529
|
+
const checks = [];
|
|
1530
|
+
|
|
1531
|
+
// Detect frameworks present
|
|
1532
|
+
const allContent = [...fileContents.values()].join("\n");
|
|
1533
|
+
const hasDjango = /(?:from django|import django|DJANGO_SETTINGS_MODULE|django\.conf)/i.test(allContent);
|
|
1534
|
+
const hasFlask = /(?:from flask|import flask|Flask\(__name__\))/i.test(allContent);
|
|
1535
|
+
const hasExpress = /(?:require\(['"]express['"]\)|from ['"]express['"])/i.test(allContent);
|
|
1536
|
+
const hasPython = [...fileContents.keys()].some((fp) => fp.endsWith(".py"));
|
|
1537
|
+
const hasJS = [...fileContents.keys()].some((fp) => [".js", ".ts", ".mjs"].includes(path.extname(fp)));
|
|
1538
|
+
|
|
1539
|
+
// Check: Django ALLOWED_HOSTS / DEBUG misconfiguration (5 pts)
|
|
1540
|
+
if (hasDjango) {
|
|
1541
|
+
const djangoIssues = [];
|
|
1542
|
+
|
|
1543
|
+
for (const [filePath, content] of fileContents) {
|
|
1544
|
+
if (!filePath.endsWith(".py")) continue;
|
|
1545
|
+
const lines = content.split("\n");
|
|
1546
|
+
|
|
1547
|
+
lines.forEach((line, idx) => {
|
|
1548
|
+
const trimmed = line.trim();
|
|
1549
|
+
if (trimmed.startsWith("#")) return;
|
|
1550
|
+
if (/ALLOWED_HOSTS\s*=\s*\[.*['"]\*['"]/.test(line)) {
|
|
1551
|
+
djangoIssues.push({ file: relPath(projectDir, filePath), line: idx + 1, message: "ALLOWED_HOSTS = ['*'] allows any host — set explicit domains in production" });
|
|
1552
|
+
}
|
|
1553
|
+
if (/^\s*DEBUG\s*=\s*True/.test(line) && !filePath.includes("test") && !filePath.includes("dev") && !filePath.includes("local")) {
|
|
1554
|
+
djangoIssues.push({ file: relPath(projectDir, filePath), line: idx + 1, message: "DEBUG = True — ensure this is not active in production" });
|
|
1555
|
+
}
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
if (djangoIssues.length === 0) {
|
|
1560
|
+
checks.push(checkResult("Django: safe ALLOWED_HOSTS / DEBUG", "security-best-practices/django", 5, 5, "pass", "No ALLOWED_HOSTS=['*'] or unconditional DEBUG=True detected"));
|
|
1561
|
+
} else {
|
|
1562
|
+
checks.push(checkResult(
|
|
1563
|
+
"Django: safe ALLOWED_HOSTS / DEBUG", "security-best-practices/django", 5, 0, "fail",
|
|
1564
|
+
`${djangoIssues.length} Django configuration issue(s). Wildcard hosts and DEBUG=True expose the application.`,
|
|
1565
|
+
djangoIssues,
|
|
1566
|
+
));
|
|
1567
|
+
}
|
|
1568
|
+
} else if (hasPython) {
|
|
1569
|
+
checks.push(checkResult("Django: safe ALLOWED_HOSTS / DEBUG", "security-best-practices/django", 5, 0, "skip", "Django not detected"));
|
|
1570
|
+
} else {
|
|
1571
|
+
checks.push(checkResult("Django: safe ALLOWED_HOSTS / DEBUG", "security-best-practices/django", 5, 0, "skip", "No Python project detected"));
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// Check: No server-side template injection (SSTI) patterns (5 pts)
|
|
1575
|
+
const sstiIssues = [];
|
|
1576
|
+
|
|
1577
|
+
for (const [filePath, content] of fileContents) {
|
|
1578
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
1579
|
+
if (![".py", ".js", ".ts", ".mjs"].includes(ext)) continue;
|
|
1580
|
+
|
|
1581
|
+
const lines = content.split("\n");
|
|
1582
|
+
lines.forEach((line, idx) => {
|
|
1583
|
+
const trimmed = line.trim();
|
|
1584
|
+
if (trimmed.startsWith("#") || trimmed.startsWith("//") || trimmed.startsWith("*")) return;
|
|
1585
|
+
|
|
1586
|
+
// Flask/Jinja2: render_template_string(request.* or user input)
|
|
1587
|
+
if (/render_template_string\s*\(.*(?:request\.|form\[|args\[|json\[|data\[)/.test(line)) {
|
|
1588
|
+
sstiIssues.push({ file: relPath(projectDir, filePath), line: idx + 1, message: "render_template_string() with user-controlled input (SSTI)" });
|
|
1589
|
+
}
|
|
1590
|
+
// Django: Template(user_input)
|
|
1591
|
+
if (/Template\s*\(.*(?:request\.|GET\[|POST\[|data\[)/.test(line)) {
|
|
1592
|
+
sstiIssues.push({ file: relPath(projectDir, filePath), line: idx + 1, message: "Django Template() with user-controlled input (SSTI)" });
|
|
1593
|
+
}
|
|
1594
|
+
// Express: res.render(req.query.* or req.body.*)
|
|
1595
|
+
if (/res\.render\s*\(\s*req\.(?:query|body|params)/.test(line)) {
|
|
1596
|
+
sstiIssues.push({ file: relPath(projectDir, filePath), line: idx + 1, message: "res.render() with user-controlled template name (SSTI)" });
|
|
1597
|
+
}
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
if (sstiIssues.length === 0) {
|
|
1602
|
+
checks.push(checkResult("No server-side template injection", "security-best-practices/flask-express", 5, 5, "pass", "No SSTI patterns (render_template_string/res.render with user input) detected"));
|
|
1603
|
+
} else {
|
|
1604
|
+
checks.push(checkResult(
|
|
1605
|
+
"No server-side template injection", "security-best-practices/flask-express", 5, 0, "fail",
|
|
1606
|
+
`${sstiIssues.length} SSTI risk(s). Never pass user-controlled strings directly to template engines.`,
|
|
1607
|
+
sstiIssues,
|
|
1608
|
+
));
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
// Check: No Express MemoryStore session (3 pts)
|
|
1612
|
+
if (hasExpress) {
|
|
1613
|
+
const memStoreIssues = [];
|
|
1614
|
+
|
|
1615
|
+
for (const [filePath, content] of fileContents) {
|
|
1616
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
1617
|
+
if (![".js", ".ts", ".mjs", ".cjs"].includes(ext)) continue;
|
|
1618
|
+
|
|
1619
|
+
if (/express-session|session\s*\(/.test(content)) {
|
|
1620
|
+
// MemoryStore is the default when no store: option is set alongside session()
|
|
1621
|
+
if (!/store\s*:/.test(content) && /session\s*\(\s*\{/.test(content)) {
|
|
1622
|
+
const line = content.split("\n").findIndex((l) => /session\s*\(\s*\{/.test(l)) + 1;
|
|
1623
|
+
memStoreIssues.push({ file: relPath(projectDir, filePath), line, message: "express-session configured without explicit store — defaults to MemoryStore (not production-safe)" });
|
|
1624
|
+
}
|
|
1625
|
+
// Explicit new MemoryStore() is always wrong
|
|
1626
|
+
if (/new\s+MemoryStore\s*\(/.test(content)) {
|
|
1627
|
+
const line = content.split("\n").findIndex((l) => /new\s+MemoryStore/.test(l)) + 1;
|
|
1628
|
+
memStoreIssues.push({ file: relPath(projectDir, filePath), line, message: "Explicit MemoryStore — leaks memory and resets on restart. Use Redis or DB store." });
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
if (memStoreIssues.length === 0) {
|
|
1634
|
+
checks.push(checkResult("Express: persistent session store", "security-best-practices/express", 3, 3, "pass", "No MemoryStore session configuration detected"));
|
|
1635
|
+
} else {
|
|
1636
|
+
checks.push(checkResult(
|
|
1637
|
+
"Express: persistent session store", "security-best-practices/express", 3, 0, "fail",
|
|
1638
|
+
`${memStoreIssues.length} Express session issue(s). Use connect-redis, connect-pg-simple, or similar persistent store.`,
|
|
1639
|
+
memStoreIssues,
|
|
1640
|
+
));
|
|
1641
|
+
}
|
|
1642
|
+
} else if (hasJS) {
|
|
1643
|
+
checks.push(checkResult("Express: persistent session store", "security-best-practices/express", 3, 0, "skip", "Express not detected"));
|
|
1644
|
+
} else {
|
|
1645
|
+
checks.push(checkResult("Express: persistent session store", "security-best-practices/express", 3, 0, "skip", "No JS project detected"));
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
return { name: "Framework Config", checks };
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1408
1651
|
// ---- Utility helpers for scanner ----
|
|
1409
1652
|
|
|
1410
1653
|
function relPath(projectDir, filePath) {
|
|
@@ -1464,6 +1707,8 @@ async function runSecurityScan(projectDir) {
|
|
|
1464
1707
|
await checkInputApi(projectDir, fileContents),
|
|
1465
1708
|
await checkHeadersTransport(projectDir, fileContents),
|
|
1466
1709
|
await checkDatabase(projectDir, fileContents),
|
|
1710
|
+
await checkFrontendSecurity(projectDir, fileContents),
|
|
1711
|
+
await checkFrameworkConfig(projectDir, fileContents),
|
|
1467
1712
|
];
|
|
1468
1713
|
|
|
1469
1714
|
// Calculate scores
|
|
@@ -1757,6 +2002,11 @@ async function runSecurityCommand() {
|
|
|
1757
2002
|
fs.writeFileSync(logPath, logContent);
|
|
1758
2003
|
console.log(`\n Full report: ${logPath}\n`);
|
|
1759
2004
|
|
|
2005
|
+
// Clean up downloaded files — only the log should remain
|
|
2006
|
+
if (fs.existsSync(masterDest)) {
|
|
2007
|
+
fs.unlinkSync(masterDest);
|
|
2008
|
+
}
|
|
2009
|
+
|
|
1760
2010
|
// Ensure yeknal-security.log is in .gitignore so it never gets pushed
|
|
1761
2011
|
const gitignorePath = path.join(projectDir, ".gitignore");
|
|
1762
2012
|
const logEntry = "yeknal-security.log";
|