deltarq-scan 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/README.md +210 -0
- package/bin/deltarq-scan.js +198 -0
- package/logo.png +0 -0
- package/package.json +50 -0
- package/src/engine/aggregator.js +96 -0
- package/src/engine/anonymizer.js +79 -0
- package/src/output/terminal.js +218 -0
- package/src/output/uploader.js +122 -0
- package/src/rules/data.js +24 -0
- package/src/rules/git.js +14 -0
- package/src/rules/identity.js +34 -0
- package/src/rules/index.js +53 -0
- package/src/rules/infrastructure.js +44 -0
- package/src/rules/logging.js +14 -0
- package/src/scanner/awsScanner.js +138 -0
- package/src/scanner/dbScanner.js +67 -0
- package/src/scanner/fileScanner.js +422 -0
- package/src/scanner/gitScanner.js +115 -0
- package/src/utils/detect.js +90 -0
- package/src/utils/fileUtils.js +88 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database Scanner — Analyzes database connection patterns for security issues
|
|
3
|
+
* No actual database connections are made — pattern matching only
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { readFileSafe, findFiles, parseEnvContent } from '../utils/fileUtils.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Run database-specific security scans
|
|
11
|
+
* @param {string} targetDir - The project root directory
|
|
12
|
+
* @returns {Promise<Array<{ rule: string, severity: string, passed: boolean, detail: string }>>}
|
|
13
|
+
*/
|
|
14
|
+
export async function runDbScanner(targetDir) {
|
|
15
|
+
const findings = [];
|
|
16
|
+
|
|
17
|
+
// Scan all source files for database connection patterns
|
|
18
|
+
const sourceFiles = await findFiles(targetDir, [
|
|
19
|
+
'**/*.js',
|
|
20
|
+
'**/*.ts',
|
|
21
|
+
'**/*.py',
|
|
22
|
+
'**/*.rb',
|
|
23
|
+
'**/*.go',
|
|
24
|
+
'**/config/**',
|
|
25
|
+
'**/database.*',
|
|
26
|
+
'**/db.*',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
for (const file of sourceFiles) {
|
|
30
|
+
const content = readFileSafe(file);
|
|
31
|
+
if (!content) continue;
|
|
32
|
+
|
|
33
|
+
const relPath = path.relative(targetDir, file);
|
|
34
|
+
|
|
35
|
+
// DB-002: Check for connection pool configuration in source code
|
|
36
|
+
const hasDbConnection = content.includes('createPool') ||
|
|
37
|
+
content.includes('Pool(') ||
|
|
38
|
+
content.includes('new Pool') ||
|
|
39
|
+
content.includes('ConnectionPool') ||
|
|
40
|
+
content.includes('pool') ||
|
|
41
|
+
content.includes('create_engine') ||
|
|
42
|
+
content.includes('Sequelize');
|
|
43
|
+
|
|
44
|
+
if (hasDbConnection) {
|
|
45
|
+
const hasPoolLimit = content.includes('max:') ||
|
|
46
|
+
content.includes('max =') ||
|
|
47
|
+
content.includes('pool_size') ||
|
|
48
|
+
content.includes('maxConnections') ||
|
|
49
|
+
content.includes('max_connections') ||
|
|
50
|
+
content.includes('pool_max_size');
|
|
51
|
+
|
|
52
|
+
if (!hasPoolLimit) {
|
|
53
|
+
findings.push({
|
|
54
|
+
rule: 'DB-002',
|
|
55
|
+
severity: 'MEDIUM',
|
|
56
|
+
passed: false,
|
|
57
|
+
detail: `Database connection in ${relPath} may not have a pool size limit`,
|
|
58
|
+
file: relPath,
|
|
59
|
+
});
|
|
60
|
+
// Only report once
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return findings;
|
|
67
|
+
}
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Scanner — Scans .env, Dockerfile, docker-compose.yml for security issues
|
|
3
|
+
* This is the core scanner that checks local config files
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import yaml from 'js-yaml';
|
|
8
|
+
import { readFileSafe, findFiles, parseEnvContent, fileExists } from '../utils/fileUtils.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Run all file-based scans on the target directory
|
|
12
|
+
* @param {string} targetDir - The project root directory
|
|
13
|
+
* @returns {Promise<Array<{ rule: string, severity: string, passed: boolean, detail: string }>>}
|
|
14
|
+
*/
|
|
15
|
+
export async function runFileScanner(targetDir) {
|
|
16
|
+
const findings = [];
|
|
17
|
+
|
|
18
|
+
// Scan .env files
|
|
19
|
+
const envFindings = await scanEnvFiles(targetDir);
|
|
20
|
+
findings.push(...envFindings);
|
|
21
|
+
|
|
22
|
+
// Scan Dockerfiles
|
|
23
|
+
const dockerFindings = await scanDockerfiles(targetDir);
|
|
24
|
+
findings.push(...dockerFindings);
|
|
25
|
+
|
|
26
|
+
// Scan docker-compose files
|
|
27
|
+
const composeFindings = await scanDockerCompose(targetDir);
|
|
28
|
+
findings.push(...composeFindings);
|
|
29
|
+
|
|
30
|
+
// Scan for audit logging patterns
|
|
31
|
+
const logFindings = await scanForAuditLogs(targetDir);
|
|
32
|
+
findings.push(...logFindings);
|
|
33
|
+
|
|
34
|
+
// Scan lockfiles (INFRA-003)
|
|
35
|
+
const lockFindings = await scanLockfiles(targetDir);
|
|
36
|
+
findings.push(...lockFindings);
|
|
37
|
+
|
|
38
|
+
// Scan CI pipelines (INFRA-004)
|
|
39
|
+
const ciFindings = await scanCIPipelines(targetDir);
|
|
40
|
+
findings.push(...ciFindings);
|
|
41
|
+
|
|
42
|
+
return findings;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Scan .env files for database and credential issues
|
|
47
|
+
*/
|
|
48
|
+
async function scanEnvFiles(targetDir) {
|
|
49
|
+
const findings = [];
|
|
50
|
+
const envFiles = await findFiles(targetDir, ['.env', '.env.*', '**/.env', '!.env.example']);
|
|
51
|
+
|
|
52
|
+
for (const envFile of envFiles) {
|
|
53
|
+
const content = readFileSafe(envFile);
|
|
54
|
+
if (!content) continue;
|
|
55
|
+
|
|
56
|
+
const parsed = parseEnvContent(content);
|
|
57
|
+
const relPath = path.relative(targetDir, envFile);
|
|
58
|
+
|
|
59
|
+
// DB-001: Check DATABASE_URL for sslmode=disable or public host
|
|
60
|
+
const dbUrl = parsed.DATABASE_URL || parsed.DB_URL || parsed.POSTGRES_URL;
|
|
61
|
+
if (dbUrl) {
|
|
62
|
+
const hasSSLDisabled = dbUrl.includes('sslmode=disable') || !dbUrl.includes('sslmode');
|
|
63
|
+
const isPublicHost = !isPrivateHost(dbUrl);
|
|
64
|
+
|
|
65
|
+
if (hasSSLDisabled && isPublicHost) {
|
|
66
|
+
findings.push({
|
|
67
|
+
rule: 'DB-001',
|
|
68
|
+
severity: 'CRITICAL',
|
|
69
|
+
passed: false,
|
|
70
|
+
detail: `Unencrypted DB connection on public host found in ${relPath}`,
|
|
71
|
+
file: relPath,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Also check POSTGRES_HOST directly
|
|
77
|
+
const pgHost = parsed.POSTGRES_HOST || parsed.DB_HOST;
|
|
78
|
+
const pgSSL = parsed.POSTGRES_SSLMODE || parsed.DB_SSLMODE;
|
|
79
|
+
if (pgHost && !isPrivateHost(`host://${pgHost}`) && (!pgSSL || pgSSL === 'disable')) {
|
|
80
|
+
findings.push({
|
|
81
|
+
rule: 'DB-001',
|
|
82
|
+
severity: 'CRITICAL',
|
|
83
|
+
passed: false,
|
|
84
|
+
detail: `Postgres host "${pgHost}" appears publicly accessible without SSL in ${relPath}`,
|
|
85
|
+
file: relPath,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// IAM-002: Check for hardcoded Cloud/API Secrets (AWS, Stripe, Slack, GitHub)
|
|
90
|
+
const hasAccessKey = parsed.AWS_ACCESS_KEY_ID || parsed.AWS_ACCESS_KEY;
|
|
91
|
+
const hasSecretKey = parsed.AWS_SECRET_ACCESS_KEY || parsed.AWS_SECRET_KEY;
|
|
92
|
+
let foundSecret = null;
|
|
93
|
+
|
|
94
|
+
if (hasAccessKey && hasSecretKey) foundSecret = 'AWS credentials';
|
|
95
|
+
|
|
96
|
+
if (!foundSecret) {
|
|
97
|
+
// Check values against regex patterns
|
|
98
|
+
const stripePattern = /^sk_live_[a-zA-Z0-9]+$/;
|
|
99
|
+
const slackPattern = /^xox[bap]-[0-9A-Za-z\-]+$/;
|
|
100
|
+
const githubPattern = /^gh[po]_[a-zA-Z0-9]{36}$/;
|
|
101
|
+
|
|
102
|
+
for (const value of Object.values(parsed)) {
|
|
103
|
+
if (stripePattern.test(value)) { foundSecret = 'Stripe Live Secret'; break; }
|
|
104
|
+
if (slackPattern.test(value)) { foundSecret = 'Slack Token'; break; }
|
|
105
|
+
if (githubPattern.test(value)) { foundSecret = 'GitHub Token'; break; }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (foundSecret) {
|
|
110
|
+
findings.push({
|
|
111
|
+
rule: 'IAM-002',
|
|
112
|
+
severity: 'HIGH',
|
|
113
|
+
passed: false,
|
|
114
|
+
detail: `Hardcoded ${foundSecret} found in ${relPath}`,
|
|
115
|
+
file: relPath,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// DB-002: Check for connection pool config
|
|
120
|
+
const hasPoolMax = parsed.DB_POOL_MAX || parsed.POOL_MAX || parsed.DB_POOL_SIZE;
|
|
121
|
+
if (dbUrl && !hasPoolMax) {
|
|
122
|
+
// Check if there's any pool config hint in the .env
|
|
123
|
+
const envContent = content.toLowerCase();
|
|
124
|
+
const hasPoolConfig = envContent.includes('pool_max') ||
|
|
125
|
+
envContent.includes('pool_size') ||
|
|
126
|
+
envContent.includes('max_connections');
|
|
127
|
+
if (!hasPoolConfig) {
|
|
128
|
+
findings.push({
|
|
129
|
+
rule: 'DB-002',
|
|
130
|
+
severity: 'MEDIUM',
|
|
131
|
+
passed: false,
|
|
132
|
+
detail: `No connection pool limit configured in ${relPath}`,
|
|
133
|
+
file: relPath,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return findings;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Scan Dockerfiles for security issues
|
|
144
|
+
*/
|
|
145
|
+
async function scanDockerfiles(targetDir) {
|
|
146
|
+
const findings = [];
|
|
147
|
+
const dockerfiles = await findFiles(targetDir, ['Dockerfile', 'Dockerfile.*', '**/Dockerfile']);
|
|
148
|
+
|
|
149
|
+
for (const dockerfile of dockerfiles) {
|
|
150
|
+
const content = readFileSafe(dockerfile);
|
|
151
|
+
if (!content) continue;
|
|
152
|
+
|
|
153
|
+
const relPath = path.relative(targetDir, dockerfile);
|
|
154
|
+
|
|
155
|
+
// INFRA-001: Check for USER directive
|
|
156
|
+
const lines = content.split('\n');
|
|
157
|
+
const hasUserDirective = lines.some(line => {
|
|
158
|
+
const trimmed = line.trim();
|
|
159
|
+
return trimmed.startsWith('USER ') && !trimmed.startsWith('#');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (!hasUserDirective) {
|
|
163
|
+
findings.push({
|
|
164
|
+
rule: 'INFRA-001',
|
|
165
|
+
severity: 'CRITICAL',
|
|
166
|
+
passed: false,
|
|
167
|
+
detail: `No USER directive in ${relPath} — container runs as root`,
|
|
168
|
+
file: relPath,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return findings;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Scan docker-compose files for exposed ports
|
|
178
|
+
*/
|
|
179
|
+
async function scanDockerCompose(targetDir) {
|
|
180
|
+
const findings = [];
|
|
181
|
+
const composeFiles = await findFiles(targetDir, [
|
|
182
|
+
'docker-compose.yml',
|
|
183
|
+
'docker-compose.yaml',
|
|
184
|
+
'docker-compose.*.yml',
|
|
185
|
+
'docker-compose.*.yaml',
|
|
186
|
+
'compose.yml',
|
|
187
|
+
'compose.yaml',
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
for (const composeFile of composeFiles) {
|
|
191
|
+
const content = readFileSafe(composeFile);
|
|
192
|
+
if (!content) continue;
|
|
193
|
+
|
|
194
|
+
const relPath = path.relative(targetDir, composeFile);
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const compose = yaml.load(content);
|
|
198
|
+
if (!compose || !compose.services) continue;
|
|
199
|
+
|
|
200
|
+
for (const [serviceName, service] of Object.entries(compose.services)) {
|
|
201
|
+
if (!service.ports) continue;
|
|
202
|
+
|
|
203
|
+
for (const portMapping of service.ports) {
|
|
204
|
+
const portStr = String(portMapping);
|
|
205
|
+
|
|
206
|
+
// INFRA-002: Check if DB ports are exposed to all interfaces
|
|
207
|
+
const dbPorts = ['5432', '3306', '27017', '6379', '1433'];
|
|
208
|
+
const isDBPort = dbPorts.some(p => portStr.includes(p));
|
|
209
|
+
const isBoundToAll = !portStr.startsWith('127.0.0.1:') &&
|
|
210
|
+
!portStr.startsWith('localhost:');
|
|
211
|
+
|
|
212
|
+
if (isDBPort && isBoundToAll) {
|
|
213
|
+
findings.push({
|
|
214
|
+
rule: 'INFRA-002',
|
|
215
|
+
severity: 'HIGH',
|
|
216
|
+
passed: false,
|
|
217
|
+
detail: `Service "${serviceName}" exposes port ${portStr} to all interfaces in ${relPath}`,
|
|
218
|
+
file: relPath,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
// YAML parse error — skip this file
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return findings;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Scan source files for audit logging patterns
|
|
233
|
+
*/
|
|
234
|
+
async function scanForAuditLogs(targetDir) {
|
|
235
|
+
const findings = [];
|
|
236
|
+
|
|
237
|
+
// Look for common logging config files/patterns
|
|
238
|
+
const sourceFiles = await findFiles(targetDir, [
|
|
239
|
+
'**/*.js',
|
|
240
|
+
'**/*.ts',
|
|
241
|
+
'**/*.py',
|
|
242
|
+
'**/logging.conf',
|
|
243
|
+
'**/logger.*',
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
// Only run the audit trail checks if the project actually contains source code or manifest files
|
|
247
|
+
const hasCodeFiles = sourceFiles.length > 0 ||
|
|
248
|
+
fileExists(path.join(targetDir, 'package.json')) ||
|
|
249
|
+
fileExists(path.join(targetDir, 'requirements.txt')) ||
|
|
250
|
+
fileExists(path.join(targetDir, 'pyproject.toml')) ||
|
|
251
|
+
fileExists(path.join(targetDir, 'go.mod')) ||
|
|
252
|
+
fileExists(path.join(targetDir, 'Gemfile')) ||
|
|
253
|
+
fileExists(path.join(targetDir, 'Dockerfile'));
|
|
254
|
+
|
|
255
|
+
if (!hasCodeFiles) {
|
|
256
|
+
return findings;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Known audit/logging library patterns
|
|
260
|
+
const auditPatterns = [
|
|
261
|
+
'winston',
|
|
262
|
+
'morgan',
|
|
263
|
+
'pino',
|
|
264
|
+
'bunyan',
|
|
265
|
+
'log4js',
|
|
266
|
+
'audit',
|
|
267
|
+
'access.log',
|
|
268
|
+
'structlog',
|
|
269
|
+
'loguru',
|
|
270
|
+
'logging.config',
|
|
271
|
+
'AuditLog',
|
|
272
|
+
'auditLog',
|
|
273
|
+
'audit_log',
|
|
274
|
+
];
|
|
275
|
+
|
|
276
|
+
let foundAuditLog = false;
|
|
277
|
+
|
|
278
|
+
for (const file of sourceFiles) {
|
|
279
|
+
const content = readFileSafe(file);
|
|
280
|
+
if (!content) continue;
|
|
281
|
+
|
|
282
|
+
for (const pattern of auditPatterns) {
|
|
283
|
+
if (content.includes(pattern)) {
|
|
284
|
+
foundAuditLog = true;
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (foundAuditLog) break;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Also check package.json for logging dependencies
|
|
292
|
+
if (!foundAuditLog) {
|
|
293
|
+
const pkgJson = readFileSafe(path.join(targetDir, 'package.json'));
|
|
294
|
+
if (pkgJson) {
|
|
295
|
+
try {
|
|
296
|
+
const pkg = JSON.parse(pkgJson);
|
|
297
|
+
const allDeps = {
|
|
298
|
+
...pkg.dependencies,
|
|
299
|
+
...pkg.devDependencies,
|
|
300
|
+
};
|
|
301
|
+
for (const pattern of auditPatterns) {
|
|
302
|
+
if (allDeps[pattern]) {
|
|
303
|
+
foundAuditLog = true;
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
} catch {
|
|
308
|
+
// JSON parse error
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Check requirements.txt for Python projects
|
|
314
|
+
if (!foundAuditLog) {
|
|
315
|
+
const reqTxt = readFileSafe(path.join(targetDir, 'requirements.txt'));
|
|
316
|
+
if (reqTxt) {
|
|
317
|
+
for (const pattern of auditPatterns) {
|
|
318
|
+
if (reqTxt.toLowerCase().includes(pattern)) {
|
|
319
|
+
foundAuditLog = true;
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (!foundAuditLog) {
|
|
327
|
+
findings.push({
|
|
328
|
+
rule: 'LOG-001',
|
|
329
|
+
severity: 'HIGH',
|
|
330
|
+
passed: false,
|
|
331
|
+
detail: 'No structured audit trail or logging library detected in project',
|
|
332
|
+
file: null,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return findings;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Scan for missing lockfiles (Supply Chain Security)
|
|
341
|
+
*/
|
|
342
|
+
async function scanLockfiles(targetDir) {
|
|
343
|
+
const findings = [];
|
|
344
|
+
const hasPkgJson = fileExists(path.join(targetDir, 'package.json'));
|
|
345
|
+
|
|
346
|
+
if (hasPkgJson) {
|
|
347
|
+
const hasNpmLock = fileExists(path.join(targetDir, 'package-lock.json'));
|
|
348
|
+
const hasYarnLock = fileExists(path.join(targetDir, 'yarn.lock'));
|
|
349
|
+
const hasPnpmLock = fileExists(path.join(targetDir, 'pnpm-lock.yaml'));
|
|
350
|
+
|
|
351
|
+
if (!hasNpmLock && !hasYarnLock && !hasPnpmLock) {
|
|
352
|
+
findings.push({
|
|
353
|
+
rule: 'INFRA-003',
|
|
354
|
+
severity: 'HIGH',
|
|
355
|
+
passed: false,
|
|
356
|
+
detail: 'package.json exists but no lockfile found — builds are non-deterministic',
|
|
357
|
+
file: 'package.json',
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return findings;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Scan CI/CD pipelines for dangerous triggers
|
|
366
|
+
*/
|
|
367
|
+
async function scanCIPipelines(targetDir) {
|
|
368
|
+
const findings = [];
|
|
369
|
+
const workflows = await findFiles(targetDir, ['.github/workflows/*.yml', '.github/workflows/*.yaml']);
|
|
370
|
+
|
|
371
|
+
for (const workflow of workflows) {
|
|
372
|
+
const content = readFileSafe(workflow);
|
|
373
|
+
if (!content) continue;
|
|
374
|
+
|
|
375
|
+
const relPath = path.relative(targetDir, workflow);
|
|
376
|
+
if (content.includes('pull_request_target:')) {
|
|
377
|
+
findings.push({
|
|
378
|
+
rule: 'INFRA-004',
|
|
379
|
+
severity: 'CRITICAL',
|
|
380
|
+
passed: false,
|
|
381
|
+
detail: `Dangerous "pull_request_target" trigger used in ${relPath}`,
|
|
382
|
+
file: relPath,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return findings;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Check if a host in a connection URL is private (localhost, 127.0.0.1, etc.)
|
|
391
|
+
*/
|
|
392
|
+
function isPrivateHost(url) {
|
|
393
|
+
const privatePatterns = [
|
|
394
|
+
'127.0.0.1',
|
|
395
|
+
'localhost',
|
|
396
|
+
'0.0.0.0',
|
|
397
|
+
'10.',
|
|
398
|
+
'172.16.',
|
|
399
|
+
'172.17.',
|
|
400
|
+
'172.18.',
|
|
401
|
+
'172.19.',
|
|
402
|
+
'172.20.',
|
|
403
|
+
'172.21.',
|
|
404
|
+
'172.22.',
|
|
405
|
+
'172.23.',
|
|
406
|
+
'172.24.',
|
|
407
|
+
'172.25.',
|
|
408
|
+
'172.26.',
|
|
409
|
+
'172.27.',
|
|
410
|
+
'172.28.',
|
|
411
|
+
'172.29.',
|
|
412
|
+
'172.30.',
|
|
413
|
+
'172.31.',
|
|
414
|
+
'192.168.',
|
|
415
|
+
'.internal',
|
|
416
|
+
'.local',
|
|
417
|
+
'rds.amazonaws.com', // RDS endpoints are private within VPC
|
|
418
|
+
];
|
|
419
|
+
|
|
420
|
+
const urlLower = url.toLowerCase();
|
|
421
|
+
return privatePatterns.some(p => urlLower.includes(p));
|
|
422
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git Scanner — Checks .gitignore config and git history for secret leaks
|
|
3
|
+
* All operations are local-only, read-only
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { execSync } from 'child_process';
|
|
8
|
+
import { readFileSafe, fileExists } from '../utils/fileUtils.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Run git-related security scans
|
|
12
|
+
* @param {string} targetDir - The project root directory
|
|
13
|
+
* @returns {Promise<Array<{ rule: string, severity: string, passed: boolean, detail: string }>>}
|
|
14
|
+
*/
|
|
15
|
+
export async function runGitScanner(targetDir) {
|
|
16
|
+
const findings = [];
|
|
17
|
+
|
|
18
|
+
// Check if this is a git repo
|
|
19
|
+
const isGitRepo = fileExists(path.join(targetDir, '.git'));
|
|
20
|
+
if (!isGitRepo) {
|
|
21
|
+
return findings; // Not a git repo, skip git checks
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// GIT-001: Check .gitignore for .env exclusion
|
|
25
|
+
const gitignoreFinding = checkGitignore(targetDir);
|
|
26
|
+
if (gitignoreFinding) findings.push(gitignoreFinding);
|
|
27
|
+
|
|
28
|
+
// GIT-001 extension: Check if .env was ever committed
|
|
29
|
+
const historyFinding = checkGitHistory(targetDir);
|
|
30
|
+
if (historyFinding) findings.push(historyFinding);
|
|
31
|
+
|
|
32
|
+
return findings;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check if .gitignore properly excludes sensitive files
|
|
37
|
+
*/
|
|
38
|
+
function checkGitignore(targetDir) {
|
|
39
|
+
const gitignorePath = path.join(targetDir, '.gitignore');
|
|
40
|
+
const content = readFileSafe(gitignorePath);
|
|
41
|
+
|
|
42
|
+
if (!content) {
|
|
43
|
+
// No .gitignore at all
|
|
44
|
+
return {
|
|
45
|
+
rule: 'GIT-001',
|
|
46
|
+
severity: 'MEDIUM',
|
|
47
|
+
passed: false,
|
|
48
|
+
detail: 'No .gitignore file found — .env and secrets may be tracked by git',
|
|
49
|
+
file: null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const lines = content.split('\n').map(l => l.trim());
|
|
54
|
+
const sensitivePatterns = ['.env', '.env.*', '*.pem', '*.key'];
|
|
55
|
+
const envExcluded = lines.some(line =>
|
|
56
|
+
line === '.env' || line === '.env*' || line === '.env.*' || line === '*.env'
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (!envExcluded) {
|
|
60
|
+
return {
|
|
61
|
+
rule: 'GIT-001',
|
|
62
|
+
severity: 'MEDIUM',
|
|
63
|
+
passed: false,
|
|
64
|
+
detail: '.env is not excluded in .gitignore — secrets may be in your repository',
|
|
65
|
+
file: '.gitignore',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check git history for leaked secrets (local git log only)
|
|
74
|
+
*/
|
|
75
|
+
function checkGitHistory(targetDir) {
|
|
76
|
+
const secretPatterns = [
|
|
77
|
+
'AWS_SECRET_ACCESS_KEY',
|
|
78
|
+
'AWS_ACCESS_KEY_ID',
|
|
79
|
+
'PRIVATE_KEY',
|
|
80
|
+
'DATABASE_URL',
|
|
81
|
+
'DB_PASSWORD',
|
|
82
|
+
'SECRET_KEY',
|
|
83
|
+
'API_KEY',
|
|
84
|
+
'STRIPE_SECRET',
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
for (const pattern of secretPatterns) {
|
|
89
|
+
const result = execSync(
|
|
90
|
+
`git log --oneline --all --grep="${pattern}" -n 1`,
|
|
91
|
+
{
|
|
92
|
+
cwd: targetDir,
|
|
93
|
+
encoding: 'utf-8',
|
|
94
|
+
timeout: 5000,
|
|
95
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
96
|
+
}
|
|
97
|
+
).trim();
|
|
98
|
+
|
|
99
|
+
if (result.length > 0) {
|
|
100
|
+
return {
|
|
101
|
+
rule: 'GIT-001',
|
|
102
|
+
severity: 'MEDIUM',
|
|
103
|
+
passed: false,
|
|
104
|
+
detail: `Secret pattern "${pattern}" found in git commit history — even deleted files are recoverable`,
|
|
105
|
+
file: null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// Git command failed — might not have git installed or not a repo
|
|
111
|
+
// Silently skip
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { fileExists } from './fileUtils.js';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Auto-detect the project type based on marker files
|
|
6
|
+
* @param {string} targetDir - The project root directory
|
|
7
|
+
* @returns {{ type: string, label: string, markers: string[] }}
|
|
8
|
+
*/
|
|
9
|
+
export function detectProjectType(targetDir) {
|
|
10
|
+
const checks = [
|
|
11
|
+
{
|
|
12
|
+
type: 'fastapi',
|
|
13
|
+
label: 'FastAPI (Python)',
|
|
14
|
+
markers: ['requirements.txt', 'pyproject.toml', 'main.py', 'app/main.py'],
|
|
15
|
+
keywords: ['fastapi', 'uvicorn'],
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
type: 'django',
|
|
19
|
+
label: 'Django (Python)',
|
|
20
|
+
markers: ['manage.py', 'settings.py'],
|
|
21
|
+
keywords: ['django'],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
type: 'express',
|
|
25
|
+
label: 'Express / Node.js',
|
|
26
|
+
markers: ['package.json', 'server.js', 'app.js', 'index.js'],
|
|
27
|
+
keywords: ['express'],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
type: 'nextjs',
|
|
31
|
+
label: 'Next.js',
|
|
32
|
+
markers: ['next.config.js', 'next.config.mjs', 'next.config.ts'],
|
|
33
|
+
keywords: ['next'],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
type: 'golang',
|
|
37
|
+
label: 'Go',
|
|
38
|
+
markers: ['go.mod', 'go.sum'],
|
|
39
|
+
keywords: [],
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
type: 'ruby',
|
|
43
|
+
label: 'Ruby on Rails',
|
|
44
|
+
markers: ['Gemfile', 'config/routes.rb'],
|
|
45
|
+
keywords: ['rails'],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
type: 'dotnet',
|
|
49
|
+
label: '.NET',
|
|
50
|
+
markers: ['*.csproj', '*.sln', 'Program.cs'],
|
|
51
|
+
keywords: [],
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const foundMarkers = [];
|
|
56
|
+
|
|
57
|
+
for (const check of checks) {
|
|
58
|
+
for (const marker of check.markers) {
|
|
59
|
+
const fullPath = path.join(targetDir, marker);
|
|
60
|
+
if (fileExists(fullPath)) {
|
|
61
|
+
foundMarkers.push(marker);
|
|
62
|
+
// Check for keyword matches in package.json or requirements.txt for more accuracy
|
|
63
|
+
return {
|
|
64
|
+
type: check.type,
|
|
65
|
+
label: check.label,
|
|
66
|
+
markers: [marker],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check for Docker/infrastructure
|
|
73
|
+
const hasDocker = fileExists(path.join(targetDir, 'Dockerfile')) ||
|
|
74
|
+
fileExists(path.join(targetDir, 'docker-compose.yml')) ||
|
|
75
|
+
fileExists(path.join(targetDir, 'docker-compose.yaml'));
|
|
76
|
+
|
|
77
|
+
const hasPostgres = fileExists(path.join(targetDir, '.env'));
|
|
78
|
+
|
|
79
|
+
let label = 'Unknown Project';
|
|
80
|
+
const detectedParts = [];
|
|
81
|
+
if (hasDocker) detectedParts.push('Docker');
|
|
82
|
+
if (hasPostgres) detectedParts.push('PostgreSQL (likely)');
|
|
83
|
+
if (detectedParts.length > 0) label = detectedParts.join(' + ');
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
type: 'unknown',
|
|
87
|
+
label,
|
|
88
|
+
markers: foundMarkers,
|
|
89
|
+
};
|
|
90
|
+
}
|