devkit-quality 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +312 -0
- package/bin/devkit.js +3 -0
- package/dist/ast/comments.js +28 -0
- package/dist/ast/parse.js +56 -0
- package/dist/ast/walk.js +69 -0
- package/dist/cli.js +167 -0
- package/dist/cliLogic.js +36 -0
- package/dist/config.js +57 -0
- package/dist/context.js +2 -0
- package/dist/discovery.js +99 -0
- package/dist/finding.js +20 -0
- package/dist/glob.js +35 -0
- package/dist/ignore.js +28 -0
- package/dist/moduleGraph.js +311 -0
- package/dist/moduleResolution.js +64 -0
- package/dist/reporters.js +258 -0
- package/dist/rules/architecture.js +57 -0
- package/dist/rules/complexity.js +230 -0
- package/dist/rules/deadCode.js +252 -0
- package/dist/rules/dependencies.js +137 -0
- package/dist/rules/duplication.js +138 -0
- package/dist/rules/errorHandling.js +120 -0
- package/dist/rules/hygiene.js +102 -0
- package/dist/rules/javascript.js +69 -0
- package/dist/rules/redundancy.js +75 -0
- package/dist/rules/secretPatterns.js +24 -0
- package/dist/rules/security.js +318 -0
- package/dist/rules/typescript.js +124 -0
- package/dist/rules.js +546 -0
- package/dist/scanner.js +150 -0
- package/dist/scoring.js +68 -0
- package/dist/suppressions.js +21 -0
- package/dist/terminal.js +135 -0
- package/dist/types.js +2 -0
- package/package.json +46 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.runHygieneRules = runHygieneRules;
|
|
7
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
8
|
+
const typescript_1 = __importDefault(require("typescript"));
|
|
9
|
+
const finding_1 = require("../finding");
|
|
10
|
+
const config_1 = require("../config");
|
|
11
|
+
const discovery_1 = require("../discovery");
|
|
12
|
+
const comments_1 = require("../ast/comments");
|
|
13
|
+
const walk_1 = require("../ast/walk");
|
|
14
|
+
const BACKUP_FILE_PATTERN = /(\.bak|\.orig|\.tmp|~)$/i;
|
|
15
|
+
const ENV_FILE_PATTERN = /^\.env(\..+)?$/i;
|
|
16
|
+
const ENV_SAFE_SUFFIXES = /\.(example|sample|template|dist)$/i;
|
|
17
|
+
function runHygieneRules(context) {
|
|
18
|
+
const findings = [];
|
|
19
|
+
for (const file of context.files) {
|
|
20
|
+
const text = (0, discovery_1.readFileSafe)(file);
|
|
21
|
+
const sourceFile = context.program.getSourceFile(file);
|
|
22
|
+
if (!sourceFile)
|
|
23
|
+
continue;
|
|
24
|
+
const relativePath = node_path_1.default.relative(context.projectRoot, file).replace(/\\/g, '/');
|
|
25
|
+
const visit = (node) => {
|
|
26
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'HYGIENE001') && typescript_1.default.isCallExpression(node) && typescript_1.default.isPropertyAccessExpression(node.expression) &&
|
|
27
|
+
typescript_1.default.isIdentifier(node.expression.expression) && node.expression.expression.text === 'console' &&
|
|
28
|
+
['log', 'debug', 'warn', 'error'].includes(node.expression.name.text)) {
|
|
29
|
+
const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, node.getStart());
|
|
30
|
+
findings.push((0, finding_1.buildFinding)({
|
|
31
|
+
ruleId: 'HYGIENE001', category: 'hygiene', severity: 'LOW', confidence: 'CERTAIN', file: relativePath, line, column,
|
|
32
|
+
message: 'Debug statement found', description: 'Console output remains in source code.', evidence: node.getText().slice(0, 160),
|
|
33
|
+
suggestion: 'Remove or gate debug logging before production deployment.', fixAvailable: true
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'HYGIENE001') && typescript_1.default.isDebuggerStatement(node)) {
|
|
37
|
+
const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, node.getStart());
|
|
38
|
+
findings.push((0, finding_1.buildFinding)({
|
|
39
|
+
ruleId: 'HYGIENE001', category: 'hygiene', severity: 'LOW', confidence: 'CERTAIN', file: relativePath, line, column,
|
|
40
|
+
message: 'Debugger statement found', description: 'Debugger statements are left in code and can leak runtime state.', evidence: node.getText(),
|
|
41
|
+
suggestion: 'Remove debugger statements before committing code.', fixAvailable: true
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
typescript_1.default.forEachChild(node, visit);
|
|
45
|
+
};
|
|
46
|
+
visit(sourceFile);
|
|
47
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'HYGIENE002')) {
|
|
48
|
+
for (const range of (0, comments_1.collectCommentRanges)(sourceFile)) {
|
|
49
|
+
const comment = text.slice(range.pos, range.end);
|
|
50
|
+
const markers = /\b(TODO|FIXME|HACK|XXX)\b/g;
|
|
51
|
+
for (const match of comment.matchAll(markers)) {
|
|
52
|
+
const position = range.pos + (match.index ?? 0);
|
|
53
|
+
const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, position);
|
|
54
|
+
findings.push((0, finding_1.buildFinding)({
|
|
55
|
+
ruleId: 'HYGIENE002', category: 'hygiene', severity: 'LOW', confidence: 'MEDIUM', file: relativePath, line, column,
|
|
56
|
+
message: 'Temporary marker found', description: 'A marker suggests unresolved work or temporary code remains in the repository.',
|
|
57
|
+
evidence: match[0], suggestion: 'Track the issue properly or remove the marker before merge.', fixAvailable: true
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'HYGIENE003') || (0, config_1.isRuleEnabled)(context.config, 'HYGIENE004')) {
|
|
64
|
+
for (const file of context.allFiles) {
|
|
65
|
+
const basename = node_path_1.default.basename(file);
|
|
66
|
+
const relativePath = node_path_1.default.relative(context.projectRoot, file).replace(/\\/g, '/');
|
|
67
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'HYGIENE003') && BACKUP_FILE_PATTERN.test(basename)) {
|
|
68
|
+
findings.push((0, finding_1.buildFinding)({
|
|
69
|
+
ruleId: 'HYGIENE003',
|
|
70
|
+
category: 'hygiene',
|
|
71
|
+
severity: 'LOW',
|
|
72
|
+
confidence: 'HIGH',
|
|
73
|
+
file: relativePath,
|
|
74
|
+
line: 1,
|
|
75
|
+
column: 1,
|
|
76
|
+
message: 'Backup or temporary file committed',
|
|
77
|
+
description: 'A backup/temporary file was found in the repository.',
|
|
78
|
+
evidence: relativePath,
|
|
79
|
+
suggestion: 'Delete the file and add its pattern to .gitignore.',
|
|
80
|
+
fixAvailable: false
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'HYGIENE004') && ENV_FILE_PATTERN.test(basename) && !ENV_SAFE_SUFFIXES.test(basename)) {
|
|
84
|
+
findings.push((0, finding_1.buildFinding)({
|
|
85
|
+
ruleId: 'HYGIENE004',
|
|
86
|
+
category: 'hygiene',
|
|
87
|
+
severity: 'HIGH',
|
|
88
|
+
confidence: 'MEDIUM',
|
|
89
|
+
file: relativePath,
|
|
90
|
+
line: 1,
|
|
91
|
+
column: 1,
|
|
92
|
+
message: 'Committed .env file',
|
|
93
|
+
description: 'An environment file is present in the repository and may contain secrets.',
|
|
94
|
+
evidence: relativePath,
|
|
95
|
+
suggestion: 'Remove the file from version control and add it to .gitignore.',
|
|
96
|
+
fixAvailable: false
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return findings;
|
|
102
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.runJavaScriptRules = runJavaScriptRules;
|
|
7
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
8
|
+
const typescript_1 = __importDefault(require("typescript"));
|
|
9
|
+
const finding_1 = require("../finding");
|
|
10
|
+
const config_1 = require("../config");
|
|
11
|
+
const walk_1 = require("../ast/walk");
|
|
12
|
+
const LOOSE_EQUALITY_OPERATORS = new Set([typescript_1.default.SyntaxKind.EqualsEqualsToken, typescript_1.default.SyntaxKind.ExclamationEqualsToken]);
|
|
13
|
+
function isNullLiteral(node) {
|
|
14
|
+
return node.kind === typescript_1.default.SyntaxKind.NullKeyword;
|
|
15
|
+
}
|
|
16
|
+
function runJavaScriptRules(context) {
|
|
17
|
+
const findings = [];
|
|
18
|
+
for (const file of context.files) {
|
|
19
|
+
const sourceFile = context.program.getSourceFile(file);
|
|
20
|
+
if (!sourceFile)
|
|
21
|
+
continue;
|
|
22
|
+
const relativePath = node_path_1.default.relative(context.projectRoot, file).replace(/\\/g, '/');
|
|
23
|
+
const visit = (node) => {
|
|
24
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'JS001') &&
|
|
25
|
+
typescript_1.default.isVariableStatement(node) &&
|
|
26
|
+
(node.declarationList.flags & typescript_1.default.NodeFlags.BlockScoped) === 0) {
|
|
27
|
+
const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, node.getStart());
|
|
28
|
+
findings.push((0, finding_1.buildFinding)({
|
|
29
|
+
ruleId: 'JS001',
|
|
30
|
+
category: 'hygiene',
|
|
31
|
+
severity: 'LOW',
|
|
32
|
+
confidence: 'CERTAIN',
|
|
33
|
+
file: relativePath,
|
|
34
|
+
line,
|
|
35
|
+
column,
|
|
36
|
+
message: "'var' declaration",
|
|
37
|
+
description: 'var is function-scoped and hoisted, which commonly causes bugs.',
|
|
38
|
+
evidence: node.getText().slice(0, 160),
|
|
39
|
+
suggestion: 'Use let or const instead.',
|
|
40
|
+
fixAvailable: false
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'JS002') &&
|
|
44
|
+
typescript_1.default.isBinaryExpression(node) &&
|
|
45
|
+
LOOSE_EQUALITY_OPERATORS.has(node.operatorToken.kind) &&
|
|
46
|
+
!isNullLiteral(node.left) &&
|
|
47
|
+
!isNullLiteral(node.right)) {
|
|
48
|
+
const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, node.getStart());
|
|
49
|
+
findings.push((0, finding_1.buildFinding)({
|
|
50
|
+
ruleId: 'JS002',
|
|
51
|
+
category: 'hygiene',
|
|
52
|
+
severity: 'LOW',
|
|
53
|
+
confidence: 'CERTAIN',
|
|
54
|
+
file: relativePath,
|
|
55
|
+
line,
|
|
56
|
+
column,
|
|
57
|
+
message: 'Loose equality',
|
|
58
|
+
description: 'Loose equality performs implicit type coercion, which can hide bugs.',
|
|
59
|
+
evidence: node.getText().slice(0, 160),
|
|
60
|
+
suggestion: 'Use === or !== instead.',
|
|
61
|
+
fixAvailable: false
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
typescript_1.default.forEachChild(node, visit);
|
|
65
|
+
};
|
|
66
|
+
visit(sourceFile);
|
|
67
|
+
}
|
|
68
|
+
return findings;
|
|
69
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.runRedundancyRules = runRedundancyRules;
|
|
7
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
8
|
+
const typescript_1 = __importDefault(require("typescript"));
|
|
9
|
+
const finding_1 = require("../finding");
|
|
10
|
+
const config_1 = require("../config");
|
|
11
|
+
const walk_1 = require("../ast/walk");
|
|
12
|
+
const LOOSE_EQUALITY_OPERATORS = new Set([typescript_1.default.SyntaxKind.EqualsEqualsToken, typescript_1.default.SyntaxKind.ExclamationEqualsToken]);
|
|
13
|
+
const STRICT_EQUALITY_OPERATORS = new Set([typescript_1.default.SyntaxKind.EqualsEqualsEqualsToken, typescript_1.default.SyntaxKind.ExclamationEqualsEqualsToken]);
|
|
14
|
+
function isBooleanLiteral(node) {
|
|
15
|
+
return node.kind === typescript_1.default.SyntaxKind.TrueKeyword || node.kind === typescript_1.default.SyntaxKind.FalseKeyword;
|
|
16
|
+
}
|
|
17
|
+
function runRedundancyRules(context) {
|
|
18
|
+
const findings = [];
|
|
19
|
+
for (const file of context.files) {
|
|
20
|
+
const sourceFile = context.program.getSourceFile(file);
|
|
21
|
+
if (!sourceFile)
|
|
22
|
+
continue;
|
|
23
|
+
const relativePath = node_path_1.default.relative(context.projectRoot, file).replace(/\\/g, '/');
|
|
24
|
+
const visit = (node) => {
|
|
25
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'REDUNDANT001') &&
|
|
26
|
+
typescript_1.default.isBinaryExpression(node) &&
|
|
27
|
+
(LOOSE_EQUALITY_OPERATORS.has(node.operatorToken.kind) || STRICT_EQUALITY_OPERATORS.has(node.operatorToken.kind)) &&
|
|
28
|
+
(isBooleanLiteral(node.left) || isBooleanLiteral(node.right))) {
|
|
29
|
+
const comparedExpression = isBooleanLiteral(node.left) ? node.right : node.left;
|
|
30
|
+
const comparedType = context.program.getTypeChecker().getTypeAtLocation(comparedExpression);
|
|
31
|
+
if (comparedType.isUnion() && comparedType.types.some((member) => (member.flags & typescript_1.default.TypeFlags.BooleanLike) === 0)) {
|
|
32
|
+
typescript_1.default.forEachChild(node, visit);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, node.getStart());
|
|
36
|
+
findings.push((0, finding_1.buildFinding)({
|
|
37
|
+
ruleId: 'REDUNDANT001',
|
|
38
|
+
category: 'redundantLogic',
|
|
39
|
+
severity: 'LOW',
|
|
40
|
+
confidence: 'CERTAIN',
|
|
41
|
+
file: relativePath,
|
|
42
|
+
line,
|
|
43
|
+
column,
|
|
44
|
+
message: 'Redundant boolean comparison',
|
|
45
|
+
description: 'Comparing a value directly to true/false is unnecessary.',
|
|
46
|
+
evidence: node.getText().slice(0, 160),
|
|
47
|
+
suggestion: 'Use the expression directly, negating it with ! if needed.',
|
|
48
|
+
fixAvailable: false
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'REDUNDANT002') &&
|
|
52
|
+
typescript_1.default.isIfStatement(node) &&
|
|
53
|
+
(node.expression.kind === typescript_1.default.SyntaxKind.TrueKeyword || node.expression.kind === typescript_1.default.SyntaxKind.FalseKeyword)) {
|
|
54
|
+
const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, node.getStart());
|
|
55
|
+
findings.push((0, finding_1.buildFinding)({
|
|
56
|
+
ruleId: 'REDUNDANT002',
|
|
57
|
+
category: 'redundantLogic',
|
|
58
|
+
severity: 'MEDIUM',
|
|
59
|
+
confidence: 'CERTAIN',
|
|
60
|
+
file: relativePath,
|
|
61
|
+
line,
|
|
62
|
+
column,
|
|
63
|
+
message: 'Impossible condition',
|
|
64
|
+
description: `The condition is a literal ${node.expression.kind === typescript_1.default.SyntaxKind.TrueKeyword ? 'true' : 'false'}, so one branch can never execute.`,
|
|
65
|
+
evidence: node.getText().slice(0, 160),
|
|
66
|
+
suggestion: 'Remove the dead branch or the conditional entirely.',
|
|
67
|
+
fixAvailable: false
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
typescript_1.default.forEachChild(node, visit);
|
|
71
|
+
};
|
|
72
|
+
visit(sourceFile);
|
|
73
|
+
}
|
|
74
|
+
return findings;
|
|
75
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SECRET_PATTERNS = void 0;
|
|
4
|
+
exports.looksHighEntropy = looksHighEntropy;
|
|
5
|
+
exports.SECRET_PATTERNS = [
|
|
6
|
+
{ id: 'aws-access-key', label: 'AWS access key ID', regex: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
7
|
+
{ id: 'stripe-secret-key', label: 'Stripe secret key', regex: /\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b/ },
|
|
8
|
+
{ id: 'github-token', label: 'GitHub token', regex: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/ },
|
|
9
|
+
{ id: 'pem-private-key', label: 'PEM private key', regex: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/ },
|
|
10
|
+
{ id: 'jwt', label: 'JWT token', regex: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/ },
|
|
11
|
+
{ id: 'slack-token', label: 'Slack token', regex: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ }
|
|
12
|
+
];
|
|
13
|
+
function looksHighEntropy(value, minLength = 20) {
|
|
14
|
+
if (value.length < minLength)
|
|
15
|
+
return false;
|
|
16
|
+
const frequencies = new Map();
|
|
17
|
+
for (const character of value)
|
|
18
|
+
frequencies.set(character, (frequencies.get(character) ?? 0) + 1);
|
|
19
|
+
const entropy = [...frequencies.values()].reduce((sum, count) => {
|
|
20
|
+
const probability = count / value.length;
|
|
21
|
+
return sum - probability * Math.log2(probability);
|
|
22
|
+
}, 0);
|
|
23
|
+
return entropy >= 3.2;
|
|
24
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.runSecurityRules = runSecurityRules;
|
|
7
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
8
|
+
const typescript_1 = __importDefault(require("typescript"));
|
|
9
|
+
const finding_1 = require("../finding");
|
|
10
|
+
const config_1 = require("../config");
|
|
11
|
+
const discovery_1 = require("../discovery");
|
|
12
|
+
const walk_1 = require("../ast/walk");
|
|
13
|
+
const secretPatterns_1 = require("./secretPatterns");
|
|
14
|
+
const SECRET_PATTERN = /\b(API_KEY|SECRET|TOKEN|PASSWORD|PRIVATE_KEY)\b\s*[:=]\s*["'`]([^"'`]{4,})["'`]/i;
|
|
15
|
+
const WEAK_HASH_ALGORITHMS = /^(md5|sha1|des|rc4)$/i;
|
|
16
|
+
const EXEC_FUNCTION_NAMES = new Set(['exec', 'execSync']);
|
|
17
|
+
const SHELL_FUNCTION_NAMES = new Set(['spawn', 'spawnSync', 'execFile', 'execFileSync']);
|
|
18
|
+
const FILE_FUNCTION_NAMES = new Set(['readFile', 'readFileSync', 'writeFile', 'writeFileSync', 'appendFile', 'appendFileSync', 'createReadStream', 'createWriteStream', 'unlink', 'unlinkSync', 'open', 'openSync', 'stat', 'statSync', 'access', 'accessSync']);
|
|
19
|
+
function calleeName(expression) {
|
|
20
|
+
if (typescript_1.default.isIdentifier(expression))
|
|
21
|
+
return expression.text;
|
|
22
|
+
if (typescript_1.default.isPropertyAccessExpression(expression))
|
|
23
|
+
return expression.name.text;
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
function isDynamicString(expression) {
|
|
27
|
+
return typescript_1.default.isTemplateExpression(expression) || typescript_1.default.isBinaryExpression(expression);
|
|
28
|
+
}
|
|
29
|
+
function nearestFunction(node) {
|
|
30
|
+
return typescript_1.default.findAncestor(node, (ancestor) => typescript_1.default.isFunctionDeclaration(ancestor) || typescript_1.default.isFunctionExpression(ancestor) || typescript_1.default.isArrowFunction(ancestor) || typescript_1.default.isMethodDeclaration(ancestor) || typescript_1.default.isConstructorDeclaration(ancestor));
|
|
31
|
+
}
|
|
32
|
+
function hasShellEnabled(node) {
|
|
33
|
+
return node.arguments.some((argument) => typescript_1.default.isObjectLiteralExpression(argument) && argument.properties.some((property) => typescript_1.default.isPropertyAssignment(property) && property.name.getText() === 'shell' && property.initializer.kind === typescript_1.default.SyntaxKind.TrueKeyword));
|
|
34
|
+
}
|
|
35
|
+
function isDynamicCommandArgument(node) {
|
|
36
|
+
const first = node.arguments[0];
|
|
37
|
+
if (!first)
|
|
38
|
+
return false;
|
|
39
|
+
if (isDynamicString(first))
|
|
40
|
+
return true;
|
|
41
|
+
if (!typescript_1.default.isIdentifier(first))
|
|
42
|
+
return false;
|
|
43
|
+
const enclosingFunction = nearestFunction(node);
|
|
44
|
+
let nearestDeclaration;
|
|
45
|
+
const sourceFile = node.getSourceFile();
|
|
46
|
+
const visit = (current) => {
|
|
47
|
+
if (current !== sourceFile && current !== enclosingFunction &&
|
|
48
|
+
(typescript_1.default.isFunctionDeclaration(current) || typescript_1.default.isFunctionExpression(current) || typescript_1.default.isArrowFunction(current) || typescript_1.default.isMethodDeclaration(current)))
|
|
49
|
+
return;
|
|
50
|
+
if (typescript_1.default.isVariableDeclaration(current) && typescript_1.default.isIdentifier(current.name) && current.name.text === first.text &&
|
|
51
|
+
current.initializer && current.getStart() < node.getStart() && nearestFunction(current) === enclosingFunction &&
|
|
52
|
+
(!nearestDeclaration || current.getStart() > nearestDeclaration.getStart()))
|
|
53
|
+
nearestDeclaration = current;
|
|
54
|
+
typescript_1.default.forEachChild(current, visit);
|
|
55
|
+
};
|
|
56
|
+
visit(enclosingFunction ?? sourceFile);
|
|
57
|
+
return !!nearestDeclaration?.initializer && isDynamicString(nearestDeclaration.initializer);
|
|
58
|
+
}
|
|
59
|
+
function containsUntrustedInput(expression) {
|
|
60
|
+
return /\.(?:params|query|body)\b/.test(expression.getText());
|
|
61
|
+
}
|
|
62
|
+
function hasPathSanitizer(node) {
|
|
63
|
+
return /(?:normalize|sanitize|safePath|allowedPaths|allowlist)\s*\(/i.test(node.getText());
|
|
64
|
+
}
|
|
65
|
+
function runSecretScan(context) {
|
|
66
|
+
if (!(0, config_1.isRuleEnabled)(context.config, 'SEC001'))
|
|
67
|
+
return [];
|
|
68
|
+
const findings = [];
|
|
69
|
+
for (const file of context.files) {
|
|
70
|
+
const text = (0, discovery_1.readFileSafe)(file);
|
|
71
|
+
const relativePath = node_path_1.default.relative(context.projectRoot, file).replace(/\\/g, '/');
|
|
72
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
73
|
+
const isTestFixture = (0, discovery_1.isTestFile)(relativePath) || /(?:^|\/)(?:fixtures?|__mocks__)(?:\/|$)/i.test(relativePath);
|
|
74
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
75
|
+
const providerPattern = secretPatterns_1.SECRET_PATTERNS.find((pattern) => pattern.regex.test(lines[i]));
|
|
76
|
+
const fallbackMatch = !providerPattern && !isTestFixture ? lines[i].match(SECRET_PATTERN) : null;
|
|
77
|
+
if (providerPattern || (fallbackMatch && (0, secretPatterns_1.looksHighEntropy)(fallbackMatch[2]))) {
|
|
78
|
+
findings.push((0, finding_1.buildFinding)({
|
|
79
|
+
ruleId: 'SEC001',
|
|
80
|
+
category: 'security',
|
|
81
|
+
severity: 'HIGH',
|
|
82
|
+
confidence: providerPattern ? 'HIGH' : 'MEDIUM',
|
|
83
|
+
file: relativePath,
|
|
84
|
+
line: i + 1,
|
|
85
|
+
column: providerPattern ? lines[i].search(providerPattern.regex) + 1 : lines[i].indexOf(fallbackMatch[0]) + 1,
|
|
86
|
+
message: providerPattern ? `Potential ${providerPattern.label}` : 'Potential secret in source',
|
|
87
|
+
description: providerPattern
|
|
88
|
+
? `A value matches the known format for a ${providerPattern.label}.`
|
|
89
|
+
: 'A high-entropy value is assigned to a credential-like variable name.',
|
|
90
|
+
evidence: providerPattern ? `${providerPattern.label} pattern matched` : `${fallbackMatch[1]}=[redacted]`,
|
|
91
|
+
suggestion: 'Move sensitive values to environment variables or secure secret storage.',
|
|
92
|
+
fixAvailable: false
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
if (/NODE_TLS_REJECT_UNAUTHORIZED['"]?\s*[:=]\s*['"]?0/.test(lines[i])) {
|
|
96
|
+
findings.push((0, finding_1.buildFinding)({
|
|
97
|
+
ruleId: 'SEC007',
|
|
98
|
+
category: 'security',
|
|
99
|
+
severity: 'HIGH',
|
|
100
|
+
confidence: 'HIGH',
|
|
101
|
+
file: relativePath,
|
|
102
|
+
line: i + 1,
|
|
103
|
+
column: 1,
|
|
104
|
+
message: 'TLS certificate verification disabled',
|
|
105
|
+
description: 'NODE_TLS_REJECT_UNAUTHORIZED is set to 0, disabling TLS verification process-wide.',
|
|
106
|
+
evidence: lines[i].trim(),
|
|
107
|
+
suggestion: 'Remove the override and fix the underlying certificate problem.',
|
|
108
|
+
fixAvailable: false
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return findings;
|
|
114
|
+
}
|
|
115
|
+
function runAstSecurityChecks(context) {
|
|
116
|
+
const findings = [];
|
|
117
|
+
for (const file of context.files) {
|
|
118
|
+
const sourceFile = context.program.getSourceFile(file);
|
|
119
|
+
if (!sourceFile)
|
|
120
|
+
continue;
|
|
121
|
+
const relativePath = node_path_1.default.relative(context.projectRoot, file).replace(/\\/g, '/');
|
|
122
|
+
const imports = context.moduleGraph.importsByFile.get(file) ?? [];
|
|
123
|
+
const importedPackages = new Set(imports.filter((item) => !item.isRelative).map((item) => item.specifier.startsWith('@') ? item.specifier.split('/').slice(0, 2).join('/') : item.specifier.split('/')[0]));
|
|
124
|
+
const visit = (node) => {
|
|
125
|
+
if (typescript_1.default.isCallExpression(node)) {
|
|
126
|
+
const name = calleeName(node.expression);
|
|
127
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'SEC002') && name === 'eval') {
|
|
128
|
+
const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, node.getStart());
|
|
129
|
+
findings.push((0, finding_1.buildFinding)({
|
|
130
|
+
ruleId: 'SEC002',
|
|
131
|
+
category: 'security',
|
|
132
|
+
severity: 'HIGH',
|
|
133
|
+
confidence: 'CERTAIN',
|
|
134
|
+
file: relativePath,
|
|
135
|
+
line,
|
|
136
|
+
column,
|
|
137
|
+
message: 'Use of eval',
|
|
138
|
+
description: 'eval executes arbitrary strings as code and is a common injection vector.',
|
|
139
|
+
evidence: node.getText().slice(0, 160),
|
|
140
|
+
suggestion: 'Avoid eval; use a safer alternative such as JSON.parse or a dedicated parser.',
|
|
141
|
+
fixAvailable: false
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'SEC003') && name && EXEC_FUNCTION_NAMES.has(name) && node.arguments.length > 0) {
|
|
145
|
+
const isDynamic = isDynamicCommandArgument(node);
|
|
146
|
+
if (isDynamic) {
|
|
147
|
+
const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, node.getStart());
|
|
148
|
+
findings.push((0, finding_1.buildFinding)({
|
|
149
|
+
ruleId: 'SEC003',
|
|
150
|
+
category: 'security',
|
|
151
|
+
severity: 'HIGH',
|
|
152
|
+
confidence: 'MEDIUM',
|
|
153
|
+
file: relativePath,
|
|
154
|
+
line,
|
|
155
|
+
column,
|
|
156
|
+
message: 'Dangerous command execution',
|
|
157
|
+
description: `${name}() is invoked with a dynamically built command string, risking command injection.`,
|
|
158
|
+
evidence: node.getText().slice(0, 160),
|
|
159
|
+
suggestion: 'Use execFile/spawn with an argument array instead of a concatenated shell string.',
|
|
160
|
+
fixAvailable: false
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'SEC003') && name && SHELL_FUNCTION_NAMES.has(name) && hasShellEnabled(node)) {
|
|
165
|
+
const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, node.getStart());
|
|
166
|
+
findings.push((0, finding_1.buildFinding)({
|
|
167
|
+
ruleId: 'SEC003', category: 'security', severity: 'HIGH', confidence: 'HIGH', file: relativePath, line, column,
|
|
168
|
+
message: 'Shell execution enabled',
|
|
169
|
+
description: `${name}() enables shell execution with shell: true, so untrusted arguments can become shell commands.`,
|
|
170
|
+
evidence: node.getText().slice(0, 160),
|
|
171
|
+
suggestion: 'Remove shell: true or ensure every argument is fixed and trusted.', fixAvailable: false
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'SEC004') && name === 'insertAdjacentHTML') {
|
|
175
|
+
const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, node.getStart());
|
|
176
|
+
findings.push((0, finding_1.buildFinding)({
|
|
177
|
+
ruleId: 'SEC004', category: 'security', severity: 'MEDIUM', confidence: 'HIGH', file: relativePath, line, column,
|
|
178
|
+
message: 'Unsafe HTML insertion',
|
|
179
|
+
description: 'insertAdjacentHTML parses a string as HTML and can introduce cross-site scripting when the value is untrusted.',
|
|
180
|
+
evidence: node.getText().slice(0, 160),
|
|
181
|
+
suggestion: 'Sanitize the HTML or use safe DOM APIs such as textContent.', fixAvailable: false
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'SEC008') && name && ['query', 'execute'].includes(name) && node.arguments[0] && isDynamicString(node.arguments[0])) {
|
|
185
|
+
const queryText = node.arguments[0].getText();
|
|
186
|
+
if (/\b(select|insert|update|delete|replace|with)\b/i.test(queryText)) {
|
|
187
|
+
const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, node.getStart());
|
|
188
|
+
findings.push((0, finding_1.buildFinding)({
|
|
189
|
+
ruleId: 'SEC008', category: 'security', severity: 'HIGH', confidence: 'MEDIUM', file: relativePath, line, column,
|
|
190
|
+
message: 'Potential SQL injection',
|
|
191
|
+
description: 'A query string is dynamically constructed and may include untrusted input.',
|
|
192
|
+
evidence: node.getText().slice(0, 160),
|
|
193
|
+
suggestion: 'Use parameterized queries and keep user input out of SQL syntax.', fixAvailable: false
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'SEC009') && name) {
|
|
198
|
+
const pathJoin = name === 'join' && typescript_1.default.isPropertyAccessExpression(node.expression) && node.expression.expression.getText() === 'path';
|
|
199
|
+
const filesystemCall = FILE_FUNCTION_NAMES.has(name);
|
|
200
|
+
const relevantArgs = pathJoin ? node.arguments : node.arguments.slice(0, 1);
|
|
201
|
+
if ((pathJoin || filesystemCall) && !hasPathSanitizer(node) && relevantArgs.some(containsUntrustedInput)) {
|
|
202
|
+
const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, node.getStart());
|
|
203
|
+
findings.push((0, finding_1.buildFinding)({
|
|
204
|
+
ruleId: 'SEC009', category: 'security', severity: 'HIGH', confidence: 'LOW', file: relativePath, line, column,
|
|
205
|
+
message: 'Potential path traversal',
|
|
206
|
+
description: 'A filesystem path is built from request parameters without an obvious normalization or allowlist step.',
|
|
207
|
+
evidence: node.getText().slice(0, 160),
|
|
208
|
+
suggestion: 'Resolve the path against an allowed root and verify the normalized result stays inside it.', fixAvailable: false
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'SEC010') && name) {
|
|
213
|
+
const unsafeDeserializer = (name === 'unserialize' && (importedPackages.has('node-serialize') || importedPackages.has('serialize-javascript'))) ||
|
|
214
|
+
name === 'runInNewContext' || (name === 'load' && importedPackages.has('js-yaml'));
|
|
215
|
+
if (unsafeDeserializer) {
|
|
216
|
+
const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, node.getStart());
|
|
217
|
+
findings.push((0, finding_1.buildFinding)({
|
|
218
|
+
ruleId: 'SEC010', category: 'security', severity: 'HIGH', confidence: 'MEDIUM', file: relativePath, line, column,
|
|
219
|
+
message: 'Unsafe deserialization or dynamic execution',
|
|
220
|
+
description: `${name}() can execute or construct unsafe values from untrusted input.`,
|
|
221
|
+
evidence: node.getText().slice(0, 160),
|
|
222
|
+
suggestion: 'Use a safe data format and avoid deserializing untrusted objects or executing input.', fixAvailable: false
|
|
223
|
+
}));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'SEC005') && name === 'createHash' && node.arguments.length > 0) {
|
|
227
|
+
const arg = node.arguments[0];
|
|
228
|
+
if (typescript_1.default.isStringLiteral(arg) && WEAK_HASH_ALGORITHMS.test(arg.text)) {
|
|
229
|
+
const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, node.getStart());
|
|
230
|
+
findings.push((0, finding_1.buildFinding)({
|
|
231
|
+
ruleId: 'SEC005',
|
|
232
|
+
category: 'security',
|
|
233
|
+
severity: 'MEDIUM',
|
|
234
|
+
confidence: 'HIGH',
|
|
235
|
+
file: relativePath,
|
|
236
|
+
line,
|
|
237
|
+
column,
|
|
238
|
+
message: 'Weak cryptographic hash',
|
|
239
|
+
description: `"${arg.text}" is not a cryptographically strong hash algorithm.`,
|
|
240
|
+
evidence: node.getText().slice(0, 160),
|
|
241
|
+
suggestion: 'Use a modern algorithm such as sha256 or stronger.',
|
|
242
|
+
fixAvailable: false
|
|
243
|
+
}));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'SEC004')) {
|
|
248
|
+
if (typescript_1.default.isBinaryExpression(node) &&
|
|
249
|
+
node.operatorToken.kind === typescript_1.default.SyntaxKind.EqualsToken &&
|
|
250
|
+
typescript_1.default.isPropertyAccessExpression(node.left) &&
|
|
251
|
+
(node.left.name.text === 'innerHTML' || node.left.name.text === 'outerHTML')) {
|
|
252
|
+
const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, node.getStart());
|
|
253
|
+
findings.push((0, finding_1.buildFinding)({
|
|
254
|
+
ruleId: 'SEC004',
|
|
255
|
+
category: 'security',
|
|
256
|
+
severity: 'MEDIUM',
|
|
257
|
+
confidence: 'MEDIUM',
|
|
258
|
+
file: relativePath,
|
|
259
|
+
line,
|
|
260
|
+
column,
|
|
261
|
+
message: 'Unsafe HTML assignment',
|
|
262
|
+
description: `Assigning to ${node.left.name.text} can introduce cross-site scripting if the value is not sanitized.`,
|
|
263
|
+
evidence: node.getText().slice(0, 160),
|
|
264
|
+
suggestion: 'Sanitize the value or use safe DOM APIs such as textContent.',
|
|
265
|
+
fixAvailable: false
|
|
266
|
+
}));
|
|
267
|
+
}
|
|
268
|
+
if (typescript_1.default.isJsxAttribute(node) && node.name.getText() === 'dangerouslySetInnerHTML') {
|
|
269
|
+
const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, node.getStart());
|
|
270
|
+
findings.push((0, finding_1.buildFinding)({
|
|
271
|
+
ruleId: 'SEC004',
|
|
272
|
+
category: 'security',
|
|
273
|
+
severity: 'MEDIUM',
|
|
274
|
+
confidence: 'MEDIUM',
|
|
275
|
+
file: relativePath,
|
|
276
|
+
line,
|
|
277
|
+
column,
|
|
278
|
+
message: 'Unsafe HTML assignment',
|
|
279
|
+
description: 'dangerouslySetInnerHTML can introduce cross-site scripting if the value is not sanitized.',
|
|
280
|
+
evidence: node.getText().slice(0, 160),
|
|
281
|
+
suggestion: 'Sanitize the HTML before rendering, or avoid raw HTML injection.',
|
|
282
|
+
fixAvailable: false
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
typescript_1.default.forEachChild(node, visit);
|
|
287
|
+
};
|
|
288
|
+
visit(sourceFile);
|
|
289
|
+
}
|
|
290
|
+
return findings;
|
|
291
|
+
}
|
|
292
|
+
function scanTemplateFilesForUnsafeBindings(context) {
|
|
293
|
+
if (!(0, config_1.isRuleEnabled)(context.config, 'SEC004'))
|
|
294
|
+
return [];
|
|
295
|
+
const findings = [];
|
|
296
|
+
for (const file of context.allFiles) {
|
|
297
|
+
if (!/\.(?:vue|html)$/i.test(file))
|
|
298
|
+
continue;
|
|
299
|
+
const text = (0, discovery_1.readFileSafe)(file);
|
|
300
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
301
|
+
const relativePath = node_path_1.default.relative(context.projectRoot, file).replace(/\\/g, '/');
|
|
302
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
303
|
+
const match = /(?:\bv-html\s*=|\[innerHTML\]\s*=)/i.exec(lines[index]);
|
|
304
|
+
if (!match)
|
|
305
|
+
continue;
|
|
306
|
+
findings.push((0, finding_1.buildFinding)({
|
|
307
|
+
ruleId: 'SEC004', category: 'security', severity: 'MEDIUM', confidence: 'LOW', file: relativePath,
|
|
308
|
+
line: index + 1, column: match.index + 1, message: 'Unsafe template HTML binding',
|
|
309
|
+
description: 'A template uses a raw HTML binding; the text scan cannot verify whether the value is sanitized.',
|
|
310
|
+
evidence: match[0], suggestion: 'Sanitize the bound value or render it as text.', fixAvailable: false
|
|
311
|
+
}));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return findings;
|
|
315
|
+
}
|
|
316
|
+
function runSecurityRules(context) {
|
|
317
|
+
return [...runSecretScan(context), ...runAstSecurityChecks(context), ...scanTemplateFilesForUnsafeBindings(context)];
|
|
318
|
+
}
|