@havoc-security/scanner 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.
Files changed (140) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-test.log +22 -0
  3. package/dist/analyzers/AuthorizationCoverageAnalyzer.d.ts +7 -0
  4. package/dist/analyzers/AuthorizationCoverageAnalyzer.d.ts.map +1 -0
  5. package/dist/analyzers/AuthorizationCoverageAnalyzer.js +100 -0
  6. package/dist/analyzers/AuthorizationCoverageAnalyzer.js.map +1 -0
  7. package/dist/analyzers/CredentialExposureAnalyzer.d.ts +11 -0
  8. package/dist/analyzers/CredentialExposureAnalyzer.d.ts.map +1 -0
  9. package/dist/analyzers/CredentialExposureAnalyzer.js +262 -0
  10. package/dist/analyzers/CredentialExposureAnalyzer.js.map +1 -0
  11. package/dist/analyzers/DependencyAuditAnalyzer.d.ts +28 -0
  12. package/dist/analyzers/DependencyAuditAnalyzer.d.ts.map +1 -0
  13. package/dist/analyzers/DependencyAuditAnalyzer.js +107 -0
  14. package/dist/analyzers/DependencyAuditAnalyzer.js.map +1 -0
  15. package/dist/analyzers/EncryptionAnalyzer.d.ts +7 -0
  16. package/dist/analyzers/EncryptionAnalyzer.d.ts.map +1 -0
  17. package/dist/analyzers/EncryptionAnalyzer.js +170 -0
  18. package/dist/analyzers/EncryptionAnalyzer.js.map +1 -0
  19. package/dist/analyzers/FileUploadAnalyzer.d.ts +8 -0
  20. package/dist/analyzers/FileUploadAnalyzer.d.ts.map +1 -0
  21. package/dist/analyzers/FileUploadAnalyzer.js +193 -0
  22. package/dist/analyzers/FileUploadAnalyzer.js.map +1 -0
  23. package/dist/analyzers/IdorAnalyzer.d.ts +7 -0
  24. package/dist/analyzers/IdorAnalyzer.d.ts.map +1 -0
  25. package/dist/analyzers/IdorAnalyzer.js +91 -0
  26. package/dist/analyzers/IdorAnalyzer.js.map +1 -0
  27. package/dist/analyzers/MassAssignmentAnalyzer.d.ts +7 -0
  28. package/dist/analyzers/MassAssignmentAnalyzer.d.ts.map +1 -0
  29. package/dist/analyzers/MassAssignmentAnalyzer.js +90 -0
  30. package/dist/analyzers/MassAssignmentAnalyzer.js.map +1 -0
  31. package/dist/analyzers/PrivilegeEscalationAnalyzer.d.ts +7 -0
  32. package/dist/analyzers/PrivilegeEscalationAnalyzer.d.ts.map +1 -0
  33. package/dist/analyzers/PrivilegeEscalationAnalyzer.js +217 -0
  34. package/dist/analyzers/PrivilegeEscalationAnalyzer.js.map +1 -0
  35. package/dist/analyzers/RateLimitAnalyzer.d.ts +7 -0
  36. package/dist/analyzers/RateLimitAnalyzer.d.ts.map +1 -0
  37. package/dist/analyzers/RateLimitAnalyzer.js +151 -0
  38. package/dist/analyzers/RateLimitAnalyzer.js.map +1 -0
  39. package/dist/analyzers/SessionSecurityAnalyzer.d.ts +10 -0
  40. package/dist/analyzers/SessionSecurityAnalyzer.d.ts.map +1 -0
  41. package/dist/analyzers/SessionSecurityAnalyzer.js +295 -0
  42. package/dist/analyzers/SessionSecurityAnalyzer.js.map +1 -0
  43. package/dist/analyzers/SqlInjectionAnalyzer.d.ts +7 -0
  44. package/dist/analyzers/SqlInjectionAnalyzer.d.ts.map +1 -0
  45. package/dist/analyzers/SqlInjectionAnalyzer.js +77 -0
  46. package/dist/analyzers/SqlInjectionAnalyzer.js.map +1 -0
  47. package/dist/analyzers/XssSurfaceAnalyzer.d.ts +7 -0
  48. package/dist/analyzers/XssSurfaceAnalyzer.d.ts.map +1 -0
  49. package/dist/analyzers/XssSurfaceAnalyzer.js +100 -0
  50. package/dist/analyzers/XssSurfaceAnalyzer.js.map +1 -0
  51. package/dist/analyzers/index.d.ts +13 -0
  52. package/dist/analyzers/index.d.ts.map +1 -0
  53. package/dist/analyzers/index.js +13 -0
  54. package/dist/analyzers/index.js.map +1 -0
  55. package/dist/index.d.ts +17 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +139 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/parsers/PhpParser.d.ts +56 -0
  60. package/dist/parsers/PhpParser.d.ts.map +1 -0
  61. package/dist/parsers/PhpParser.js +193 -0
  62. package/dist/parsers/PhpParser.js.map +1 -0
  63. package/dist/parsers/RouteParser.d.ts +87 -0
  64. package/dist/parsers/RouteParser.d.ts.map +1 -0
  65. package/dist/parsers/RouteParser.js +327 -0
  66. package/dist/parsers/RouteParser.js.map +1 -0
  67. package/dist/rules/index.d.ts +14 -0
  68. package/dist/rules/index.d.ts.map +1 -0
  69. package/dist/rules/index.js +9 -0
  70. package/dist/rules/index.js.map +1 -0
  71. package/dist/types/index.d.ts +137 -0
  72. package/dist/types/index.d.ts.map +1 -0
  73. package/dist/types/index.js +13 -0
  74. package/dist/types/index.js.map +1 -0
  75. package/package.json +30 -0
  76. package/package.json.bak +27 -0
  77. package/src/analyzers/AuthorizationCoverageAnalyzer.ts +213 -0
  78. package/src/analyzers/CredentialExposureAnalyzer.ts +312 -0
  79. package/src/analyzers/DependencyAuditAnalyzer.ts +135 -0
  80. package/src/analyzers/EncryptionAnalyzer.ts +195 -0
  81. package/src/analyzers/FileUploadAnalyzer.ts +239 -0
  82. package/src/analyzers/IdorAnalyzer.ts +118 -0
  83. package/src/analyzers/InsecureDeserializationAnalyzer.ts +212 -0
  84. package/src/analyzers/MassAssignmentAnalyzer.ts +105 -0
  85. package/src/analyzers/OpenRedirectAnalyzer.ts +149 -0
  86. package/src/analyzers/PrivilegeEscalationAnalyzer.ts +258 -0
  87. package/src/analyzers/RateLimitAnalyzer.ts +195 -0
  88. package/src/analyzers/SecurityHeaderAnalyzer.ts +263 -0
  89. package/src/analyzers/SessionSecurityAnalyzer.ts +342 -0
  90. package/src/analyzers/SqlInjectionAnalyzer.ts +99 -0
  91. package/src/analyzers/XssSurfaceAnalyzer.ts +112 -0
  92. package/src/analyzers/exclusions.ts +87 -0
  93. package/src/analyzers/index.ts +15 -0
  94. package/src/index.ts +226 -0
  95. package/src/parsers/PhpParser.ts +259 -0
  96. package/src/parsers/RouteParser.ts +384 -0
  97. package/src/rules/index.ts +16 -0
  98. package/src/types/index.ts +164 -0
  99. package/tests/EncryptionAnalyzer.test.ts +137 -0
  100. package/tests/PrivilegeEscalationAnalyzer.test.ts +141 -0
  101. package/tests/RateLimitAnalyzer.test.ts +112 -0
  102. package/tests/analyzers.test.ts +678 -0
  103. package/tests/auth-coverage-route-aware.test.ts +294 -0
  104. package/tests/credential-exposure.test.ts +142 -0
  105. package/tests/file-upload.test.ts +141 -0
  106. package/tests/fixtures/app/Http/Controllers/AdminController.php +19 -0
  107. package/tests/fixtures/app/Http/Controllers/PostController.php +49 -0
  108. package/tests/fixtures/app/Http/Controllers/PublicController.php +17 -0
  109. package/tests/fixtures/app/Models/Comment.php +11 -0
  110. package/tests/fixtures/app/Models/OpenModel.php +11 -0
  111. package/tests/fixtures/app/Models/Post.php +14 -0
  112. package/tests/fixtures/app/Models/SafeModel.php +10 -0
  113. package/tests/fixtures/app/Models/User.php +15 -0
  114. package/tests/fixtures/blade/mail.blade.php +8 -0
  115. package/tests/fixtures/blade/safe.blade.php +12 -0
  116. package/tests/fixtures/blade/vulnerable.blade.php +12 -0
  117. package/tests/fixtures/controllers/AdminController.php +19 -0
  118. package/tests/fixtures/controllers/PostController.php +49 -0
  119. package/tests/fixtures/controllers/PublicController.php +17 -0
  120. package/tests/fixtures/deserialization/safe.php +32 -0
  121. package/tests/fixtures/deserialization/unsafe.php +60 -0
  122. package/tests/fixtures/models/Comment.php +11 -0
  123. package/tests/fixtures/models/OpenModel.php +11 -0
  124. package/tests/fixtures/models/Post.php +14 -0
  125. package/tests/fixtures/models/SafeModel.php +10 -0
  126. package/tests/fixtures/models/User.php +15 -0
  127. package/tests/fixtures/redirect/safe.php +38 -0
  128. package/tests/fixtures/redirect/unsafe.php +39 -0
  129. package/tests/fixtures/routes/api.php +9 -0
  130. package/tests/fixtures/routes/web.php +18 -0
  131. package/tests/fixtures/security-headers/app/Http/Middleware/SecurityHeaders.php +24 -0
  132. package/tests/fixtures/security-headers/app/Providers/AppServiceProvider.php +16 -0
  133. package/tests/fixtures/sql/safe_queries.php +7 -0
  134. package/tests/fixtures/sql/vulnerable_queries.php +7 -0
  135. package/tests/new-analyzers.test.ts +373 -0
  136. package/tests/route-parser.test.ts +257 -0
  137. package/tests/scanner.test.ts +82 -0
  138. package/tests/session-security.test.ts +161 -0
  139. package/tests/types.test.ts +29 -0
  140. package/tsconfig.json +9 -0
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Route-aware AuthorizationCoverageAnalyzer tests
3
+ *
4
+ * These tests validate that route-level middleware correctly reduces false positives:
5
+ * - can:* middleware → fully authorized → not flagged at all
6
+ * - auth-only middleware → downgraded from HIGH to LOW
7
+ * - No middleware → still HIGH
8
+ * - Webhook handler → not flagged
9
+ */
10
+ import { describe, it, expect } from 'vitest';
11
+ import { resolve, dirname } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { AuthorizationCoverageAnalyzer } from '../src/analyzers/AuthorizationCoverageAnalyzer.js';
14
+ import { RouteParser } from '../src/parsers/RouteParser.js';
15
+ import { PhpParser } from '../src/parsers/PhpParser.js';
16
+ import { DEFAULT_CONFIG } from '../src/index.js';
17
+ import { Severity } from '../src/types/index.js';
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const FIXTURES = resolve(__dirname, 'fixtures');
21
+
22
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
23
+
24
+ async function parsedController(className: string, methods: string) {
25
+ const parser = new PhpParser();
26
+ const source = `<?php
27
+ namespace App\\Http\\Controllers;
28
+ class ${className} extends Controller {
29
+ ${methods}
30
+ }`;
31
+ const { writeFile, mkdir } = await import('node:fs/promises');
32
+ const tmpDir = `/tmp/havoc-test-${className}/app/Http/Controllers`;
33
+ await mkdir(tmpDir, { recursive: true });
34
+ const tmpPath = `${tmpDir}/${className}.php`;
35
+ await writeFile(tmpPath, source, 'utf-8');
36
+ return parser.parseFile(tmpPath, `/tmp/havoc-test-${className}`);
37
+ }
38
+
39
+ // ─── can:* middleware → fully authorized ─────────────────────────────────────
40
+
41
+ describe('AuthorizationCoverageAnalyzer — route-level can:* middleware', () => {
42
+ it('does NOT flag a controller action with can:* middleware', async () => {
43
+ const routeParser = new RouteParser();
44
+ const routes = routeParser.parseContent(
45
+ `Route::get('/admin', [AdminController::class, 'index'])->middleware('can:view-admin');`,
46
+ );
47
+
48
+ const file = await parsedController('AdminController', `
49
+ public function index() {
50
+ return view('admin.index');
51
+ }
52
+ `);
53
+
54
+ const analyzer = new AuthorizationCoverageAnalyzer([routes]);
55
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
56
+
57
+ const authFindings = findings.filter((f) => f.analyzer === 'AuthorizationCoverageAnalyzer' && f.id === 'HAVOC-AUTH-001');
58
+ expect(authFindings).toHaveLength(0);
59
+ });
60
+
61
+ it('does NOT flag any action when entire group has can:* middleware', async () => {
62
+ const routeParser = new RouteParser();
63
+ const routes = routeParser.parseContent(`
64
+ Route::middleware(['can:manage-posts'])->group(function () {
65
+ Route::resource('posts', PostController::class);
66
+ });
67
+ `);
68
+
69
+ const file = await parsedController('PostController', `
70
+ public function index() { return Post::all(); }
71
+ public function store() { return Post::create([]); }
72
+ public function destroy() { }
73
+ `);
74
+
75
+ const analyzer = new AuthorizationCoverageAnalyzer([routes]);
76
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
77
+ const authFindings = findings.filter((f) => f.id === 'HAVOC-AUTH-001');
78
+ expect(authFindings).toHaveLength(0);
79
+ });
80
+ });
81
+
82
+ // ─── auth-only middleware → downgrade to LOW ──────────────────────────────────
83
+
84
+ describe('AuthorizationCoverageAnalyzer — auth-only middleware', () => {
85
+ it('produces LOW severity (not HIGH) for auth-only middleware', async () => {
86
+ const routeParser = new RouteParser();
87
+ const routes = routeParser.parseContent(
88
+ `Route::get('/profile', [ProfileController::class, 'show'])->middleware('auth');`,
89
+ );
90
+
91
+ const file = await parsedController('ProfileController', `
92
+ public function show() {
93
+ return auth()->user();
94
+ }
95
+ `);
96
+
97
+ const analyzer = new AuthorizationCoverageAnalyzer([routes]);
98
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
99
+ const authFindings = findings.filter((f) => f.id === 'HAVOC-AUTH-001');
100
+
101
+ expect(authFindings).toHaveLength(1);
102
+ expect(authFindings[0].severity).toBe(Severity.Low);
103
+ });
104
+
105
+ it('produces LOW severity for auth:sanctum middleware', async () => {
106
+ const routeParser = new RouteParser();
107
+ const routes = routeParser.parseContent(
108
+ `Route::get('/api/profile', [ApiProfileController::class, 'show'])->middleware('auth:sanctum');`,
109
+ );
110
+
111
+ const file = await parsedController('ApiProfileController', `
112
+ public function show() { return response()->json([]); }
113
+ `);
114
+
115
+ const analyzer = new AuthorizationCoverageAnalyzer([routes]);
116
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
117
+ const authFindings = findings.filter((f) => f.id === 'HAVOC-AUTH-001');
118
+
119
+ expect(authFindings).toHaveLength(1);
120
+ expect(authFindings[0].severity).toBe(Severity.Low);
121
+ });
122
+
123
+ it('produces LOW for verified middleware', async () => {
124
+ const routeParser = new RouteParser();
125
+ const routes = routeParser.parseContent(
126
+ `Route::get('/dashboard', [DashboardController::class, 'index'])->middleware(['auth', 'verified']);`,
127
+ );
128
+
129
+ const file = await parsedController('DashboardController', `
130
+ public function index() { return view('dashboard'); }
131
+ `);
132
+
133
+ const analyzer = new AuthorizationCoverageAnalyzer([routes]);
134
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
135
+ const authFindings = findings.filter((f) => f.id === 'HAVOC-AUTH-001');
136
+
137
+ expect(authFindings).toHaveLength(1);
138
+ expect(authFindings[0].severity).toBe(Severity.Low);
139
+ });
140
+ });
141
+
142
+ // ─── No middleware → HIGH ─────────────────────────────────────────────────────
143
+
144
+ describe('AuthorizationCoverageAnalyzer — no route middleware', () => {
145
+ it('still produces HIGH when no route middleware is provided', async () => {
146
+ const analyzer = new AuthorizationCoverageAnalyzer([]); // no routes
147
+
148
+ const file = await parsedController('ExposedController', `
149
+ public function show() {
150
+ return Post::all();
151
+ }
152
+ `);
153
+
154
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
155
+ const authFindings = findings.filter((f) => f.id === 'HAVOC-AUTH-001');
156
+ expect(authFindings).toHaveLength(1);
157
+ expect(authFindings[0].severity).toBe(Severity.High);
158
+ });
159
+
160
+ it('still produces HIGH for controller not present in route file', async () => {
161
+ const routeParser = new RouteParser();
162
+ const routes = routeParser.parseContent(
163
+ `Route::get('/other', [OtherController::class, 'index'])->middleware('can:do-stuff');`,
164
+ );
165
+
166
+ const file = await parsedController('UnknownController', `
167
+ public function show() { return Post::find(1); }
168
+ `);
169
+
170
+ const analyzer = new AuthorizationCoverageAnalyzer([routes]);
171
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
172
+ const authFindings = findings.filter((f) => f.id === 'HAVOC-AUTH-001');
173
+ expect(authFindings).toHaveLength(1);
174
+ expect(authFindings[0].severity).toBe(Severity.High);
175
+ });
176
+ });
177
+
178
+ // ─── Inline authorization still works ────────────────────────────────────────
179
+
180
+ describe('AuthorizationCoverageAnalyzer — inline authorization', () => {
181
+ it('does not flag a method with $this->authorize even without route middleware', async () => {
182
+ const file = await parsedController('SecureController', `
183
+ public function show() {
184
+ $this->authorize('view', Post::class);
185
+ return Post::find(1);
186
+ }
187
+ `);
188
+
189
+ const analyzer = new AuthorizationCoverageAnalyzer([]);
190
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
191
+ const authFindings = findings.filter((f) => f.id === 'HAVOC-AUTH-001');
192
+ expect(authFindings).toHaveLength(0);
193
+ });
194
+
195
+ it('does not flag a method with Gate::allows', async () => {
196
+ const file = await parsedController('GateController', `
197
+ public function index() {
198
+ Gate::allows('view-admin');
199
+ return view('admin');
200
+ }
201
+ `);
202
+
203
+ const analyzer = new AuthorizationCoverageAnalyzer([]);
204
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
205
+ const authFindings = findings.filter((f) => f.id === 'HAVOC-AUTH-001');
206
+ expect(authFindings).toHaveLength(0);
207
+ });
208
+ });
209
+
210
+ // ─── Webhook handler detection ────────────────────────────────────────────────
211
+
212
+ describe('AuthorizationCoverageAnalyzer — webhook handlers', () => {
213
+ it('does not flag a webhook controller that verifies signature via X-Signature header', async () => {
214
+ const file = await parsedController('StripeWebhookController', `
215
+ public function handle(Request $request) {
216
+ $sig = $request->header('X-Stripe-Signature');
217
+ $event = Webhook::constructEvent($payload, $sig, config('services.stripe.secret'));
218
+ return response()->json(['status' => 'ok']);
219
+ }
220
+ `);
221
+
222
+ const analyzer = new AuthorizationCoverageAnalyzer([]);
223
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
224
+ const authFindings = findings.filter((f) => f.id === 'HAVOC-AUTH-001');
225
+ expect(authFindings).toHaveLength(0);
226
+ });
227
+
228
+ it('does not flag a webhook controller that uses hash_hmac verification', async () => {
229
+ const file = await parsedController('GitHubWebhookController', `
230
+ public function receive(Request $request) {
231
+ $computed = hash_hmac('sha256', $request->getContent(), config('services.github.secret'));
232
+ if (!hash_equals($computed, $request->header('X-Hub-Signature-256'))) {
233
+ abort(403);
234
+ }
235
+ return response()->json(['ok' => true]);
236
+ }
237
+ `);
238
+
239
+ const analyzer = new AuthorizationCoverageAnalyzer([]);
240
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
241
+ const authFindings = findings.filter((f) => f.id === 'HAVOC-AUTH-001');
242
+ expect(authFindings).toHaveLength(0);
243
+ });
244
+ });
245
+
246
+ // ─── Integration: fixture files ───────────────────────────────────────────────
247
+
248
+ describe('AuthorizationCoverageAnalyzer — integration with fixture files', () => {
249
+ it('fixture route file reduces PostController false positives', async () => {
250
+ const routeParser = new RouteParser();
251
+ const phpParser = new PhpParser();
252
+
253
+ const routes = await routeParser.parseFile(resolve(FIXTURES, 'routes/web.php'), FIXTURES);
254
+
255
+ const postFile = await phpParser.parseFile(
256
+ resolve(FIXTURES, 'app/Http/Controllers/PostController.php'),
257
+ FIXTURES,
258
+ );
259
+
260
+ // Without routes: store() and update() are HIGH
261
+ const analyzerNoRoutes = new AuthorizationCoverageAnalyzer([]);
262
+ const findingsNoRoutes = await analyzerNoRoutes.analyze([postFile], DEFAULT_CONFIG);
263
+ const highNoRoutes = findingsNoRoutes.filter((f) => f.severity === Severity.High && f.id === 'HAVOC-AUTH-001');
264
+ expect(highNoRoutes.length).toBeGreaterThan(0);
265
+
266
+ // With routes: store() and update() are behind auth group -> downgraded to LOW
267
+ const analyzerWithRoutes = new AuthorizationCoverageAnalyzer([routes]);
268
+ const findingsWithRoutes = await analyzerWithRoutes.analyze([postFile], DEFAULT_CONFIG);
269
+ const highWithRoutes = findingsWithRoutes.filter((f) => f.severity === Severity.High && f.id === 'HAVOC-AUTH-001');
270
+ expect(highWithRoutes.length).toBeLessThan(highNoRoutes.length);
271
+ });
272
+
273
+ it('setRouteFiles updates route data after construction', async () => {
274
+ const routeParser = new RouteParser();
275
+ const routes = routeParser.parseContent(
276
+ `Route::get('/items', [ItemController::class, 'index'])->middleware('can:view-items');`,
277
+ );
278
+
279
+ const file = await parsedController('ItemController', `
280
+ public function index() { return Item::all(); }
281
+ `);
282
+
283
+ const analyzer = new AuthorizationCoverageAnalyzer([]);
284
+
285
+ // Before setting routes: HIGH
286
+ const before = await analyzer.analyze([file], DEFAULT_CONFIG);
287
+ expect(before.filter((f) => f.id === 'HAVOC-AUTH-001')[0]?.severity).toBe(Severity.High);
288
+
289
+ // After setting routes: should be cleared
290
+ analyzer.setRouteFiles([routes]);
291
+ const after = await analyzer.analyze([file], DEFAULT_CONFIG);
292
+ expect(after.filter((f) => f.id === 'HAVOC-AUTH-001')).toHaveLength(0);
293
+ });
294
+ });
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { CredentialExposureAnalyzer } from '../src/analyzers/CredentialExposureAnalyzer.js';
3
+ import { DEFAULT_CONFIG } from '../src/index.js';
4
+ import type { ParsedFile } from '../src/types/index.js';
5
+ import { Severity } from '../src/types/index.js';
6
+
7
+ function makeFile(path: string, content: string): ParsedFile {
8
+ return { path, content, ast: null };
9
+ }
10
+
11
+ const analyzer = new CredentialExposureAnalyzer();
12
+
13
+ // ─── CredentialExposureAnalyzer ───────────────────────────────────────────────
14
+
15
+ describe('CredentialExposureAnalyzer', () => {
16
+ it('has correct name', () => {
17
+ expect(analyzer.name).toBe('CredentialExposureAnalyzer');
18
+ });
19
+
20
+ it('returns empty array for empty file list', async () => {
21
+ const result = await analyzer.analyze([], DEFAULT_CONFIG);
22
+ expect(result).toHaveLength(0);
23
+ });
24
+
25
+ // .env file committed
26
+ it('flags .env file committed to repo as CRITICAL', async () => {
27
+ const file = makeFile('.env', 'APP_KEY=base64:abc123\nDB_PASSWORD=secret');
28
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
29
+ expect(findings.some(f => f.id === 'HAVOC-CRED-001')).toBe(true);
30
+ expect(findings.find(f => f.id === 'HAVOC-CRED-001')?.severity).toBe(Severity.Critical);
31
+ });
32
+
33
+ it('flags .env.production as committed', async () => {
34
+ const file = makeFile('.env.production', 'APP_KEY=base64:abc123');
35
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
36
+ expect(findings.some(f => f.id === 'HAVOC-CRED-001')).toBe(true);
37
+ });
38
+
39
+ it('does NOT flag .env.example as a secret exposure', async () => {
40
+ const file = makeFile('.env.example', 'APP_KEY=\nDB_PASSWORD=');
41
+ // .env.example is not treated as an env file by isEnvFile(), so CRED-001 is not triggered
42
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
43
+ // .env.example should not trigger CRED-002 (hardcoded secrets) since values are empty
44
+ expect(findings.filter(f => f.id === 'HAVOC-CRED-002')).toHaveLength(0);
45
+ });
46
+
47
+ // Hardcoded Stripe key
48
+ it('detects hardcoded Stripe live secret key as CRITICAL', async () => {
49
+ const file = makeFile('app/Services/PaymentService.php', `<?php
50
+ $key = 'sk_live_abcdefghijklmnopqrstuvwx123456';
51
+ `);
52
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
53
+ expect(findings.some(f => f.id === 'HAVOC-CRED-002' && f.severity === Severity.Critical)).toBe(true);
54
+ });
55
+
56
+ // AWS key
57
+ it('detects hardcoded AWS Access Key ID', async () => {
58
+ const file = makeFile('app/Services/S3Service.php', `<?php
59
+ $key = 'AKIAIOSFODNN7REALKEY';
60
+ `);
61
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
62
+ expect(findings.some(f => f.id === 'HAVOC-CRED-002')).toBe(true);
63
+ });
64
+
65
+ // Clean code with env()
66
+ it('does NOT flag secrets read from env()', async () => {
67
+ const file = makeFile('config/services.php', `<?php
68
+ return [
69
+ 'stripe' => ['key' => env('STRIPE_SECRET')],
70
+ ];
71
+ `);
72
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
73
+ expect(findings.filter(f => f.id === 'HAVOC-CRED-002')).toHaveLength(0);
74
+ });
75
+
76
+ // APP_DEBUG=true
77
+ it('flags APP_DEBUG=true in .env as HIGH', async () => {
78
+ const file = makeFile('.env', 'APP_DEBUG=true\nAPP_ENV=production');
79
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
80
+ expect(findings.some(f => f.id === 'HAVOC-CRED-003' && f.severity === Severity.High)).toBe(true);
81
+ });
82
+
83
+ it('does NOT flag APP_DEBUG=false', async () => {
84
+ const file = makeFile('.env', 'APP_DEBUG=false\nAPP_ENV=production');
85
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
86
+ expect(findings.filter(f => f.id === 'HAVOC-CRED-003')).toHaveLength(0);
87
+ });
88
+
89
+ // APP_ENV=local in production config
90
+ it('flags APP_ENV=local in .env.production as MEDIUM', async () => {
91
+ const file = makeFile('.env.production', 'APP_ENV=local\nAPP_DEBUG=false');
92
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
93
+ expect(findings.some(f => f.id === 'HAVOC-CRED-004' && f.severity === Severity.Medium)).toBe(true);
94
+ });
95
+
96
+ // debug => true in config/app.php
97
+ it("flags 'debug' => true hardcoded in config/app.php as HIGH", async () => {
98
+ const file = makeFile('config/app.php', `<?php
99
+ return [
100
+ 'debug' => true,
101
+ 'env' => env('APP_ENV', 'production'),
102
+ ];
103
+ `);
104
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
105
+ expect(findings.some(f => f.id === 'HAVOC-CRED-005' && f.severity === Severity.High)).toBe(true);
106
+ });
107
+
108
+ it("does NOT flag 'debug' => env('APP_DEBUG', false)", async () => {
109
+ const file = makeFile('config/app.php', `<?php
110
+ return [
111
+ 'debug' => env('APP_DEBUG', false),
112
+ ];
113
+ `);
114
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
115
+ expect(findings.filter(f => f.id === 'HAVOC-CRED-005')).toHaveLength(0);
116
+ });
117
+
118
+ // Private key inline
119
+ it('detects PEM private key in PHP source as CRITICAL', async () => {
120
+ const file = makeFile('app/Services/JwtService.php', `<?php
121
+ $key = '-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----';
122
+ `);
123
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
124
+ expect(findings.some(f => f.id === 'HAVOC-CRED-007' && f.severity === Severity.Critical)).toBe(true);
125
+ });
126
+
127
+ // DB credentials
128
+ it('detects PDO instantiation with hardcoded password as CRITICAL', async () => {
129
+ const file = makeFile('app/Legacy/Database.php', `<?php
130
+ $pdo = new PDO('mysql:host=localhost;dbname=app', 'root', 'supersecretpassword');
131
+ `);
132
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
133
+ expect(findings.some(f => f.id === 'HAVOC-CRED-006' && f.severity === Severity.Critical)).toBe(true);
134
+ });
135
+
136
+ // Non-PHP files not scanned for secrets
137
+ it('does not scan blade templates for PHP secrets', async () => {
138
+ const file = makeFile('resources/views/home.blade.php', `password = 'hardcoded_secret'`);
139
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
140
+ expect(findings.filter(f => f.id === 'HAVOC-CRED-002')).toHaveLength(0);
141
+ });
142
+ });
@@ -0,0 +1,141 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { FileUploadAnalyzer } from '../src/analyzers/FileUploadAnalyzer.js';
3
+ import { DEFAULT_CONFIG } from '../src/index.js';
4
+ import type { ParsedFile } from '../src/types/index.js';
5
+ import { Severity } from '../src/types/index.js';
6
+
7
+ function makeFile(path: string, content: string): ParsedFile {
8
+ return { path, content, ast: null };
9
+ }
10
+
11
+ const analyzer = new FileUploadAnalyzer();
12
+
13
+ describe('FileUploadAnalyzer', () => {
14
+ it('has correct name', () => {
15
+ expect(analyzer.name).toBe('FileUploadAnalyzer');
16
+ });
17
+
18
+ it('returns empty for empty file list', async () => {
19
+ expect(await analyzer.analyze([], DEFAULT_CONFIG)).toHaveLength(0);
20
+ });
21
+
22
+ // Missing type validation
23
+ it('flags upload without mimes validation as HIGH', async () => {
24
+ const file = makeFile('app/Http/Controllers/UploadController.php', `<?php
25
+ class UploadController extends Controller {
26
+ public function store(Request $request) {
27
+ $request->validate(['file' => 'required|file|max:2048']);
28
+ $path = $request->file('file')->store('uploads');
29
+ return response()->json(['path' => $path]);
30
+ }
31
+ }`);
32
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
33
+ expect(findings.some(f => f.id === 'HAVOC-UPLOAD-001' && f.severity === Severity.High)).toBe(true);
34
+ });
35
+
36
+ // Missing size validation
37
+ it('flags upload without max: size rule as MEDIUM', async () => {
38
+ const file = makeFile('app/Http/Controllers/UploadController.php', `<?php
39
+ class UploadController extends Controller {
40
+ public function store(Request $request) {
41
+ $request->validate(['file' => 'required|file|mimes:jpg,png']);
42
+ $path = $request->file('file')->store('uploads');
43
+ return response()->json(['path' => $path]);
44
+ }
45
+ }`);
46
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
47
+ expect(findings.some(f => f.id === 'HAVOC-UPLOAD-002' && f.severity === Severity.Medium)).toBe(true);
48
+ });
49
+
50
+ // Clean upload controller — full validation
51
+ it('does NOT flag an upload with proper mimes and max validation', async () => {
52
+ const file = makeFile('app/Http/Controllers/UploadController.php', `<?php
53
+ class UploadController extends Controller {
54
+ public function store(Request $request) {
55
+ $request->validate(['file' => 'required|file|mimes:jpg,png,pdf|max:10240']);
56
+ $path = $request->file('file')->store('uploads');
57
+ return response()->json(['path' => $path]);
58
+ }
59
+ }`);
60
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
61
+ expect(findings.filter(f => f.id === 'HAVOC-UPLOAD-001')).toHaveLength(0);
62
+ expect(findings.filter(f => f.id === 'HAVOC-UPLOAD-002')).toHaveLength(0);
63
+ });
64
+
65
+ // Public disk storage
66
+ it('flags storage to public disk as MEDIUM', async () => {
67
+ const file = makeFile('app/Http/Controllers/AvatarController.php', `<?php
68
+ class AvatarController extends Controller {
69
+ public function update(Request $request) {
70
+ $request->validate(['avatar' => 'required|file|mimes:jpg,png|max:2048']);
71
+ $path = $request->file('avatar')->store('avatars', 'public');
72
+ return response()->json(['path' => $path]);
73
+ }
74
+ }`);
75
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
76
+ expect(findings.some(f => f.id === 'HAVOC-UPLOAD-003' && f.severity === Severity.Medium)).toBe(true);
77
+ });
78
+
79
+ // Executable extension in mimes
80
+ it('flags php in allowed mimes as HIGH', async () => {
81
+ const file = makeFile('app/Http/Controllers/FileController.php', `<?php
82
+ class FileController extends Controller {
83
+ public function store(Request $request) {
84
+ $request->validate(['file' => 'required|file|mimes:php,txt|max:1024']);
85
+ $path = $request->file('file')->store('uploads');
86
+ return response()->json(['path' => $path]);
87
+ }
88
+ }`);
89
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
90
+ expect(findings.some(f => f.id === 'HAVOC-UPLOAD-004' && f.severity === Severity.High)).toBe(true);
91
+ });
92
+
93
+ // Path from user input
94
+ it('flags $request->input("path") used in Storage call as HIGH', async () => {
95
+ const file = makeFile('app/Http/Controllers/DocumentController.php', `<?php
96
+ class DocumentController extends Controller {
97
+ public function store(Request $request) {
98
+ $path = $request->input('path');
99
+ Storage::put($path, $request->file('doc')->get());
100
+ }
101
+ }`);
102
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
103
+ expect(findings.some(f => f.id === 'HAVOC-UPLOAD-005' && f.severity === Severity.High)).toBe(true);
104
+ });
105
+
106
+ // Path from input not near storage — no false positive
107
+ it('does NOT flag $request->input("path") used in non-storage context', async () => {
108
+ const file = makeFile('app/Http/Controllers/PageController.php', `<?php
109
+ class PageController extends Controller {
110
+ public function show(Request $request) {
111
+ $path = $request->input('path');
112
+ return view($path);
113
+ }
114
+ }`);
115
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
116
+ expect(findings.filter(f => f.id === 'HAVOC-UPLOAD-005')).toHaveLength(0);
117
+ });
118
+
119
+ // Non-PHP files skipped
120
+ it('does not analyze blade templates for upload issues', async () => {
121
+ const file = makeFile('resources/views/upload.blade.php', `<form method="POST" enctype="multipart/form-data"></form>`);
122
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
123
+ expect(findings).toHaveLength(0);
124
+ });
125
+
126
+ // $request->hasFile variant
127
+ it('detects upload patterns using $request->hasFile()', async () => {
128
+ const file = makeFile('app/Http/Controllers/ProfileController.php', `<?php
129
+ class ProfileController extends Controller {
130
+ public function update(Request $request) {
131
+ if ($request->hasFile('avatar')) {
132
+ $path = $request->file('avatar')->storeAs('avatars', 'file.jpg');
133
+ }
134
+ }
135
+ }`);
136
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
137
+ // Should flag missing type and size validation
138
+ expect(findings.some(f => f.id === 'HAVOC-UPLOAD-001')).toBe(true);
139
+ expect(findings.some(f => f.id === 'HAVOC-UPLOAD-002')).toBe(true);
140
+ });
141
+ });
@@ -0,0 +1,19 @@
1
+ <?php
2
+
3
+ namespace App\Http\Controllers;
4
+
5
+ class AdminController extends Controller
6
+ {
7
+ public function dashboard()
8
+ {
9
+ Gate::authorize('admin');
10
+ return view('admin.dashboard');
11
+ }
12
+
13
+ public function settings()
14
+ {
15
+ // Fully covered with Gate
16
+ Gate::allows('manage-settings');
17
+ return view('admin.settings');
18
+ }
19
+ }
@@ -0,0 +1,49 @@
1
+ <?php
2
+
3
+ namespace App\Http\Controllers;
4
+
5
+ use App\Models\Post;
6
+ use Illuminate\Http\Request;
7
+
8
+ class PostController extends Controller
9
+ {
10
+ public function __construct()
11
+ {
12
+ // constructors are excluded
13
+ }
14
+
15
+ public function index()
16
+ {
17
+ $this->authorize('viewAny', Post::class);
18
+ return Post::all();
19
+ }
20
+
21
+ public function show(Post $post)
22
+ {
23
+ Gate::allows('view', $post);
24
+ return $post;
25
+ }
26
+
27
+ public function store(Request $request)
28
+ {
29
+ // Missing authorization — should be flagged
30
+ return Post::create($request->all());
31
+ }
32
+
33
+ public function update(Request $request, Post $post)
34
+ {
35
+ // Missing authorization — should be flagged
36
+ $post->update($request->all());
37
+ }
38
+
39
+ public function destroy(Post $post)
40
+ {
41
+ $this->authorize('delete', $post);
42
+ $post->delete();
43
+ }
44
+
45
+ protected function someProtectedMethod()
46
+ {
47
+ // protected — not flagged
48
+ }
49
+ }
@@ -0,0 +1,17 @@
1
+ <?php
2
+
3
+ namespace App\Http\Controllers;
4
+
5
+ class PublicController extends Controller
6
+ {
7
+ public function home()
8
+ {
9
+ // Public page — no auth needed, but analyzer will flag it
10
+ return view('home');
11
+ }
12
+
13
+ public function about()
14
+ {
15
+ return view('about');
16
+ }
17
+ }
@@ -0,0 +1,11 @@
1
+ <?php
2
+
3
+ namespace App\Models;
4
+
5
+ use Illuminate\Database\Eloquent\Model;
6
+
7
+ // No $fillable or $guarded — should be flagged MEDIUM
8
+ class Comment extends Model
9
+ {
10
+ public $timestamps = true;
11
+ }