@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,678 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { resolve, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import {
6
+ AuthorizationCoverageAnalyzer,
7
+ MassAssignmentAnalyzer,
8
+ XssSurfaceAnalyzer,
9
+ SqlInjectionAnalyzer,
10
+ DependencyAuditAnalyzer,
11
+ IdorAnalyzer,
12
+ CredentialExposureAnalyzer,
13
+ SessionSecurityAnalyzer,
14
+ FileUploadAnalyzer,
15
+ } from '../src/analyzers/index.js';
16
+ import { DEFAULT_CONFIG } from '../src/index.js';
17
+ import { PhpParser } from '../src/parsers/PhpParser.js';
18
+ import type { ParsedFile } from '../src/types/index.js';
19
+ import { Severity } from '../src/types/index.js';
20
+
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+ const FIXTURES = resolve(__dirname, 'fixtures');
23
+
24
+ async function fixtureFile(relativePath: string): Promise<ParsedFile> {
25
+ const absPath = resolve(FIXTURES, relativePath);
26
+ const content = await readFile(absPath, 'utf-8');
27
+ return { path: relativePath, content, ast: null };
28
+ }
29
+
30
+ async function parsedFixture(relativePath: string) {
31
+ const parser = new PhpParser();
32
+ const absPath = resolve(FIXTURES, relativePath);
33
+ return parser.parseFile(absPath, FIXTURES);
34
+ }
35
+
36
+ // ─── Analyzer interface conformance ──────────────────────────────────────────
37
+
38
+ describe('Analyzer interface conformance', () => {
39
+ const ALL_ANALYZERS = [
40
+ new AuthorizationCoverageAnalyzer(),
41
+ new MassAssignmentAnalyzer(),
42
+ new XssSurfaceAnalyzer(),
43
+ new SqlInjectionAnalyzer(),
44
+ new DependencyAuditAnalyzer(),
45
+ new IdorAnalyzer(),
46
+ new CredentialExposureAnalyzer(),
47
+ new SessionSecurityAnalyzer(),
48
+ new FileUploadAnalyzer(),
49
+ ];
50
+
51
+ it('all analyzers have a non-empty name string', () => {
52
+ for (const a of ALL_ANALYZERS) {
53
+ expect(typeof a.name).toBe('string');
54
+ expect(a.name.length).toBeGreaterThan(0);
55
+ }
56
+ });
57
+
58
+ it('all analyzers have a non-empty description string', () => {
59
+ for (const a of ALL_ANALYZERS) {
60
+ expect(typeof a.description).toBe('string');
61
+ expect(a.description.length).toBeGreaterThan(0);
62
+ }
63
+ });
64
+
65
+ it('all analyzers return an array for empty file input', async () => {
66
+ for (const a of ALL_ANALYZERS) {
67
+ const result = await a.analyze([], DEFAULT_CONFIG);
68
+ expect(Array.isArray(result)).toBe(true);
69
+ }
70
+ });
71
+
72
+ it('all analyzers return empty array for empty file list', async () => {
73
+ for (const a of ALL_ANALYZERS) {
74
+ const result = await a.analyze([], DEFAULT_CONFIG);
75
+ expect(result).toHaveLength(0);
76
+ }
77
+ });
78
+
79
+ it('AuthorizationCoverageAnalyzer name is AuthorizationCoverageAnalyzer', () => {
80
+ expect(new AuthorizationCoverageAnalyzer().name).toBe('AuthorizationCoverageAnalyzer');
81
+ });
82
+
83
+ it('MassAssignmentAnalyzer name is MassAssignmentAnalyzer', () => {
84
+ expect(new MassAssignmentAnalyzer().name).toBe('MassAssignmentAnalyzer');
85
+ });
86
+
87
+ it('XssSurfaceAnalyzer name is XssSurfaceAnalyzer', () => {
88
+ expect(new XssSurfaceAnalyzer().name).toBe('XssSurfaceAnalyzer');
89
+ });
90
+
91
+ it('SqlInjectionAnalyzer name is SqlInjectionAnalyzer', () => {
92
+ expect(new SqlInjectionAnalyzer().name).toBe('SqlInjectionAnalyzer');
93
+ });
94
+
95
+ it('DependencyAuditAnalyzer name is DependencyAuditAnalyzer', () => {
96
+ expect(new DependencyAuditAnalyzer().name).toBe('DependencyAuditAnalyzer');
97
+ });
98
+
99
+ it('IdorAnalyzer name is IdorAnalyzer', () => {
100
+ expect(new IdorAnalyzer().name).toBe('IdorAnalyzer');
101
+ });
102
+ });
103
+
104
+ // ─── PhpParser tests ──────────────────────────────────────────────────────────
105
+
106
+ describe('PhpParser', () => {
107
+ it('parses a PHP file and returns a ParsedPhpFile', async () => {
108
+ const result = await parsedFixture('app/Http/Controllers/PostController.php');
109
+ expect(result.path).toContain('PostController.php');
110
+ expect(result.content.length).toBeGreaterThan(0);
111
+ expect(result.classes).toBeDefined();
112
+ });
113
+
114
+ it('extracts class name from a controller', async () => {
115
+ const result = await parsedFixture('app/Http/Controllers/PostController.php');
116
+ expect(result.classes.length).toBeGreaterThan(0);
117
+ expect(result.classes[0].name).toBe('PostController');
118
+ });
119
+
120
+ it('extracts public methods from a controller', async () => {
121
+ const result = await parsedFixture('app/Http/Controllers/PostController.php');
122
+ const cls = result.classes[0];
123
+ const methodNames = cls.methods.map((m) => m.name);
124
+ expect(methodNames).toContain('index');
125
+ expect(methodNames).toContain('store');
126
+ expect(methodNames).toContain('destroy');
127
+ });
128
+
129
+ it('marks methods with correct visibility', async () => {
130
+ const result = await parsedFixture('app/Http/Controllers/PostController.php');
131
+ const cls = result.classes[0];
132
+ const storeMethod = cls.methods.find((m) => m.name === 'store');
133
+ expect(storeMethod?.visibility).toBe('public');
134
+ const protectedMethod = cls.methods.find((m) => m.name === 'someProtectedMethod');
135
+ expect(protectedMethod?.visibility).toBe('protected');
136
+ });
137
+
138
+ it('extracts model properties ($fillable)', async () => {
139
+ const result = await parsedFixture('app/Models/User.php');
140
+ const cls = result.classes[0];
141
+ expect(cls.name).toBe('User');
142
+ const fillable = cls.properties.find((p) => p.name === 'fillable');
143
+ expect(fillable).toBeDefined();
144
+ });
145
+
146
+ it('fillable initializer includes field names', async () => {
147
+ const result = await parsedFixture('app/Models/User.php');
148
+ const cls = result.classes[0];
149
+ const fillable = cls.properties.find((p) => p.name === 'fillable');
150
+ expect(fillable?.initializer).toContain('name');
151
+ expect(fillable?.initializer).toContain('email');
152
+ });
153
+
154
+ it('extracts namespace from PHP file', async () => {
155
+ const result = await parsedFixture('app/Models/User.php');
156
+ expect(result.namespace).toBe('App\\Models');
157
+ });
158
+
159
+ it('extracts use statements (imports)', async () => {
160
+ const result = await parsedFixture('app/Http/Controllers/PostController.php');
161
+ expect(result.useStatements).toBeDefined();
162
+ expect(Array.isArray(result.useStatements)).toBe(true);
163
+ });
164
+
165
+ it('returns array for route file (procedural)', async () => {
166
+ const result = await parsedFixture('routes/web.php');
167
+ expect(Array.isArray(result.classes)).toBe(true);
168
+ });
169
+
170
+ it('handles parse errors gracefully (no throw)', async () => {
171
+ const parser = new PhpParser();
172
+ const badFile = resolve(FIXTURES, 'app/Http/Controllers/PostController.php');
173
+ const result = await parser.parseFile(badFile, FIXTURES);
174
+ expect(result).toBeDefined();
175
+ });
176
+
177
+ it('extracts method body text', async () => {
178
+ const result = await parsedFixture('app/Http/Controllers/PostController.php');
179
+ const cls = result.classes[0];
180
+ const storeMethod = cls.methods.find((m) => m.name === 'store');
181
+ expect(typeof storeMethod?.bodyText).toBe('string');
182
+ });
183
+
184
+ it('extracts parent class name', async () => {
185
+ const result = await parsedFixture('app/Http/Controllers/PostController.php');
186
+ const cls = result.classes[0];
187
+ expect(cls.parent).toBe('Controller');
188
+ });
189
+
190
+ it('model with no fillable has no properties', async () => {
191
+ const result = await parsedFixture('app/Models/Comment.php');
192
+ const cls = result.classes[0];
193
+ expect(cls.name).toBe('Comment');
194
+ const fillable = cls.properties.find((p) => p.name === 'fillable');
195
+ expect(fillable).toBeUndefined();
196
+ });
197
+ });
198
+
199
+ // ─── AuthorizationCoverageAnalyzer ───────────────────────────────────────────
200
+
201
+ describe('AuthorizationCoverageAnalyzer', () => {
202
+ it('does not flag non-controller files', async () => {
203
+ const file = await parsedFixture('app/Models/User.php');
204
+ const analyzer = new AuthorizationCoverageAnalyzer();
205
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
206
+ expect(findings).toHaveLength(0);
207
+ });
208
+
209
+ it('flags unprotected controller actions', async () => {
210
+ const file = await parsedFixture('app/Http/Controllers/PostController.php');
211
+ const analyzer = new AuthorizationCoverageAnalyzer();
212
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
213
+ const unprotected = findings.filter((f) => f.id === 'HAVOC-AUTH-001');
214
+ expect(unprotected.length).toBeGreaterThanOrEqual(2);
215
+ });
216
+
217
+ it('does not flag protected actions with $this->authorize()', async () => {
218
+ const file = await parsedFixture('app/Http/Controllers/PostController.php');
219
+ const analyzer = new AuthorizationCoverageAnalyzer();
220
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
221
+ const flaggedMethods = findings.map((f) => f.title);
222
+ expect(flaggedMethods.some((t) => t.includes('index'))).toBe(false);
223
+ expect(flaggedMethods.some((t) => t.includes('destroy'))).toBe(false);
224
+ });
225
+
226
+ it('does not flag __construct method', async () => {
227
+ const file = await parsedFixture('app/Http/Controllers/PostController.php');
228
+ const analyzer = new AuthorizationCoverageAnalyzer();
229
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
230
+ const flaggedMethods = findings.map((f) => f.title);
231
+ expect(flaggedMethods.some((t) => t.includes('__construct'))).toBe(false);
232
+ });
233
+
234
+ it('does not flag protected methods', async () => {
235
+ const file = await parsedFixture('app/Http/Controllers/PostController.php');
236
+ const analyzer = new AuthorizationCoverageAnalyzer();
237
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
238
+ const flaggedMethods = findings.map((f) => f.title);
239
+ expect(flaggedMethods.some((t) => t.includes('someProtectedMethod'))).toBe(false);
240
+ });
241
+
242
+ it('flags public controller methods without auth', async () => {
243
+ const file = await parsedFixture('app/Http/Controllers/PostController.php');
244
+ const analyzer = new AuthorizationCoverageAnalyzer();
245
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
246
+ expect(findings.some((f) => f.id === 'HAVOC-AUTH-001')).toBe(true);
247
+ });
248
+
249
+ it('produces findings with correct CWE', async () => {
250
+ const file = await parsedFixture('app/Http/Controllers/PostController.php');
251
+ const analyzer = new AuthorizationCoverageAnalyzer();
252
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
253
+ const authFindings = findings.filter((f) => f.id === 'HAVOC-AUTH-001');
254
+ for (const f of authFindings) {
255
+ expect(f.cwe).toBe('CWE-862');
256
+ }
257
+ });
258
+
259
+ it('findings have High or Medium severity', async () => {
260
+ const file = await parsedFixture('app/Http/Controllers/PostController.php');
261
+ const analyzer = new AuthorizationCoverageAnalyzer();
262
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
263
+ for (const f of findings) {
264
+ expect([Severity.High, Severity.Medium]).toContain(f.severity);
265
+ }
266
+ });
267
+
268
+ it('includes file path in finding', async () => {
269
+ const file = await parsedFixture('app/Http/Controllers/PostController.php');
270
+ const analyzer = new AuthorizationCoverageAnalyzer();
271
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
272
+ const authFindings = findings.filter((f) => f.id === 'HAVOC-AUTH-001');
273
+ for (const f of authFindings) {
274
+ expect(f.file).toContain('PostController.php');
275
+ }
276
+ });
277
+
278
+ it('fully covered controller produces no HAVOC-AUTH-001 findings', async () => {
279
+ const file = await parsedFixture('app/Http/Controllers/AdminController.php');
280
+ const analyzer = new AuthorizationCoverageAnalyzer();
281
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
282
+ const authFindings = findings.filter((f) => f.id === 'HAVOC-AUTH-001');
283
+ expect(authFindings).toHaveLength(0);
284
+ });
285
+ });
286
+
287
+ // ─── MassAssignmentAnalyzer ───────────────────────────────────────────────────
288
+
289
+ describe('MassAssignmentAnalyzer', () => {
290
+ it('does not flag non-model files', async () => {
291
+ const file = await fixtureFile('app/Http/Controllers/PostController.php');
292
+ const analyzer = new MassAssignmentAnalyzer();
293
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
294
+ expect(findings).toHaveLength(0);
295
+ });
296
+
297
+ it('flags model with no $fillable or $guarded (MEDIUM)', async () => {
298
+ const file = await parsedFixture('app/Models/Comment.php');
299
+ const analyzer = new MassAssignmentAnalyzer();
300
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
301
+ expect(findings.some((f) => f.id === 'HAVOC-MA-001')).toBe(true);
302
+ expect(findings.find((f) => f.id === 'HAVOC-MA-001')?.severity).toBe(Severity.Medium);
303
+ });
304
+
305
+ it('flags model with $guarded = [] as HIGH', async () => {
306
+ const file = await parsedFixture('app/Models/OpenModel.php');
307
+ const analyzer = new MassAssignmentAnalyzer();
308
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
309
+ expect(findings.some((f) => f.id === 'HAVOC-MA-002')).toBe(true);
310
+ expect(findings.find((f) => f.id === 'HAVOC-MA-002')?.severity).toBe(Severity.High);
311
+ });
312
+
313
+ it('flags sensitive field "password" in $fillable (MEDIUM)', async () => {
314
+ const file = await parsedFixture('app/Models/User.php');
315
+ const analyzer = new MassAssignmentAnalyzer();
316
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
317
+ const sensitiveFindings = findings.filter((f) => f.id === 'HAVOC-MA-003');
318
+ const passwordFinding = sensitiveFindings.find((f) => f.title.includes('password'));
319
+ expect(passwordFinding).toBeDefined();
320
+ expect(passwordFinding?.severity).toBe(Severity.Medium);
321
+ });
322
+
323
+ it('flags sensitive field "role" in $fillable', async () => {
324
+ const file = await parsedFixture('app/Models/User.php');
325
+ const analyzer = new MassAssignmentAnalyzer();
326
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
327
+ const roleFinding = findings.find((f) => f.title.includes('role'));
328
+ expect(roleFinding).toBeDefined();
329
+ });
330
+
331
+ it('does not flag safe model with clean $fillable', async () => {
332
+ const file = await parsedFixture('app/Models/SafeModel.php');
333
+ const analyzer = new MassAssignmentAnalyzer();
334
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
335
+ expect(findings).toHaveLength(0);
336
+ });
337
+
338
+ it('does not flag Post model with clean $fillable', async () => {
339
+ const file = await parsedFixture('app/Models/Post.php');
340
+ const analyzer = new MassAssignmentAnalyzer();
341
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
342
+ expect(findings).toHaveLength(0);
343
+ });
344
+
345
+ it('produces findings with CWE-915', async () => {
346
+ const file = await parsedFixture('app/Models/Comment.php');
347
+ const analyzer = new MassAssignmentAnalyzer();
348
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
349
+ for (const f of findings) {
350
+ expect(f.cwe).toBe('CWE-915');
351
+ }
352
+ });
353
+ });
354
+
355
+ // ─── SqlInjectionAnalyzer ────────────────────────────────────────────────────
356
+
357
+ describe('SqlInjectionAnalyzer', () => {
358
+ it('does not flag non-PHP files', async () => {
359
+ const file: ParsedFile = { path: 'resources/views/home.blade.php', content: 'whereRaw($x)', ast: null };
360
+ const analyzer = new SqlInjectionAnalyzer();
361
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
362
+ expect(findings).toHaveLength(0);
363
+ });
364
+
365
+ it('flags DB::select with string interpolation as HIGH', async () => {
366
+ const content = `<?php\n$result = DB::select("SELECT * FROM users WHERE name = '$name'");\n`;
367
+ const file: ParsedFile = { path: 'app/Services/UserService.php', content, ast: null };
368
+ const analyzer = new SqlInjectionAnalyzer();
369
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
370
+ expect(findings.some((f) => f.id === 'HAVOC-SQL-001')).toBe(true);
371
+ });
372
+
373
+ it('flags whereRaw with variable concatenation as HIGH', async () => {
374
+ const content = `<?php\n$users = User::whereRaw('status = ' . $status)->get();\n`;
375
+ const file: ParsedFile = { path: 'app/Http/Controllers/UserController.php', content, ast: null };
376
+ const analyzer = new SqlInjectionAnalyzer();
377
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
378
+ expect(findings.some((f) => f.id === 'HAVOC-SQL-001')).toBe(true);
379
+ });
380
+
381
+ it('does not flag whereRaw with ? binding', async () => {
382
+ const content = `<?php\n$users = User::whereRaw('age > ?', [$minAge])->get();\n`;
383
+ const file: ParsedFile = { path: 'app/Http/Controllers/UserController.php', content, ast: null };
384
+ const analyzer = new SqlInjectionAnalyzer();
385
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
386
+ expect(findings.filter((f) => f.id === 'HAVOC-SQL-001')).toHaveLength(0);
387
+ });
388
+
389
+ it('reports info for raw query without interpolation or bindings', async () => {
390
+ // DB::raw($query) — variable argument, no interpolation patterns, no binding → Info
391
+ const content = `<?php\n$order = DB::raw($column);\n`;
392
+ const file: ParsedFile = { path: 'app/Http/Controllers/FeedController.php', content, ast: null };
393
+ const analyzer = new SqlInjectionAnalyzer();
394
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
395
+ expect(findings.some((f) => f.id === 'HAVOC-SQL-002' && f.severity === Severity.Info)).toBe(true);
396
+ });
397
+
398
+ it('produces findings with CWE-89', async () => {
399
+ const content = `<?php\n$r = DB::select("SELECT * FROM t WHERE x = '$x'");\n`;
400
+ const file: ParsedFile = { path: 'app/Services/Service.php', content, ast: null };
401
+ const analyzer = new SqlInjectionAnalyzer();
402
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
403
+ for (const f of findings.filter((x) => x.id === 'HAVOC-SQL-001')) {
404
+ expect(f.cwe).toBe('CWE-89');
405
+ }
406
+ });
407
+
408
+ it('flags DB::statement with interpolation', async () => {
409
+ const content = `<?php\n$raw = DB::statement("UPDATE users SET role = '$role' WHERE id = $id");\n`;
410
+ const file: ParsedFile = { path: 'app/Services/RoleService.php', content, ast: null };
411
+ const analyzer = new SqlInjectionAnalyzer();
412
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
413
+ expect(findings.some((f) => f.id === 'HAVOC-SQL-001')).toBe(true);
414
+ });
415
+
416
+ it('does not flag safe queries file', async () => {
417
+ const file = await fixtureFile('sql/safe_queries.php');
418
+ file.path = 'app/Services/SafeService.php';
419
+ const analyzer = new SqlInjectionAnalyzer();
420
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
421
+ expect(findings.filter((f) => f.id === 'HAVOC-SQL-001')).toHaveLength(0);
422
+ });
423
+ });
424
+
425
+ // ─── XssSurfaceAnalyzer ───────────────────────────────────────────────────────
426
+
427
+ describe('XssSurfaceAnalyzer', () => {
428
+ it('does not flag escaped Blade output', async () => {
429
+ const content = '<p>{{ $user->name }}</p>';
430
+ const file: ParsedFile = { path: 'resources/views/show.blade.php', content, ast: null };
431
+ const analyzer = new XssSurfaceAnalyzer();
432
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
433
+ expect(findings).toHaveLength(0);
434
+ });
435
+
436
+ it('flags user-controlled unescaped output as HIGH', async () => {
437
+ const content = '<div>{!! $comment->body !!}</div>';
438
+ const file: ParsedFile = { path: 'resources/views/comments/show.blade.php', content, ast: null };
439
+ const analyzer = new XssSurfaceAnalyzer();
440
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
441
+ expect(findings.some((f) => f.severity === Severity.High)).toBe(true);
442
+ });
443
+
444
+ it('does not flag e()-wrapped output', async () => {
445
+ const content = '<div>{!! e($userInput) !!}</div>';
446
+ const file: ParsedFile = { path: 'resources/views/show.blade.php', content, ast: null };
447
+ const analyzer = new XssSurfaceAnalyzer();
448
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
449
+ expect(findings).toHaveLength(0);
450
+ });
451
+
452
+ it('does not flag config() values in unescaped output', async () => {
453
+ const content = '<div>{!! config("app.footer") !!}</div>';
454
+ const file: ParsedFile = { path: 'resources/views/layout.blade.php', content, ast: null };
455
+ const analyzer = new XssSurfaceAnalyzer();
456
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
457
+ expect(findings).toHaveLength(0);
458
+ });
459
+
460
+ it('does not flag mail template unescaped output', async () => {
461
+ const content = '<div>{!! $mailContent !!}</div>';
462
+ const file: ParsedFile = { path: 'resources/views/mail/welcome.blade.php', content, ast: null };
463
+ const analyzer = new XssSurfaceAnalyzer();
464
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
465
+ expect(findings).toHaveLength(0);
466
+ });
467
+
468
+ it('does not flag non-Blade files', async () => {
469
+ const file: ParsedFile = { path: 'app/Http/Controllers/HomeController.php', content: '{!! $x !!}', ast: null };
470
+ const analyzer = new XssSurfaceAnalyzer();
471
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
472
+ expect(findings).toHaveLength(0);
473
+ });
474
+
475
+ it('produces findings with CWE-79', async () => {
476
+ const content = '<div>{!! $text !!}</div>';
477
+ const file: ParsedFile = { path: 'resources/views/show.blade.php', content, ast: null };
478
+ const analyzer = new XssSurfaceAnalyzer();
479
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
480
+ for (const f of findings) {
481
+ expect(f.cwe).toBe('CWE-79');
482
+ }
483
+ });
484
+
485
+ it('does not flag htmlspecialchars-wrapped output', async () => {
486
+ const content = '<div>{!! htmlspecialchars($input) !!}</div>';
487
+ const file: ParsedFile = { path: 'resources/views/show.blade.php', content, ast: null };
488
+ const analyzer = new XssSurfaceAnalyzer();
489
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
490
+ expect(findings).toHaveLength(0);
491
+ });
492
+
493
+ it('flags $message variable as user-controlled', async () => {
494
+ const content = '<div>{!! $message !!}</div>';
495
+ const file: ParsedFile = { path: 'resources/views/show.blade.php', content, ast: null };
496
+ const analyzer = new XssSurfaceAnalyzer();
497
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
498
+ expect(findings.some((f) => f.severity === Severity.High)).toBe(true);
499
+ });
500
+ });
501
+
502
+ // ─── IdorAnalyzer ────────────────────────────────────────────────────────────
503
+
504
+ describe('IdorAnalyzer', () => {
505
+ it('does not flag routes without multiple params', async () => {
506
+ const content = `Route::get('/posts/{post}', [PostController::class, 'show']);`;
507
+ const file: ParsedFile = { path: 'routes/web.php', content, ast: null };
508
+ const analyzer = new IdorAnalyzer();
509
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
510
+ expect(findings).toHaveLength(0);
511
+ });
512
+
513
+ it('flags nested routes with multiple params', async () => {
514
+ const content = `Route::get('/users/{userId}/posts/{postId}', [PostController::class, 'show']);`;
515
+ const file: ParsedFile = { path: 'routes/web.php', content, ast: null };
516
+ const analyzer = new IdorAnalyzer();
517
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
518
+ expect(findings.some((f) => f.id === 'HAVOC-IDOR-001')).toBe(true);
519
+ });
520
+
521
+ it('flags nested resource routes (dots)', async () => {
522
+ const content = `Route::resource('users.posts', PostController::class);`;
523
+ const file: ParsedFile = { path: 'routes/web.php', content, ast: null };
524
+ const analyzer = new IdorAnalyzer();
525
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
526
+ expect(findings.some((f) => f.id === 'HAVOC-IDOR-002')).toBe(true);
527
+ });
528
+
529
+ it('does not flag non-route files', async () => {
530
+ const file: ParsedFile = {
531
+ path: 'app/Http/Controllers/PostController.php',
532
+ content: '/users/{userId}/posts/{postId}',
533
+ ast: null,
534
+ };
535
+ const analyzer = new IdorAnalyzer();
536
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
537
+ expect(findings).toHaveLength(0);
538
+ });
539
+
540
+ it('produces findings with CWE-639', async () => {
541
+ const content = `Route::get('/users/{userId}/posts/{postId}', [PostController::class, 'show']);`;
542
+ const file: ParsedFile = { path: 'routes/web.php', content, ast: null };
543
+ const analyzer = new IdorAnalyzer();
544
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
545
+ for (const f of findings) {
546
+ expect(f.cwe).toBe('CWE-639');
547
+ }
548
+ });
549
+
550
+ it('does not flag route files with ownership checks in controllers', async () => {
551
+ const routeFile: ParsedFile = {
552
+ path: 'routes/web.php',
553
+ content: `Route::get('/users/{userId}/posts/{postId}', [PostController::class, 'show']);`,
554
+ ast: null,
555
+ };
556
+ const controllerFile: ParsedFile = {
557
+ path: 'app/Http/Controllers/PostController.php',
558
+ content: `$post = $user->posts()->findOrFail($postId); Gate::allows('view', $post);`,
559
+ ast: null,
560
+ };
561
+ const analyzer = new IdorAnalyzer();
562
+ const findings = await analyzer.analyze([routeFile, controllerFile], DEFAULT_CONFIG);
563
+ // With ownership check, should not flag
564
+ expect(findings).toHaveLength(0);
565
+ });
566
+ });
567
+
568
+ // ─── DependencyAuditAnalyzer ──────────────────────────────────────────────────
569
+
570
+ describe('DependencyAuditAnalyzer', () => {
571
+ it('skips scan when no composer.json in file list', async () => {
572
+ const analyzer = new DependencyAuditAnalyzer();
573
+ const findings = await analyzer.analyze([], DEFAULT_CONFIG);
574
+ expect(findings).toHaveLength(0);
575
+ });
576
+
577
+ it('uses injected mock runner and returns critical finding', async () => {
578
+ const mockRunner = async (_path: string) => ({
579
+ advisories: {
580
+ 'vendor/package': [{
581
+ advisoryId: 'ADV-001', packageName: 'vendor/package',
582
+ title: 'Remote Code Execution', link: 'https://example.com/adv-001',
583
+ cve: 'CVE-2024-1234', severity: 'critical',
584
+ affectedVersions: '>=1.0.0,<1.5.0', sources: [], reportedAt: '2024-01-01',
585
+ }],
586
+ },
587
+ abandoned: {},
588
+ });
589
+ const analyzer = new DependencyAuditAnalyzer(mockRunner);
590
+ const file: ParsedFile = { path: 'composer.json', content: '{}', ast: null };
591
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
592
+ expect(findings.some((f) => f.id === 'HAVOC-DEP-001')).toBe(true);
593
+ expect(findings[0].severity).toBe(Severity.Critical);
594
+ });
595
+
596
+ it('maps high severity CVE correctly', async () => {
597
+ const mockRunner = async (_path: string) => ({
598
+ advisories: {
599
+ 'some/package': [{
600
+ advisoryId: 'ADV-002', packageName: 'some/package',
601
+ title: 'SQL Injection', link: 'https://example.com/adv-002',
602
+ cve: null, severity: 'high', affectedVersions: '>=2.0.0',
603
+ sources: [], reportedAt: '2024-02-01',
604
+ }],
605
+ },
606
+ abandoned: {},
607
+ });
608
+ const analyzer = new DependencyAuditAnalyzer(mockRunner);
609
+ const file: ParsedFile = { path: 'composer.json', content: '{}', ast: null };
610
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
611
+ expect(findings[0].severity).toBe(Severity.High);
612
+ });
613
+
614
+ it('maps medium severity correctly', async () => {
615
+ const mockRunner = async (_path: string) => ({
616
+ advisories: {
617
+ 'a/b': [{
618
+ advisoryId: 'ADV-003', packageName: 'a/b', title: 'CSRF',
619
+ link: 'https://example.com', cve: null, severity: 'medium',
620
+ affectedVersions: '>=1.0', sources: [], reportedAt: '2024-01-01',
621
+ }],
622
+ },
623
+ abandoned: {},
624
+ });
625
+ const analyzer = new DependencyAuditAnalyzer(mockRunner);
626
+ const file: ParsedFile = { path: 'composer.json', content: '{}', ast: null };
627
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
628
+ expect(findings[0].severity).toBe(Severity.Medium);
629
+ });
630
+
631
+ it('returns info finding when composer runner returns null', async () => {
632
+ const mockRunner = async (_path: string) => null;
633
+ const analyzer = new DependencyAuditAnalyzer(mockRunner);
634
+ const file: ParsedFile = { path: 'composer.json', content: '{}', ast: null };
635
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
636
+ expect(findings.some((f) => f.id === 'HAVOC-DEP-002')).toBe(true);
637
+ });
638
+
639
+ it('returns empty findings for no advisories', async () => {
640
+ const mockRunner = async (_path: string) => ({ advisories: {}, abandoned: {} });
641
+ const analyzer = new DependencyAuditAnalyzer(mockRunner);
642
+ const file: ParsedFile = { path: 'composer.json', content: '{}', ast: null };
643
+ const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
644
+ expect(findings).toHaveLength(0);
645
+ });
646
+ });
647
+
648
+ // ─── Security Grade ───────────────────────────────────────────────────────────
649
+
650
+ describe('calculateSecurityGrade', () => {
651
+ it('returns A for zero findings', async () => {
652
+ const { calculateSecurityGrade } = await import('../src/index.js');
653
+ expect(calculateSecurityGrade([])).toBe('A');
654
+ });
655
+
656
+ it('returns F for multiple critical findings', async () => {
657
+ const { calculateSecurityGrade } = await import('../src/index.js');
658
+ const findings = Array.from({ length: 3 }, () => ({ severity: Severity.Critical }));
659
+ expect(calculateSecurityGrade(findings)).toBe('F');
660
+ });
661
+
662
+ it('returns B for a single high finding', async () => {
663
+ const { calculateSecurityGrade } = await import('../src/index.js');
664
+ expect(calculateSecurityGrade([{ severity: Severity.High }])).toBe('B');
665
+ });
666
+
667
+ it('returns A for info-only findings', async () => {
668
+ const { calculateSecurityGrade } = await import('../src/index.js');
669
+ const findings = Array.from({ length: 10 }, () => ({ severity: Severity.Info }));
670
+ expect(calculateSecurityGrade(findings)).toBe('A');
671
+ });
672
+
673
+ it('returns D for 3 high findings', async () => {
674
+ const { calculateSecurityGrade } = await import('../src/index.js');
675
+ const findings = Array.from({ length: 3 }, () => ({ severity: Severity.High }));
676
+ expect(calculateSecurityGrade(findings)).toBe('D');
677
+ });
678
+ });