@indicated/vibeguard 1.0.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/.claude/settings.local.json +5 -0
- package/.github/workflows/ci.yml +65 -0
- package/.github/workflows/release.yml +85 -0
- package/PROGRESS.md +192 -0
- package/README.md +183 -0
- package/dist/api/license.d.ts +13 -0
- package/dist/api/license.d.ts.map +1 -0
- package/dist/api/license.js +138 -0
- package/dist/api/license.js.map +1 -0
- package/dist/api/rules.d.ts +13 -0
- package/dist/api/rules.d.ts.map +1 -0
- package/dist/api/rules.js +57 -0
- package/dist/api/rules.js.map +1 -0
- package/dist/cli/commands/init.d.ts +3 -0
- package/dist/cli/commands/init.d.ts.map +1 -0
- package/dist/cli/commands/init.js +145 -0
- package/dist/cli/commands/init.js.map +1 -0
- package/dist/cli/commands/login.d.ts +4 -0
- package/dist/cli/commands/login.d.ts.map +1 -0
- package/dist/cli/commands/login.js +121 -0
- package/dist/cli/commands/login.js.map +1 -0
- package/dist/cli/commands/mcp.d.ts +3 -0
- package/dist/cli/commands/mcp.d.ts.map +1 -0
- package/dist/cli/commands/mcp.js +14 -0
- package/dist/cli/commands/mcp.js.map +1 -0
- package/dist/cli/commands/rules.d.ts +3 -0
- package/dist/cli/commands/rules.d.ts.map +1 -0
- package/dist/cli/commands/rules.js +52 -0
- package/dist/cli/commands/rules.js.map +1 -0
- package/dist/cli/commands/scan.d.ts +3 -0
- package/dist/cli/commands/scan.d.ts.map +1 -0
- package/dist/cli/commands/scan.js +114 -0
- package/dist/cli/commands/scan.js.map +1 -0
- package/dist/cli/config.d.ts +4 -0
- package/dist/cli/config.d.ts.map +1 -0
- package/dist/cli/config.js +88 -0
- package/dist/cli/config.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +25 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/output.d.ts +15 -0
- package/dist/cli/output.d.ts.map +1 -0
- package/dist/cli/output.js +152 -0
- package/dist/cli/output.js.map +1 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +188 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/scanner/index.d.ts +15 -0
- package/dist/scanner/index.d.ts.map +1 -0
- package/dist/scanner/index.js +207 -0
- package/dist/scanner/index.js.map +1 -0
- package/dist/scanner/parsers/javascript.d.ts +12 -0
- package/dist/scanner/parsers/javascript.d.ts.map +1 -0
- package/dist/scanner/parsers/javascript.js +266 -0
- package/dist/scanner/parsers/javascript.js.map +1 -0
- package/dist/scanner/parsers/python.d.ts +3 -0
- package/dist/scanner/parsers/python.d.ts.map +1 -0
- package/dist/scanner/parsers/python.js +108 -0
- package/dist/scanner/parsers/python.js.map +1 -0
- package/dist/scanner/rules/definitions.d.ts +5 -0
- package/dist/scanner/rules/definitions.d.ts.map +1 -0
- package/dist/scanner/rules/definitions.js +584 -0
- package/dist/scanner/rules/definitions.js.map +1 -0
- package/dist/scanner/rules/loader.d.ts +8 -0
- package/dist/scanner/rules/loader.d.ts.map +1 -0
- package/dist/scanner/rules/loader.js +45 -0
- package/dist/scanner/rules/loader.js.map +1 -0
- package/dist/scanner/rules/matcher.d.ts +11 -0
- package/dist/scanner/rules/matcher.d.ts.map +1 -0
- package/dist/scanner/rules/matcher.js +53 -0
- package/dist/scanner/rules/matcher.js.map +1 -0
- package/dist/types.d.ts +33 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +48 -0
- package/src/api/license.ts +120 -0
- package/src/api/rules.ts +70 -0
- package/src/cli/commands/init.ts +123 -0
- package/src/cli/commands/login.ts +92 -0
- package/src/cli/commands/mcp.ts +12 -0
- package/src/cli/commands/rules.ts +58 -0
- package/src/cli/commands/scan.ts +94 -0
- package/src/cli/config.ts +54 -0
- package/src/cli/index.ts +28 -0
- package/src/cli/output.ts +159 -0
- package/src/mcp/server.ts +195 -0
- package/src/scanner/index.ts +195 -0
- package/src/scanner/parsers/javascript.ts +285 -0
- package/src/scanner/parsers/python.ts +126 -0
- package/src/scanner/rules/definitions.ts +592 -0
- package/src/scanner/rules/loader.ts +59 -0
- package/src/scanner/rules/matcher.ts +68 -0
- package/src/types.ts +36 -0
- package/test-samples/secure.js +52 -0
- package/test-samples/vulnerable.js +56 -0
- package/test-samples/vulnerable.py +39 -0
- package/tests/helpers.ts +43 -0
- package/tests/rules/critical.test.ts +186 -0
- package/tests/rules/definitions.test.ts +167 -0
- package/tests/rules/high.test.ts +377 -0
- package/tests/rules/low.test.ts +172 -0
- package/tests/rules/medium.test.ts +224 -0
- package/tests/scanner/scanner.test.ts +161 -0
- package/tsconfig.json +19 -0
- package/vibe-coding-security-checklist.md +245 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { glob } from 'glob';
|
|
4
|
+
import { Finding, ScanResult, SecurityRule, Config } from '../types';
|
|
5
|
+
import { loadRules, filterRules } from './rules/loader';
|
|
6
|
+
import { parseJavaScript, scanWithAST, scanWithPatterns } from './parsers/javascript';
|
|
7
|
+
import { scanPythonWithPatterns } from './parsers/python';
|
|
8
|
+
|
|
9
|
+
const SUPPORTED_EXTENSIONS: Record<string, string> = {
|
|
10
|
+
'.js': 'javascript',
|
|
11
|
+
'.jsx': 'javascript',
|
|
12
|
+
'.ts': 'typescript',
|
|
13
|
+
'.tsx': 'typescript',
|
|
14
|
+
'.mjs': 'javascript',
|
|
15
|
+
'.cjs': 'javascript',
|
|
16
|
+
'.py': 'python',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const DEFAULT_EXCLUDE = [
|
|
20
|
+
'**/node_modules/**',
|
|
21
|
+
'**/dist/**',
|
|
22
|
+
'**/build/**',
|
|
23
|
+
'**/.git/**',
|
|
24
|
+
'**/coverage/**',
|
|
25
|
+
'**/__pycache__/**',
|
|
26
|
+
'**/venv/**',
|
|
27
|
+
'**/.venv/**',
|
|
28
|
+
'**/env/**',
|
|
29
|
+
'**/*.min.js',
|
|
30
|
+
'**/*.bundle.js',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
export class Scanner {
|
|
34
|
+
private rules: SecurityRule[] = [];
|
|
35
|
+
private config: Config;
|
|
36
|
+
|
|
37
|
+
constructor(config: Config = {}) {
|
|
38
|
+
this.config = config;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async initialize(licenseKey?: string): Promise<void> {
|
|
42
|
+
const allRules = await loadRules(licenseKey);
|
|
43
|
+
this.rules = filterRules(allRules, {
|
|
44
|
+
enabled: this.config.rules?.enabled,
|
|
45
|
+
disabled: this.config.rules?.disabled,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async scan(targets: string[]): Promise<ScanResult> {
|
|
50
|
+
const startTime = Date.now();
|
|
51
|
+
const findings: Finding[] = [];
|
|
52
|
+
const files: string[] = [];
|
|
53
|
+
|
|
54
|
+
for (const target of targets) {
|
|
55
|
+
const targetPath = path.resolve(target);
|
|
56
|
+
const stat = fs.statSync(targetPath);
|
|
57
|
+
|
|
58
|
+
if (stat.isDirectory()) {
|
|
59
|
+
const globPattern = path.join(targetPath, '**/*');
|
|
60
|
+
const exclude = [...DEFAULT_EXCLUDE, ...(this.config.exclude || [])];
|
|
61
|
+
|
|
62
|
+
const matchedFiles = await glob(globPattern, {
|
|
63
|
+
ignore: exclude,
|
|
64
|
+
nodir: true,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
files.push(...matchedFiles.filter(f => this.isSupportedFile(f)));
|
|
68
|
+
} else if (stat.isFile() && this.isSupportedFile(targetPath)) {
|
|
69
|
+
files.push(targetPath);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
const fileFindings = await this.scanFile(file);
|
|
75
|
+
findings.push(...fileFindings);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
files: files.length,
|
|
80
|
+
findings: this.sortFindings(findings),
|
|
81
|
+
duration: Date.now() - startTime,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async scanStaged(): Promise<ScanResult> {
|
|
86
|
+
const startTime = Date.now();
|
|
87
|
+
const findings: Finding[] = [];
|
|
88
|
+
|
|
89
|
+
// Get staged files from git
|
|
90
|
+
const { execSync } = await import('child_process');
|
|
91
|
+
let stagedFiles: string[] = [];
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const output = execSync('git diff --cached --name-only --diff-filter=ACM', {
|
|
95
|
+
encoding: 'utf-8',
|
|
96
|
+
});
|
|
97
|
+
stagedFiles = output
|
|
98
|
+
.split('\n')
|
|
99
|
+
.filter(f => f.trim() && this.isSupportedFile(f))
|
|
100
|
+
.map(f => path.resolve(f));
|
|
101
|
+
} catch {
|
|
102
|
+
// Not in a git repo or git not available
|
|
103
|
+
return {
|
|
104
|
+
files: 0,
|
|
105
|
+
findings: [],
|
|
106
|
+
duration: Date.now() - startTime,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const file of stagedFiles) {
|
|
111
|
+
if (fs.existsSync(file)) {
|
|
112
|
+
const fileFindings = await this.scanFile(file);
|
|
113
|
+
findings.push(...fileFindings);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
files: stagedFiles.length,
|
|
119
|
+
findings: this.sortFindings(findings),
|
|
120
|
+
duration: Date.now() - startTime,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async scanFile(filePath: string): Promise<Finding[]> {
|
|
125
|
+
const findings: Finding[] = [];
|
|
126
|
+
const ext = path.extname(filePath);
|
|
127
|
+
const language = SUPPORTED_EXTENSIONS[ext];
|
|
128
|
+
|
|
129
|
+
if (!language) return findings;
|
|
130
|
+
|
|
131
|
+
let code: string;
|
|
132
|
+
try {
|
|
133
|
+
code = fs.readFileSync(filePath, 'utf-8');
|
|
134
|
+
} catch {
|
|
135
|
+
return findings;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Filter rules by language
|
|
139
|
+
const languageRules = this.rules.filter(rule =>
|
|
140
|
+
rule.languages.includes(language as 'javascript' | 'typescript' | 'python')
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
if (language === 'javascript' || language === 'typescript') {
|
|
144
|
+
// Try AST-based scanning
|
|
145
|
+
const ast = parseJavaScript(code, filePath);
|
|
146
|
+
if (ast) {
|
|
147
|
+
const astFindings = scanWithAST(ast, languageRules, {
|
|
148
|
+
code,
|
|
149
|
+
lines: code.split('\n'),
|
|
150
|
+
filePath,
|
|
151
|
+
});
|
|
152
|
+
findings.push(...astFindings);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Also run pattern-based scanning
|
|
156
|
+
const patternFindings = scanWithPatterns(code, languageRules, filePath);
|
|
157
|
+
findings.push(...patternFindings);
|
|
158
|
+
} else if (language === 'python') {
|
|
159
|
+
const patternFindings = scanPythonWithPatterns(code, languageRules, filePath);
|
|
160
|
+
findings.push(...patternFindings);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Deduplicate findings
|
|
164
|
+
return this.deduplicateFindings(findings);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private isSupportedFile(filePath: string): boolean {
|
|
168
|
+
const ext = path.extname(filePath);
|
|
169
|
+
return ext in SUPPORTED_EXTENSIONS;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private sortFindings(findings: Finding[]): Finding[] {
|
|
173
|
+
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
174
|
+
return findings.sort((a, b) => {
|
|
175
|
+
const severityDiff =
|
|
176
|
+
severityOrder[a.rule.severity] - severityOrder[b.rule.severity];
|
|
177
|
+
if (severityDiff !== 0) return severityDiff;
|
|
178
|
+
return a.file.localeCompare(b.file) || a.line - b.line;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private deduplicateFindings(findings: Finding[]): Finding[] {
|
|
183
|
+
const seen = new Set<string>();
|
|
184
|
+
return findings.filter(f => {
|
|
185
|
+
const key = `${f.rule.id}:${f.file}:${f.line}`;
|
|
186
|
+
if (seen.has(key)) return false;
|
|
187
|
+
seen.add(key);
|
|
188
|
+
return true;
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
getRules(): SecurityRule[] {
|
|
193
|
+
return this.rules;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import * as parser from '@babel/parser';
|
|
2
|
+
import traverse, { NodePath } from '@babel/traverse';
|
|
3
|
+
import * as t from '@babel/types';
|
|
4
|
+
import { Finding, SecurityRule } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface ASTContext {
|
|
7
|
+
code: string;
|
|
8
|
+
lines: string[];
|
|
9
|
+
filePath: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function parseJavaScript(code: string, filePath: string): t.File | null {
|
|
13
|
+
try {
|
|
14
|
+
return parser.parse(code, {
|
|
15
|
+
sourceType: 'module',
|
|
16
|
+
plugins: [
|
|
17
|
+
'jsx',
|
|
18
|
+
'typescript',
|
|
19
|
+
'decorators-legacy',
|
|
20
|
+
'classProperties',
|
|
21
|
+
'optionalChaining',
|
|
22
|
+
'nullishCoalescingOperator',
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
} catch {
|
|
26
|
+
// If parsing fails, return null and rely on regex patterns only
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function scanWithAST(
|
|
32
|
+
ast: t.File,
|
|
33
|
+
rules: SecurityRule[],
|
|
34
|
+
context: ASTContext
|
|
35
|
+
): Finding[] {
|
|
36
|
+
const findings: Finding[] = [];
|
|
37
|
+
|
|
38
|
+
const astMatchers: Record<string, (path: NodePath) => Finding | null> = {
|
|
39
|
+
'eval-usage': (path: NodePath) => {
|
|
40
|
+
if (
|
|
41
|
+
path.isCallExpression() &&
|
|
42
|
+
t.isIdentifier(path.node.callee) &&
|
|
43
|
+
path.node.callee.name === 'eval'
|
|
44
|
+
) {
|
|
45
|
+
const rule = rules.find(r => r.id === 'eval-usage');
|
|
46
|
+
if (rule) {
|
|
47
|
+
const loc = path.node.loc;
|
|
48
|
+
return {
|
|
49
|
+
rule,
|
|
50
|
+
file: context.filePath,
|
|
51
|
+
line: loc?.start.line || 0,
|
|
52
|
+
column: loc?.start.column || 0,
|
|
53
|
+
code: context.lines[(loc?.start.line || 1) - 1] || '',
|
|
54
|
+
message: rule.description,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
'sql-injection': (path: NodePath) => {
|
|
62
|
+
if (path.isTemplateLiteral() || path.isBinaryExpression()) {
|
|
63
|
+
const parent = path.parentPath;
|
|
64
|
+
if (
|
|
65
|
+
parent?.isCallExpression() &&
|
|
66
|
+
t.isMemberExpression(parent.node.callee)
|
|
67
|
+
) {
|
|
68
|
+
const callee = parent.node.callee;
|
|
69
|
+
const methodName = t.isIdentifier(callee.property) ? callee.property.name : '';
|
|
70
|
+
|
|
71
|
+
if (['query', 'exec', 'execute', 'raw'].includes(methodName)) {
|
|
72
|
+
// Check if there's string concatenation or template literal with expressions
|
|
73
|
+
if (
|
|
74
|
+
path.isTemplateLiteral() &&
|
|
75
|
+
path.node.expressions.length > 0
|
|
76
|
+
) {
|
|
77
|
+
const codeSnippet = context.code.substring(
|
|
78
|
+
path.node.start || 0,
|
|
79
|
+
path.node.end || 0
|
|
80
|
+
).toLowerCase();
|
|
81
|
+
|
|
82
|
+
if (
|
|
83
|
+
codeSnippet.includes('select') ||
|
|
84
|
+
codeSnippet.includes('insert') ||
|
|
85
|
+
codeSnippet.includes('update') ||
|
|
86
|
+
codeSnippet.includes('delete') ||
|
|
87
|
+
codeSnippet.includes('where')
|
|
88
|
+
) {
|
|
89
|
+
const rule = rules.find(r => r.id === 'sql-injection');
|
|
90
|
+
if (rule) {
|
|
91
|
+
const loc = path.node.loc;
|
|
92
|
+
return {
|
|
93
|
+
rule,
|
|
94
|
+
file: context.filePath,
|
|
95
|
+
line: loc?.start.line || 0,
|
|
96
|
+
column: loc?.start.column || 0,
|
|
97
|
+
code: context.lines[(loc?.start.line || 1) - 1] || '',
|
|
98
|
+
message: rule.description,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
'xss-innerhtml': (path: NodePath) => {
|
|
110
|
+
if (path.isAssignmentExpression()) {
|
|
111
|
+
const left = path.node.left;
|
|
112
|
+
if (
|
|
113
|
+
t.isMemberExpression(left) &&
|
|
114
|
+
t.isIdentifier(left.property) &&
|
|
115
|
+
left.property.name === 'innerHTML'
|
|
116
|
+
) {
|
|
117
|
+
const rule = rules.find(r => r.id === 'xss-innerhtml');
|
|
118
|
+
if (rule) {
|
|
119
|
+
const loc = path.node.loc;
|
|
120
|
+
return {
|
|
121
|
+
rule,
|
|
122
|
+
file: context.filePath,
|
|
123
|
+
line: loc?.start.line || 0,
|
|
124
|
+
column: loc?.start.column || 0,
|
|
125
|
+
code: context.lines[(loc?.start.line || 1) - 1] || '',
|
|
126
|
+
message: rule.description,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Check for dangerouslySetInnerHTML in JSX
|
|
133
|
+
if (path.isJSXAttribute()) {
|
|
134
|
+
const name = path.node.name;
|
|
135
|
+
if (t.isJSXIdentifier(name) && name.name === 'dangerouslySetInnerHTML') {
|
|
136
|
+
const rule = rules.find(r => r.id === 'xss-innerhtml');
|
|
137
|
+
if (rule) {
|
|
138
|
+
const loc = path.node.loc;
|
|
139
|
+
return {
|
|
140
|
+
rule,
|
|
141
|
+
file: context.filePath,
|
|
142
|
+
line: loc?.start.line || 0,
|
|
143
|
+
column: loc?.start.column || 0,
|
|
144
|
+
code: context.lines[(loc?.start.line || 1) - 1] || '',
|
|
145
|
+
message: rule.description,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return null;
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
'missing-auth': (path: NodePath) => {
|
|
155
|
+
// Detect Express/Next.js API routes without auth checks
|
|
156
|
+
if (path.isCallExpression()) {
|
|
157
|
+
const callee = path.node.callee;
|
|
158
|
+
if (
|
|
159
|
+
t.isMemberExpression(callee) &&
|
|
160
|
+
t.isIdentifier(callee.property) &&
|
|
161
|
+
['get', 'post', 'put', 'delete', 'patch'].includes(callee.property.name)
|
|
162
|
+
) {
|
|
163
|
+
const args = path.node.arguments;
|
|
164
|
+
if (args.length >= 2) {
|
|
165
|
+
// Get the full call expression code to check for middleware
|
|
166
|
+
const callCode = context.code.substring(
|
|
167
|
+
path.node.start || 0,
|
|
168
|
+
path.node.end || 0
|
|
169
|
+
).toLowerCase();
|
|
170
|
+
|
|
171
|
+
// Check if any argument mentions auth-related terms (middleware)
|
|
172
|
+
const hasAuthMiddleware =
|
|
173
|
+
callCode.includes('auth') ||
|
|
174
|
+
callCode.includes('session') ||
|
|
175
|
+
callCode.includes('token') ||
|
|
176
|
+
callCode.includes('jwt') ||
|
|
177
|
+
callCode.includes('middleware') ||
|
|
178
|
+
callCode.includes('isauthenticated') ||
|
|
179
|
+
callCode.includes('requireauth') ||
|
|
180
|
+
callCode.includes('protect') ||
|
|
181
|
+
callCode.includes('verify');
|
|
182
|
+
|
|
183
|
+
if (!hasAuthMiddleware) {
|
|
184
|
+
// Check route path for sensitive endpoints
|
|
185
|
+
const routePath = args[0];
|
|
186
|
+
if (t.isStringLiteral(routePath)) {
|
|
187
|
+
const pathValue = routePath.value.toLowerCase();
|
|
188
|
+
if (
|
|
189
|
+
pathValue.includes('/api/') ||
|
|
190
|
+
pathValue.includes('/user') ||
|
|
191
|
+
pathValue.includes('/admin') ||
|
|
192
|
+
pathValue.includes('/account') ||
|
|
193
|
+
pathValue.includes('/profile')
|
|
194
|
+
) {
|
|
195
|
+
const rule = rules.find(r => r.id === 'missing-auth-route');
|
|
196
|
+
if (rule) {
|
|
197
|
+
const loc = path.node.loc;
|
|
198
|
+
return {
|
|
199
|
+
rule,
|
|
200
|
+
file: context.filePath,
|
|
201
|
+
line: loc?.start.line || 0,
|
|
202
|
+
column: loc?.start.column || 0,
|
|
203
|
+
code: context.lines[(loc?.start.line || 1) - 1] || '',
|
|
204
|
+
message: `API route ${routePath.value} may be missing authentication`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
traverse(ast, {
|
|
218
|
+
enter(path) {
|
|
219
|
+
for (const matcherKey of Object.keys(astMatchers)) {
|
|
220
|
+
const finding = astMatchers[matcherKey](path);
|
|
221
|
+
if (finding) {
|
|
222
|
+
// Avoid duplicates
|
|
223
|
+
const isDuplicate = findings.some(
|
|
224
|
+
f =>
|
|
225
|
+
f.rule.id === finding.rule.id &&
|
|
226
|
+
f.line === finding.line &&
|
|
227
|
+
f.file === finding.file
|
|
228
|
+
);
|
|
229
|
+
if (!isDuplicate) {
|
|
230
|
+
findings.push(finding);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
return findings;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function scanWithPatterns(
|
|
241
|
+
code: string,
|
|
242
|
+
rules: SecurityRule[],
|
|
243
|
+
filePath: string
|
|
244
|
+
): Finding[] {
|
|
245
|
+
const findings: Finding[] = [];
|
|
246
|
+
const lines = code.split('\n');
|
|
247
|
+
|
|
248
|
+
for (const rule of rules) {
|
|
249
|
+
if (!rule.patterns) continue;
|
|
250
|
+
|
|
251
|
+
for (const pattern of rule.patterns) {
|
|
252
|
+
let match;
|
|
253
|
+
const regex = new RegExp(pattern.source, pattern.flags + (pattern.flags.includes('g') ? '' : 'g'));
|
|
254
|
+
|
|
255
|
+
while ((match = regex.exec(code)) !== null) {
|
|
256
|
+
// Calculate line number from match index
|
|
257
|
+
const beforeMatch = code.substring(0, match.index);
|
|
258
|
+
const lineNumber = (beforeMatch.match(/\n/g) || []).length + 1;
|
|
259
|
+
const lineStart = beforeMatch.lastIndexOf('\n') + 1;
|
|
260
|
+
const column = match.index - lineStart;
|
|
261
|
+
|
|
262
|
+
// Avoid duplicates
|
|
263
|
+
const isDuplicate = findings.some(
|
|
264
|
+
f =>
|
|
265
|
+
f.rule.id === rule.id &&
|
|
266
|
+
f.line === lineNumber &&
|
|
267
|
+
f.file === filePath
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
if (!isDuplicate) {
|
|
271
|
+
findings.push({
|
|
272
|
+
rule,
|
|
273
|
+
file: filePath,
|
|
274
|
+
line: lineNumber,
|
|
275
|
+
column,
|
|
276
|
+
code: lines[lineNumber - 1] || '',
|
|
277
|
+
message: rule.description,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return findings;
|
|
285
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Finding, SecurityRule } from '../../types';
|
|
2
|
+
|
|
3
|
+
// Python-specific patterns in addition to shared patterns
|
|
4
|
+
const pythonPatterns: { ruleId: string; pattern: RegExp }[] = [
|
|
5
|
+
// SQL injection patterns
|
|
6
|
+
{
|
|
7
|
+
ruleId: 'sql-injection',
|
|
8
|
+
pattern: /cursor\.execute\s*\(\s*f?['"`](?:SELECT|INSERT|UPDATE|DELETE)[^'"`]*\{[^}]+\}/i,
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
ruleId: 'sql-injection',
|
|
12
|
+
pattern: /\.execute\s*\(\s*['"`](?:SELECT|INSERT|UPDATE|DELETE)[^'"`]*['"]\s*%\s*/i,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
ruleId: 'sql-injection',
|
|
16
|
+
pattern: /\.execute\s*\(\s*['"`](?:SELECT|INSERT|UPDATE|DELETE)[^'"`]*['"`]\s*\+/i,
|
|
17
|
+
},
|
|
18
|
+
// Eval usage
|
|
19
|
+
{
|
|
20
|
+
ruleId: 'eval-usage',
|
|
21
|
+
pattern: /\beval\s*\([^)]*(?:input|request|data|params|args)/i,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
ruleId: 'eval-usage',
|
|
25
|
+
pattern: /\bexec\s*\([^)]*(?:input|request|data|params|args)/i,
|
|
26
|
+
},
|
|
27
|
+
// Hardcoded secrets
|
|
28
|
+
{
|
|
29
|
+
ruleId: 'hardcoded-secret',
|
|
30
|
+
pattern: /(?:api[_-]?key|secret[_-]?key|password|token)\s*=\s*['"][^'"]{8,}['"]/i,
|
|
31
|
+
},
|
|
32
|
+
// Flask/Django without CSRF
|
|
33
|
+
{
|
|
34
|
+
ruleId: 'permissive-cors',
|
|
35
|
+
pattern: /CORS\s*\(\s*app\s*(?:,\s*resources\s*=\s*\{[^}]*\*[^}]*\})?/,
|
|
36
|
+
},
|
|
37
|
+
// Debug mode in production
|
|
38
|
+
{
|
|
39
|
+
ruleId: 'verbose-errors',
|
|
40
|
+
pattern: /app\.run\s*\([^)]*debug\s*=\s*True/i,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
ruleId: 'verbose-errors',
|
|
44
|
+
pattern: /DEBUG\s*=\s*True/,
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export function scanPythonWithPatterns(
|
|
49
|
+
code: string,
|
|
50
|
+
rules: SecurityRule[],
|
|
51
|
+
filePath: string
|
|
52
|
+
): Finding[] {
|
|
53
|
+
const findings: Finding[] = [];
|
|
54
|
+
const lines = code.split('\n');
|
|
55
|
+
|
|
56
|
+
// First, scan with rule-defined patterns
|
|
57
|
+
for (const rule of rules) {
|
|
58
|
+
if (!rule.patterns || !rule.languages.includes('python')) continue;
|
|
59
|
+
|
|
60
|
+
for (const pattern of rule.patterns) {
|
|
61
|
+
let match;
|
|
62
|
+
const regex = new RegExp(pattern.source, pattern.flags + (pattern.flags.includes('g') ? '' : 'g'));
|
|
63
|
+
|
|
64
|
+
while ((match = regex.exec(code)) !== null) {
|
|
65
|
+
const beforeMatch = code.substring(0, match.index);
|
|
66
|
+
const lineNumber = (beforeMatch.match(/\n/g) || []).length + 1;
|
|
67
|
+
const lineStart = beforeMatch.lastIndexOf('\n') + 1;
|
|
68
|
+
const column = match.index - lineStart;
|
|
69
|
+
|
|
70
|
+
const isDuplicate = findings.some(
|
|
71
|
+
f =>
|
|
72
|
+
f.rule.id === rule.id &&
|
|
73
|
+
f.line === lineNumber &&
|
|
74
|
+
f.file === filePath
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
if (!isDuplicate) {
|
|
78
|
+
findings.push({
|
|
79
|
+
rule,
|
|
80
|
+
file: filePath,
|
|
81
|
+
line: lineNumber,
|
|
82
|
+
column,
|
|
83
|
+
code: lines[lineNumber - 1] || '',
|
|
84
|
+
message: rule.description,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Then scan with Python-specific patterns
|
|
92
|
+
for (const { ruleId, pattern } of pythonPatterns) {
|
|
93
|
+
const rule = rules.find(r => r.id === ruleId);
|
|
94
|
+
if (!rule) continue;
|
|
95
|
+
|
|
96
|
+
let match;
|
|
97
|
+
const regex = new RegExp(pattern.source, pattern.flags + (pattern.flags.includes('g') ? '' : 'g'));
|
|
98
|
+
|
|
99
|
+
while ((match = regex.exec(code)) !== null) {
|
|
100
|
+
const beforeMatch = code.substring(0, match.index);
|
|
101
|
+
const lineNumber = (beforeMatch.match(/\n/g) || []).length + 1;
|
|
102
|
+
const lineStart = beforeMatch.lastIndexOf('\n') + 1;
|
|
103
|
+
const column = match.index - lineStart;
|
|
104
|
+
|
|
105
|
+
const isDuplicate = findings.some(
|
|
106
|
+
f =>
|
|
107
|
+
f.rule.id === ruleId &&
|
|
108
|
+
f.line === lineNumber &&
|
|
109
|
+
f.file === filePath
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (!isDuplicate) {
|
|
113
|
+
findings.push({
|
|
114
|
+
rule,
|
|
115
|
+
file: filePath,
|
|
116
|
+
line: lineNumber,
|
|
117
|
+
column,
|
|
118
|
+
code: lines[lineNumber - 1] || '',
|
|
119
|
+
message: rule.description,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return findings;
|
|
126
|
+
}
|