ship-safe 3.1.0 → 4.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.
Files changed (38) hide show
  1. package/README.md +200 -307
  2. package/cli/agents/api-fuzzer.js +224 -0
  3. package/cli/agents/auth-bypass-agent.js +326 -0
  4. package/cli/agents/base-agent.js +240 -0
  5. package/cli/agents/cicd-scanner.js +200 -0
  6. package/cli/agents/config-auditor.js +413 -0
  7. package/cli/agents/git-history-scanner.js +167 -0
  8. package/cli/agents/html-reporter.js +363 -0
  9. package/cli/agents/index.js +56 -0
  10. package/cli/agents/injection-tester.js +401 -0
  11. package/cli/agents/llm-redteam.js +251 -0
  12. package/cli/agents/mobile-scanner.js +225 -0
  13. package/cli/agents/orchestrator.js +152 -0
  14. package/cli/agents/policy-engine.js +149 -0
  15. package/cli/agents/recon-agent.js +196 -0
  16. package/cli/agents/sbom-generator.js +176 -0
  17. package/cli/agents/scoring-engine.js +207 -0
  18. package/cli/agents/ssrf-prober.js +130 -0
  19. package/cli/agents/supply-chain-agent.js +274 -0
  20. package/cli/bin/ship-safe.js +119 -2
  21. package/cli/commands/agent.js +606 -0
  22. package/cli/commands/audit.js +565 -0
  23. package/cli/commands/deps.js +447 -0
  24. package/cli/commands/fix.js +3 -3
  25. package/cli/commands/init.js +86 -3
  26. package/cli/commands/mcp.js +2 -2
  27. package/cli/commands/red-team.js +315 -0
  28. package/cli/commands/remediate.js +4 -4
  29. package/cli/commands/rotate.js +6 -6
  30. package/cli/commands/scan.js +64 -23
  31. package/cli/commands/score.js +446 -0
  32. package/cli/commands/watch.js +160 -0
  33. package/cli/index.js +40 -2
  34. package/cli/providers/llm-provider.js +288 -0
  35. package/cli/utils/entropy.js +6 -0
  36. package/cli/utils/output.js +42 -2
  37. package/cli/utils/patterns.js +393 -1
  38. package/package.json +19 -15
@@ -0,0 +1,413 @@
1
+ /**
2
+ * ConfigAuditor Agent
3
+ * ====================
4
+ *
5
+ * Detects security misconfigurations in:
6
+ * Dockerfile, docker-compose, vercel.json, netlify.toml,
7
+ * next.config.js, Terraform, Kubernetes, nginx, firebase,
8
+ * and security headers.
9
+ */
10
+
11
+ import path from 'path';
12
+ import { BaseAgent, createFinding } from './base-agent.js';
13
+
14
+ // =============================================================================
15
+ // DOCKERFILE PATTERNS
16
+ // =============================================================================
17
+
18
+ const DOCKERFILE_PATTERNS = [
19
+ {
20
+ rule: 'DOCKER_RUN_AS_ROOT',
21
+ title: 'Docker: Running as Root',
22
+ regex: /^(?!.*USER\s+\w).*CMD|ENTRYPOINT/gm,
23
+ severity: 'high',
24
+ cwe: 'CWE-250',
25
+ owasp: 'A05:2021',
26
+ confidence: 'medium',
27
+ description: 'No USER instruction found. Container runs as root by default.',
28
+ fix: 'Add USER nonroot before CMD/ENTRYPOINT',
29
+ },
30
+ {
31
+ rule: 'DOCKER_LATEST_TAG',
32
+ title: 'Docker: Using :latest Tag',
33
+ regex: /FROM\s+\S+:latest/gi,
34
+ severity: 'medium',
35
+ cwe: 'CWE-1104',
36
+ description: ':latest tag is mutable and can change unexpectedly. Pin to a specific version.',
37
+ fix: 'Pin to specific version: FROM node:20-alpine instead of FROM node:latest',
38
+ },
39
+ {
40
+ rule: 'DOCKER_ADD_REMOTE',
41
+ title: 'Docker: ADD with Remote URL',
42
+ regex: /ADD\s+https?:\/\//gi,
43
+ severity: 'high',
44
+ cwe: 'CWE-829',
45
+ description: 'ADD with URL downloads without checksum verification. Use COPY + curl with checksum.',
46
+ fix: 'Replace ADD URL with: RUN curl -fsSL url -o file && sha256sum -c <<< "hash file"',
47
+ },
48
+ {
49
+ rule: 'DOCKER_SECRET_ENV',
50
+ title: 'Docker: Secret in ENV/ARG',
51
+ regex: /(?:ENV|ARG)\s+(?:.*(?:PASSWORD|SECRET|KEY|TOKEN|CREDENTIAL|API_KEY))\s*=/gi,
52
+ severity: 'critical',
53
+ cwe: 'CWE-798',
54
+ description: 'Secrets in ENV/ARG are baked into image layers. Use Docker secrets or runtime env.',
55
+ fix: 'Use --secret flag in docker build, or pass secrets at runtime via -e',
56
+ },
57
+ {
58
+ rule: 'DOCKER_EXPOSE_ALL',
59
+ title: 'Docker: Exposing Privileged Port',
60
+ regex: /EXPOSE\s+(?:22|23|3389|5432|3306|27017|6379|11211)\b/g,
61
+ severity: 'medium',
62
+ cwe: 'CWE-200',
63
+ description: 'Exposing database/admin ports in container. Only expose application ports.',
64
+ fix: 'Remove EXPOSE for database ports. Use Docker networking for internal communication.',
65
+ },
66
+ {
67
+ rule: 'DOCKER_PRIVILEGED',
68
+ title: 'Docker: Privileged Mode',
69
+ regex: /privileged\s*:\s*true/g,
70
+ severity: 'critical',
71
+ cwe: 'CWE-250',
72
+ description: 'Privileged containers have full host access. This enables container escape.',
73
+ fix: 'Remove privileged: true. Use specific capabilities if needed (cap_add).',
74
+ },
75
+ ];
76
+
77
+ // =============================================================================
78
+ // CONFIG FILE PATTERNS
79
+ // =============================================================================
80
+
81
+ const CONFIG_PATTERNS = [
82
+ // ── Security Headers ───────────────────────────────────────────────────────
83
+ {
84
+ rule: 'MISSING_CSP',
85
+ title: 'Missing Content-Security-Policy',
86
+ regex: /headers\s*(?::|=)\s*\[/g,
87
+ severity: 'medium',
88
+ cwe: 'CWE-693',
89
+ owasp: 'A05:2021',
90
+ confidence: 'low',
91
+ description: 'No Content-Security-Policy header detected. CSP prevents XSS and data injection.',
92
+ fix: "Add Content-Security-Policy header: \"default-src 'self'; script-src 'self'\"",
93
+ },
94
+ {
95
+ rule: 'CORS_WILDCARD',
96
+ title: 'CORS Wildcard Origin',
97
+ regex: /(?:Access-Control-Allow-Origin|origin)\s*[:=]\s*['"]?\*['"]?/g,
98
+ severity: 'high',
99
+ cwe: 'CWE-942',
100
+ owasp: 'A05:2021',
101
+ description: 'CORS wildcard (*) allows any origin. Use specific trusted origins.',
102
+ fix: 'Replace * with specific origins: ["https://yourdomain.com"]',
103
+ },
104
+ {
105
+ rule: 'CORS_CREDENTIALS_WILDCARD',
106
+ title: 'CORS Credentials with Wildcard',
107
+ regex: /credentials\s*:\s*true.*origin\s*:\s*true|origin\s*:\s*true.*credentials\s*:\s*true/g,
108
+ severity: 'critical',
109
+ cwe: 'CWE-942',
110
+ owasp: 'A05:2021',
111
+ description: 'CORS with credentials: true and origin: true reflects any origin, enabling credential theft.',
112
+ fix: 'Use a specific origin allowlist when credentials: true',
113
+ },
114
+
115
+ // ── Next.js Config ─────────────────────────────────────────────────────────
116
+ {
117
+ rule: 'NEXTJS_POWERED_BY',
118
+ title: 'Next.js: X-Powered-By Header Enabled',
119
+ regex: /poweredByHeader\s*:\s*true/g,
120
+ severity: 'low',
121
+ cwe: 'CWE-200',
122
+ description: 'X-Powered-By header reveals technology stack. Disable for security through obscurity.',
123
+ fix: 'Set poweredByHeader: false in next.config.js',
124
+ },
125
+ {
126
+ rule: 'NEXTJS_WILDCARD_IMAGES',
127
+ title: 'Next.js: Wildcard Image Domain',
128
+ regex: /images\s*:\s*\{[^}]*(?:domains|remotePatterns)[^}]*\*\*/g,
129
+ severity: 'medium',
130
+ cwe: 'CWE-918',
131
+ description: 'Wildcard image domains can be abused for SSRF via Next.js image optimization.',
132
+ fix: 'Specify exact domains in images.remotePatterns',
133
+ },
134
+
135
+ // ── Firebase ───────────────────────────────────────────────────────────────
136
+ {
137
+ rule: 'FIREBASE_OPEN_RULES',
138
+ title: 'Firebase: Open Security Rules',
139
+ regex: /allow\s+read\s*,\s*write\s*:\s*if\s+true/g,
140
+ severity: 'critical',
141
+ cwe: 'CWE-284',
142
+ description: 'Firebase rules allow unauthenticated read/write. Any user can access all data.',
143
+ fix: 'Change to: allow read, write: if request.auth != null;',
144
+ },
145
+ {
146
+ rule: 'FIREBASE_OPEN_STORAGE',
147
+ title: 'Firebase: Open Storage Rules',
148
+ regex: /allow\s+read\s*,\s*write\s*;/g,
149
+ severity: 'critical',
150
+ cwe: 'CWE-284',
151
+ description: 'Firebase Storage rules allow unrestricted access. Add authentication checks.',
152
+ fix: 'Add auth check: allow read, write: if request.auth != null;',
153
+ },
154
+
155
+ // ── Terraform ──────────────────────────────────────────────────────────────
156
+ {
157
+ rule: 'TERRAFORM_PUBLIC_S3',
158
+ title: 'Terraform: Public S3 Bucket',
159
+ regex: /acl\s*=\s*"public-read(?:-write)?"/g,
160
+ severity: 'critical',
161
+ cwe: 'CWE-284',
162
+ description: 'S3 bucket with public ACL. Data is accessible to the internet.',
163
+ fix: 'Use acl = "private" and configure bucket policy for specific access',
164
+ },
165
+ {
166
+ rule: 'TERRAFORM_OPEN_SG',
167
+ title: 'Terraform: Open Security Group (0.0.0.0/0)',
168
+ regex: /cidr_blocks\s*=\s*\[\s*"0\.0\.0\.0\/0"\s*\]/g,
169
+ severity: 'high',
170
+ cwe: 'CWE-284',
171
+ description: 'Security group open to all IPs. Restrict to specific CIDR blocks.',
172
+ fix: 'Replace 0.0.0.0/0 with specific IP ranges for the service',
173
+ },
174
+ {
175
+ rule: 'TERRAFORM_WILDCARD_IAM',
176
+ title: 'Terraform: Wildcard IAM Action',
177
+ regex: /actions?\s*=\s*\[\s*"\*"\s*\]/g,
178
+ severity: 'critical',
179
+ cwe: 'CWE-250',
180
+ description: 'IAM policy with Action: "*" grants unrestricted access. Apply least privilege.',
181
+ fix: 'Replace with specific actions: ["s3:GetObject", "s3:PutObject"]',
182
+ },
183
+ {
184
+ rule: 'TERRAFORM_NO_ENCRYPTION',
185
+ title: 'Terraform: Unencrypted Storage',
186
+ regex: /encrypted\s*=\s*false/g,
187
+ severity: 'high',
188
+ cwe: 'CWE-311',
189
+ description: 'Storage is not encrypted. Enable encryption at rest.',
190
+ fix: 'Set encrypted = true and configure KMS key',
191
+ },
192
+ {
193
+ rule: 'TERRAFORM_NO_LOGGING',
194
+ title: 'Terraform: Missing Access Logging',
195
+ regex: /logging\s*\{[^}]*enabled\s*=\s*false/g,
196
+ severity: 'medium',
197
+ cwe: 'CWE-778',
198
+ description: 'Access logging is disabled. Enable for audit trail and incident response.',
199
+ fix: 'Set enabled = true and configure log destination',
200
+ },
201
+
202
+ // ── Kubernetes ─────────────────────────────────────────────────────────────
203
+ {
204
+ rule: 'K8S_PRIVILEGED_CONTAINER',
205
+ title: 'Kubernetes: Privileged Container',
206
+ regex: /privileged\s*:\s*true/g,
207
+ severity: 'critical',
208
+ cwe: 'CWE-250',
209
+ description: 'Privileged Kubernetes pod can escape to the host. Remove privileged flag.',
210
+ fix: 'Set privileged: false. Use specific capabilities if needed.',
211
+ },
212
+ {
213
+ rule: 'K8S_HOST_NETWORK',
214
+ title: 'Kubernetes: Host Network Mode',
215
+ regex: /hostNetwork\s*:\s*true/g,
216
+ severity: 'high',
217
+ cwe: 'CWE-284',
218
+ description: 'Pod uses host network, bypassing network policies and isolation.',
219
+ fix: 'Remove hostNetwork: true. Use Kubernetes Services for networking.',
220
+ },
221
+ {
222
+ rule: 'K8S_NO_RESOURCE_LIMITS',
223
+ title: 'Kubernetes: Missing Resource Limits',
224
+ regex: /containers\s*:/g,
225
+ severity: 'medium',
226
+ cwe: 'CWE-770',
227
+ confidence: 'low',
228
+ description: 'Container without resource limits can consume unbounded CPU/memory (DoS).',
229
+ fix: 'Add resources.limits.cpu and resources.limits.memory to container spec',
230
+ },
231
+ {
232
+ rule: 'K8S_RUN_AS_ROOT',
233
+ title: 'Kubernetes: Running as Root',
234
+ regex: /runAsUser\s*:\s*0\b/g,
235
+ severity: 'high',
236
+ cwe: 'CWE-250',
237
+ description: 'Pod running as root (UID 0). Use a non-root user.',
238
+ fix: 'Set runAsUser: 1000 and runAsNonRoot: true in securityContext',
239
+ },
240
+ {
241
+ rule: 'K8S_DEFAULT_SA',
242
+ title: 'Kubernetes: Default Service Account',
243
+ regex: /serviceAccountName\s*:\s*["']?default["']?/g,
244
+ severity: 'medium',
245
+ cwe: 'CWE-284',
246
+ description: 'Using default service account. Create a dedicated SA with minimal permissions.',
247
+ fix: 'Create a dedicated ServiceAccount with only needed RBAC bindings',
248
+ },
249
+
250
+ // ── Docker Compose ─────────────────────────────────────────────────────────
251
+ {
252
+ rule: 'COMPOSE_HOST_MOUNT',
253
+ title: 'Docker Compose: Sensitive Host Mount',
254
+ regex: /volumes\s*:\s*\n\s*-\s*(?:\/etc|\/var\/run\/docker\.sock|\/root|\/proc|\/sys)/gm,
255
+ severity: 'critical',
256
+ cwe: 'CWE-284',
257
+ description: 'Mounting sensitive host paths into container enables escape and privilege escalation.',
258
+ fix: 'Remove sensitive host mounts. Use named volumes for data persistence.',
259
+ },
260
+
261
+ // ── Nginx ──────────────────────────────────────────────────────────────────
262
+ {
263
+ rule: 'NGINX_AUTOINDEX',
264
+ title: 'Nginx: Directory Listing Enabled',
265
+ regex: /autoindex\s+on/g,
266
+ severity: 'medium',
267
+ cwe: 'CWE-548',
268
+ description: 'Directory listing exposes file structure. Disable autoindex.',
269
+ fix: 'Set autoindex off; in nginx configuration',
270
+ },
271
+ {
272
+ rule: 'NGINX_SERVER_TOKENS',
273
+ title: 'Nginx: Server Version Exposed',
274
+ regex: /server_tokens\s+on/g,
275
+ severity: 'low',
276
+ cwe: 'CWE-200',
277
+ description: 'Server tokens reveal nginx version. Disable for security.',
278
+ fix: 'Set server_tokens off; in nginx.conf',
279
+ },
280
+
281
+ // ── General Config ─────────────────────────────────────────────────────────
282
+ {
283
+ rule: 'DEBUG_MODE_PRODUCTION',
284
+ title: 'Debug Mode in Production Config',
285
+ regex: /(?:DEBUG|debug)\s*[:=]\s*(?:true|True|1|['"]true['"])/g,
286
+ severity: 'high',
287
+ cwe: 'CWE-215',
288
+ owasp: 'A05:2021',
289
+ confidence: 'medium',
290
+ description: 'Debug mode exposes stack traces, internal state, and sensitive information.',
291
+ fix: 'Set DEBUG=false in production. Use environment-specific config.',
292
+ },
293
+ {
294
+ rule: 'VERBOSE_ERROR_MESSAGES',
295
+ title: 'Verbose Error Messages',
296
+ regex: /(?:stack|stackTrace|err\.message|error\.message|traceback)\s*(?:\)|,)/g,
297
+ severity: 'medium',
298
+ cwe: 'CWE-209',
299
+ owasp: 'A05:2021',
300
+ confidence: 'low',
301
+ description: 'Exposing stack traces or detailed errors in responses leaks internal information.',
302
+ fix: 'Log errors server-side. Return generic error messages to clients.',
303
+ },
304
+
305
+ // ── Deprecated Node.js ─────────────────────────────────────────────────────
306
+ {
307
+ rule: 'DEPRECATED_BUFFER',
308
+ title: 'Deprecated: new Buffer()',
309
+ regex: /\bnew\s+Buffer\s*\(/g,
310
+ severity: 'medium',
311
+ cwe: 'CWE-676',
312
+ description: 'new Buffer() is deprecated and has security implications. Use Buffer.from().',
313
+ fix: 'Use Buffer.from(), Buffer.alloc(), or Buffer.allocUnsafe()',
314
+ },
315
+ ];
316
+
317
+ export class ConfigAuditor extends BaseAgent {
318
+ constructor() {
319
+ super('ConfigAuditor', 'Detect security misconfigurations in infrastructure and app config', 'config');
320
+ }
321
+
322
+ async analyze(context) {
323
+ const { rootPath, files, recon } = context;
324
+ let findings = [];
325
+
326
+ // ── Scan Dockerfiles ──────────────────────────────────────────────────────
327
+ const dockerfiles = files.filter(f => {
328
+ const basename = path.basename(f);
329
+ return basename === 'Dockerfile' || basename.startsWith('Dockerfile.');
330
+ });
331
+ for (const file of dockerfiles) {
332
+ findings = findings.concat(this.scanFileWithPatterns(file, DOCKERFILE_PATTERNS));
333
+ findings = findings.concat(this.checkDockerfileUser(file));
334
+ }
335
+
336
+ // ── Scan docker-compose ───────────────────────────────────────────────────
337
+ const composeFiles = files.filter(f => /docker-compose\.ya?ml$/i.test(path.basename(f)));
338
+ for (const file of composeFiles) {
339
+ findings = findings.concat(this.scanFileWithPatterns(file, CONFIG_PATTERNS));
340
+ }
341
+
342
+ // ── Scan Terraform ────────────────────────────────────────────────────────
343
+ const tfFiles = files.filter(f => path.extname(f) === '.tf');
344
+ for (const file of tfFiles) {
345
+ findings = findings.concat(this.scanFileWithPatterns(file, CONFIG_PATTERNS));
346
+ }
347
+
348
+ // ── Scan Kubernetes manifests ─────────────────────────────────────────────
349
+ const k8sFiles = files.filter(f => {
350
+ const relPath = path.relative(rootPath, f).replace(/\\/g, '/');
351
+ return /\.ya?ml$/i.test(f) && /(?:k8s|kubernetes|deploy|helm|manifests)/i.test(relPath);
352
+ });
353
+ for (const file of k8sFiles) {
354
+ findings = findings.concat(this.scanFileWithPatterns(file, CONFIG_PATTERNS));
355
+ }
356
+
357
+ // ── Scan config files ─────────────────────────────────────────────────────
358
+ const configFiles = files.filter(f => {
359
+ const basename = path.basename(f);
360
+ return [
361
+ 'vercel.json', 'netlify.toml', 'next.config.js', 'next.config.mjs', 'next.config.ts',
362
+ 'nginx.conf', 'Caddyfile', 'firebase.json', 'firestore.rules', 'storage.rules',
363
+ '.env.example', '.env.sample', '.env.local',
364
+ ].includes(basename);
365
+ });
366
+ for (const file of configFiles) {
367
+ findings = findings.concat(this.scanFileWithPatterns(file, CONFIG_PATTERNS));
368
+ }
369
+
370
+ // ── Scan all code files for general config issues ─────────────────────────
371
+ const codeFiles = files.filter(f => {
372
+ const ext = path.extname(f).toLowerCase();
373
+ return ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.py', '.rb', '.go', '.php'].includes(ext);
374
+ });
375
+ const generalPatterns = CONFIG_PATTERNS.filter(p =>
376
+ ['CORS_WILDCARD', 'CORS_CREDENTIALS_WILDCARD', 'DEBUG_MODE_PRODUCTION',
377
+ 'DEPRECATED_BUFFER'].includes(p.rule)
378
+ );
379
+ for (const file of codeFiles) {
380
+ findings = findings.concat(this.scanFileWithPatterns(file, generalPatterns));
381
+ }
382
+
383
+ return findings;
384
+ }
385
+
386
+ /**
387
+ * Check if a Dockerfile has a USER instruction before CMD/ENTRYPOINT.
388
+ */
389
+ checkDockerfileUser(filePath) {
390
+ const content = this.readFile(filePath);
391
+ if (!content) return [];
392
+
393
+ const hasUser = /^USER\s+(?!root)\S+/m.test(content);
394
+ const hasCmd = /^(?:CMD|ENTRYPOINT)\s+/m.test(content);
395
+
396
+ if (hasCmd && !hasUser) {
397
+ return [createFinding({
398
+ file: filePath,
399
+ line: 1,
400
+ severity: 'high',
401
+ category: 'config',
402
+ rule: 'DOCKER_NO_USER',
403
+ title: 'Dockerfile: No Non-Root USER',
404
+ description: 'No USER instruction found. Container runs as root, enabling escape attacks.',
405
+ matched: 'Missing USER instruction',
406
+ fix: 'Add before CMD: RUN addgroup -S app && adduser -S app -G app\nUSER app',
407
+ })];
408
+ }
409
+ return [];
410
+ }
411
+ }
412
+
413
+ export default ConfigAuditor;
@@ -0,0 +1,167 @@
1
+ /**
2
+ * GitHistoryScanner Agent
3
+ * ========================
4
+ *
5
+ * Scans git commit history for secrets that were committed
6
+ * and later removed but remain in repository history.
7
+ * These are the most dangerous secrets — developers think
8
+ * they're deleted but they're still accessible.
9
+ */
10
+
11
+ import { execSync } from 'child_process';
12
+ import path from 'path';
13
+ import { BaseAgent, createFinding } from './base-agent.js';
14
+ import { SECRET_PATTERNS } from '../utils/patterns.js';
15
+
16
+ // Compile a fast combined regex from all secret patterns
17
+ const FAST_SECRET_PATTERNS = SECRET_PATTERNS.map(p => ({
18
+ name: p.name,
19
+ pattern: p.pattern,
20
+ severity: p.severity,
21
+ }));
22
+
23
+ export class GitHistoryScanner extends BaseAgent {
24
+ constructor() {
25
+ super('GitHistoryScanner', 'Scan git history for leaked secrets', 'history');
26
+ }
27
+
28
+ async analyze(context) {
29
+ const { rootPath, options } = context;
30
+ const findings = [];
31
+
32
+ // Check if this is a git repository
33
+ if (!this.isGitRepo(rootPath)) return [];
34
+
35
+ try {
36
+ // Get recent commits (default: last 50, configurable)
37
+ const maxCommits = options?.maxCommits || 50;
38
+ const since = options?.since || null;
39
+
40
+ let gitLogCmd = `git -C "${rootPath}" log --all --diff-filter=A --diff-filter=M -p --no-color --max-count=${maxCommits}`;
41
+ if (since) {
42
+ gitLogCmd += ` --since="${since}"`;
43
+ }
44
+
45
+ let diffOutput;
46
+ try {
47
+ diffOutput = execSync(gitLogCmd, {
48
+ cwd: rootPath,
49
+ encoding: 'utf-8',
50
+ maxBuffer: 50 * 1024 * 1024, // 50MB buffer
51
+ timeout: 60000, // 60s timeout
52
+ });
53
+ } catch {
54
+ // git log failed — might be a shallow clone or no history
55
+ return [];
56
+ }
57
+
58
+ if (!diffOutput) return [];
59
+
60
+ // Parse the diff output
61
+ let currentFile = '';
62
+ let currentCommit = '';
63
+ let currentDate = '';
64
+ const lines = diffOutput.split('\n');
65
+
66
+ for (let i = 0; i < lines.length; i++) {
67
+ const line = lines[i];
68
+
69
+ // Track current commit
70
+ if (line.startsWith('commit ')) {
71
+ currentCommit = line.slice(7, 17); // First 10 chars of hash
72
+ }
73
+ if (line.startsWith('Date:')) {
74
+ currentDate = line.slice(5).trim();
75
+ }
76
+
77
+ // Track current file
78
+ if (line.startsWith('diff --git ')) {
79
+ const match = line.match(/diff --git a\/(.+) b\//);
80
+ if (match) currentFile = match[1];
81
+ }
82
+
83
+ // Only check added lines (lines starting with +)
84
+ if (!line.startsWith('+') || line.startsWith('+++')) continue;
85
+
86
+ const addedLine = line.slice(1); // Remove the leading +
87
+
88
+ // Check against all secret patterns
89
+ for (const p of FAST_SECRET_PATTERNS) {
90
+ p.pattern.lastIndex = 0;
91
+ const match = p.pattern.exec(addedLine);
92
+ if (match) {
93
+ // Check if this secret still exists in current working tree
94
+ const stillExists = this.existsInWorkingTree(rootPath, match[0]);
95
+
96
+ findings.push(createFinding({
97
+ file: path.join(rootPath, currentFile),
98
+ line: 0, // Line number not meaningful in history
99
+ severity: stillExists ? p.severity : this.elevateSeverity(p.severity),
100
+ category: 'history',
101
+ rule: 'GIT_HISTORY_SECRET',
102
+ title: `Historical Secret: ${p.name}`,
103
+ description: stillExists
104
+ ? `Secret found in current code AND in git history (commit ${currentCommit}).`
105
+ : `Secret was removed from code but still exists in git history (commit ${currentCommit}, ${currentDate}). Anyone with repo access can retrieve it.`,
106
+ matched: this.maskSecret(match[0]),
107
+ confidence: 'high',
108
+ fix: stillExists
109
+ ? 'Remove from code, rotate the credential, then clean git history with BFG or git filter-repo'
110
+ : 'Rotate this credential immediately, then clean history: npx bfg --replace-text passwords.txt',
111
+ }));
112
+ }
113
+ }
114
+ }
115
+
116
+ // Deduplicate by matched value (same secret in multiple commits)
117
+ const seen = new Set();
118
+ return findings.filter(f => {
119
+ const key = `${f.matched}:${f.title}`;
120
+ if (seen.has(key)) return false;
121
+ seen.add(key);
122
+ return true;
123
+ });
124
+
125
+ } catch (err) {
126
+ // Don't fail the entire scan if git history scan fails
127
+ return [];
128
+ }
129
+ }
130
+
131
+ isGitRepo(dir) {
132
+ try {
133
+ execSync('git rev-parse --is-inside-work-tree', { cwd: dir, stdio: 'pipe' });
134
+ return true;
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
139
+
140
+ existsInWorkingTree(rootPath, secret) {
141
+ try {
142
+ const result = execSync(`git -C "${rootPath}" grep -l "${secret.slice(0, 12)}" -- "*.js" "*.ts" "*.py" "*.env" "*.json" 2>/dev/null`, {
143
+ cwd: rootPath,
144
+ encoding: 'utf-8',
145
+ timeout: 5000,
146
+ stdio: ['pipe', 'pipe', 'pipe'],
147
+ });
148
+ return result.trim().length > 0;
149
+ } catch {
150
+ return false;
151
+ }
152
+ }
153
+
154
+ elevateSeverity(sev) {
155
+ // Secrets in history-only are MORE dangerous (developer thinks they're gone)
156
+ if (sev === 'medium') return 'high';
157
+ if (sev === 'high') return 'critical';
158
+ return sev;
159
+ }
160
+
161
+ maskSecret(secret) {
162
+ if (secret.length <= 10) return secret.slice(0, 4) + '***';
163
+ return secret.slice(0, 8) + '***' + secret.slice(-4);
164
+ }
165
+ }
166
+
167
+ export default GitHistoryScanner;