@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.
- package/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-test.log +22 -0
- package/dist/analyzers/AuthorizationCoverageAnalyzer.d.ts +7 -0
- package/dist/analyzers/AuthorizationCoverageAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/AuthorizationCoverageAnalyzer.js +100 -0
- package/dist/analyzers/AuthorizationCoverageAnalyzer.js.map +1 -0
- package/dist/analyzers/CredentialExposureAnalyzer.d.ts +11 -0
- package/dist/analyzers/CredentialExposureAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/CredentialExposureAnalyzer.js +262 -0
- package/dist/analyzers/CredentialExposureAnalyzer.js.map +1 -0
- package/dist/analyzers/DependencyAuditAnalyzer.d.ts +28 -0
- package/dist/analyzers/DependencyAuditAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/DependencyAuditAnalyzer.js +107 -0
- package/dist/analyzers/DependencyAuditAnalyzer.js.map +1 -0
- package/dist/analyzers/EncryptionAnalyzer.d.ts +7 -0
- package/dist/analyzers/EncryptionAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/EncryptionAnalyzer.js +170 -0
- package/dist/analyzers/EncryptionAnalyzer.js.map +1 -0
- package/dist/analyzers/FileUploadAnalyzer.d.ts +8 -0
- package/dist/analyzers/FileUploadAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/FileUploadAnalyzer.js +193 -0
- package/dist/analyzers/FileUploadAnalyzer.js.map +1 -0
- package/dist/analyzers/IdorAnalyzer.d.ts +7 -0
- package/dist/analyzers/IdorAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/IdorAnalyzer.js +91 -0
- package/dist/analyzers/IdorAnalyzer.js.map +1 -0
- package/dist/analyzers/MassAssignmentAnalyzer.d.ts +7 -0
- package/dist/analyzers/MassAssignmentAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/MassAssignmentAnalyzer.js +90 -0
- package/dist/analyzers/MassAssignmentAnalyzer.js.map +1 -0
- package/dist/analyzers/PrivilegeEscalationAnalyzer.d.ts +7 -0
- package/dist/analyzers/PrivilegeEscalationAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/PrivilegeEscalationAnalyzer.js +217 -0
- package/dist/analyzers/PrivilegeEscalationAnalyzer.js.map +1 -0
- package/dist/analyzers/RateLimitAnalyzer.d.ts +7 -0
- package/dist/analyzers/RateLimitAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/RateLimitAnalyzer.js +151 -0
- package/dist/analyzers/RateLimitAnalyzer.js.map +1 -0
- package/dist/analyzers/SessionSecurityAnalyzer.d.ts +10 -0
- package/dist/analyzers/SessionSecurityAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/SessionSecurityAnalyzer.js +295 -0
- package/dist/analyzers/SessionSecurityAnalyzer.js.map +1 -0
- package/dist/analyzers/SqlInjectionAnalyzer.d.ts +7 -0
- package/dist/analyzers/SqlInjectionAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/SqlInjectionAnalyzer.js +77 -0
- package/dist/analyzers/SqlInjectionAnalyzer.js.map +1 -0
- package/dist/analyzers/XssSurfaceAnalyzer.d.ts +7 -0
- package/dist/analyzers/XssSurfaceAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/XssSurfaceAnalyzer.js +100 -0
- package/dist/analyzers/XssSurfaceAnalyzer.js.map +1 -0
- package/dist/analyzers/index.d.ts +13 -0
- package/dist/analyzers/index.d.ts.map +1 -0
- package/dist/analyzers/index.js +13 -0
- package/dist/analyzers/index.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +139 -0
- package/dist/index.js.map +1 -0
- package/dist/parsers/PhpParser.d.ts +56 -0
- package/dist/parsers/PhpParser.d.ts.map +1 -0
- package/dist/parsers/PhpParser.js +193 -0
- package/dist/parsers/PhpParser.js.map +1 -0
- package/dist/parsers/RouteParser.d.ts +87 -0
- package/dist/parsers/RouteParser.d.ts.map +1 -0
- package/dist/parsers/RouteParser.js +327 -0
- package/dist/parsers/RouteParser.js.map +1 -0
- package/dist/rules/index.d.ts +14 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/index.js +9 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/types/index.d.ts +137 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +13 -0
- package/dist/types/index.js.map +1 -0
- package/package.json +30 -0
- package/package.json.bak +27 -0
- package/src/analyzers/AuthorizationCoverageAnalyzer.ts +213 -0
- package/src/analyzers/CredentialExposureAnalyzer.ts +312 -0
- package/src/analyzers/DependencyAuditAnalyzer.ts +135 -0
- package/src/analyzers/EncryptionAnalyzer.ts +195 -0
- package/src/analyzers/FileUploadAnalyzer.ts +239 -0
- package/src/analyzers/IdorAnalyzer.ts +118 -0
- package/src/analyzers/InsecureDeserializationAnalyzer.ts +212 -0
- package/src/analyzers/MassAssignmentAnalyzer.ts +105 -0
- package/src/analyzers/OpenRedirectAnalyzer.ts +149 -0
- package/src/analyzers/PrivilegeEscalationAnalyzer.ts +258 -0
- package/src/analyzers/RateLimitAnalyzer.ts +195 -0
- package/src/analyzers/SecurityHeaderAnalyzer.ts +263 -0
- package/src/analyzers/SessionSecurityAnalyzer.ts +342 -0
- package/src/analyzers/SqlInjectionAnalyzer.ts +99 -0
- package/src/analyzers/XssSurfaceAnalyzer.ts +112 -0
- package/src/analyzers/exclusions.ts +87 -0
- package/src/analyzers/index.ts +15 -0
- package/src/index.ts +226 -0
- package/src/parsers/PhpParser.ts +259 -0
- package/src/parsers/RouteParser.ts +384 -0
- package/src/rules/index.ts +16 -0
- package/src/types/index.ts +164 -0
- package/tests/EncryptionAnalyzer.test.ts +137 -0
- package/tests/PrivilegeEscalationAnalyzer.test.ts +141 -0
- package/tests/RateLimitAnalyzer.test.ts +112 -0
- package/tests/analyzers.test.ts +678 -0
- package/tests/auth-coverage-route-aware.test.ts +294 -0
- package/tests/credential-exposure.test.ts +142 -0
- package/tests/file-upload.test.ts +141 -0
- package/tests/fixtures/app/Http/Controllers/AdminController.php +19 -0
- package/tests/fixtures/app/Http/Controllers/PostController.php +49 -0
- package/tests/fixtures/app/Http/Controllers/PublicController.php +17 -0
- package/tests/fixtures/app/Models/Comment.php +11 -0
- package/tests/fixtures/app/Models/OpenModel.php +11 -0
- package/tests/fixtures/app/Models/Post.php +14 -0
- package/tests/fixtures/app/Models/SafeModel.php +10 -0
- package/tests/fixtures/app/Models/User.php +15 -0
- package/tests/fixtures/blade/mail.blade.php +8 -0
- package/tests/fixtures/blade/safe.blade.php +12 -0
- package/tests/fixtures/blade/vulnerable.blade.php +12 -0
- package/tests/fixtures/controllers/AdminController.php +19 -0
- package/tests/fixtures/controllers/PostController.php +49 -0
- package/tests/fixtures/controllers/PublicController.php +17 -0
- package/tests/fixtures/deserialization/safe.php +32 -0
- package/tests/fixtures/deserialization/unsafe.php +60 -0
- package/tests/fixtures/models/Comment.php +11 -0
- package/tests/fixtures/models/OpenModel.php +11 -0
- package/tests/fixtures/models/Post.php +14 -0
- package/tests/fixtures/models/SafeModel.php +10 -0
- package/tests/fixtures/models/User.php +15 -0
- package/tests/fixtures/redirect/safe.php +38 -0
- package/tests/fixtures/redirect/unsafe.php +39 -0
- package/tests/fixtures/routes/api.php +9 -0
- package/tests/fixtures/routes/web.php +18 -0
- package/tests/fixtures/security-headers/app/Http/Middleware/SecurityHeaders.php +24 -0
- package/tests/fixtures/security-headers/app/Providers/AppServiceProvider.php +16 -0
- package/tests/fixtures/sql/safe_queries.php +7 -0
- package/tests/fixtures/sql/vulnerable_queries.php +7 -0
- package/tests/new-analyzers.test.ts +373 -0
- package/tests/route-parser.test.ts +257 -0
- package/tests/scanner.test.ts +82 -0
- package/tests/session-security.test.ts +161 -0
- package/tests/types.test.ts +29 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,373 @@
|
|
|
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 { InsecureDeserializationAnalyzer } from '../src/analyzers/InsecureDeserializationAnalyzer.js';
|
|
6
|
+
import { OpenRedirectAnalyzer } from '../src/analyzers/OpenRedirectAnalyzer.js';
|
|
7
|
+
import { SecurityHeaderAnalyzer } from '../src/analyzers/SecurityHeaderAnalyzer.js';
|
|
8
|
+
import { DEFAULT_CONFIG } from '../src/index.js';
|
|
9
|
+
import type { ParsedFile } from '../src/types/index.js';
|
|
10
|
+
import { Severity } from '../src/types/index.js';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const FIXTURES = resolve(__dirname, 'fixtures');
|
|
14
|
+
|
|
15
|
+
async function fixture(relativePath: string): Promise<ParsedFile> {
|
|
16
|
+
const absPath = resolve(FIXTURES, relativePath);
|
|
17
|
+
const content = await readFile(absPath, 'utf-8');
|
|
18
|
+
return { path: relativePath, content, ast: null };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeFile(path: string, content: string): ParsedFile {
|
|
22
|
+
return { path, content, ast: null };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── InsecureDeserializationAnalyzer ─────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
describe('InsecureDeserializationAnalyzer', () => {
|
|
28
|
+
const analyzer = new InsecureDeserializationAnalyzer();
|
|
29
|
+
|
|
30
|
+
it('has correct name', () => {
|
|
31
|
+
expect(analyzer.name).toBe('InsecureDeserializationAnalyzer');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('has non-empty description', () => {
|
|
35
|
+
expect(analyzer.description.length).toBeGreaterThan(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns empty array for empty file list', async () => {
|
|
39
|
+
const result = await analyzer.analyze([], DEFAULT_CONFIG);
|
|
40
|
+
expect(result).toHaveLength(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('detects CRITICAL unserialize() with $request->input()', async () => {
|
|
44
|
+
const file = makeFile('app/Http/Controllers/Foo.php',
|
|
45
|
+
`<?php $data = unserialize($request->input('session'));`);
|
|
46
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
47
|
+
const found = findings.find((f) => f.id === 'HAVOC-DESER-001');
|
|
48
|
+
expect(found).toBeDefined();
|
|
49
|
+
expect(found!.severity).toBe(Severity.Critical);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('detects CRITICAL unserialize() with $_GET', async () => {
|
|
53
|
+
const file = makeFile('app/Foo.php', `<?php $obj = unserialize($_GET['data']);`);
|
|
54
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
55
|
+
const found = findings.find((f) => f.id === 'HAVOC-DESER-001');
|
|
56
|
+
expect(found).toBeDefined();
|
|
57
|
+
expect(found!.severity).toBe(Severity.Critical);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('detects MEDIUM unserialize() without allowed_classes', async () => {
|
|
61
|
+
const file = makeFile('app/Foo.php', `<?php $data = unserialize($cached);`);
|
|
62
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
63
|
+
const found = findings.find((f) => f.id === 'HAVOC-DESER-004');
|
|
64
|
+
expect(found).toBeDefined();
|
|
65
|
+
expect(found!.severity).toBe(Severity.Medium);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('does NOT flag unserialize() with allowed_classes option', async () => {
|
|
69
|
+
const file = makeFile('app/Foo.php',
|
|
70
|
+
`<?php $data = unserialize($cached, ['allowed_classes' => false]);`);
|
|
71
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
72
|
+
expect(findings.filter((f) => f.id.startsWith('HAVOC-DESER'))).toHaveLength(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('detects HIGH eval() usage', async () => {
|
|
76
|
+
const file = makeFile('app/Foo.php', `<?php eval($template);`);
|
|
77
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
78
|
+
const found = findings.find((f) => f.id === 'HAVOC-DESER-002');
|
|
79
|
+
expect(found).toBeDefined();
|
|
80
|
+
expect(found!.severity).toBe(Severity.High);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('detects HIGH create_function() usage', async () => {
|
|
84
|
+
const file = makeFile('app/Foo.php',
|
|
85
|
+
`<?php $fn = create_function('$x', 'return $x * 2;');`);
|
|
86
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
87
|
+
const found = findings.find((f) => f.id === 'HAVOC-DESER-003');
|
|
88
|
+
expect(found).toBeDefined();
|
|
89
|
+
expect(found!.severity).toBe(Severity.High);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('detects HIGH preg_replace() with /e modifier', async () => {
|
|
93
|
+
const file = makeFile('app/Foo.php',
|
|
94
|
+
`<?php preg_replace('/pattern/e', 'strtoupper("$1")', $content);`);
|
|
95
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
96
|
+
const found = findings.find((f) => f.id === 'HAVOC-DESER-005');
|
|
97
|
+
expect(found).toBeDefined();
|
|
98
|
+
expect(found!.severity).toBe(Severity.High);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('detects HIGH assert() with string argument', async () => {
|
|
102
|
+
const file = makeFile('app/Foo.php', `<?php assert('strlen($str) > 0');`);
|
|
103
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
104
|
+
const found = findings.find((f) => f.id === 'HAVOC-DESER-006');
|
|
105
|
+
expect(found).toBeDefined();
|
|
106
|
+
expect(found!.severity).toBe(Severity.High);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('detects HIGH extract() with user input', async () => {
|
|
110
|
+
const file = makeFile('app/Foo.php', `<?php extract($request->all());`);
|
|
111
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
112
|
+
const found = findings.find((f) => f.id === 'HAVOC-DESER-007');
|
|
113
|
+
expect(found).toBeDefined();
|
|
114
|
+
expect(found!.severity).toBe(Severity.High);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('does NOT flag assert() with boolean expression', async () => {
|
|
118
|
+
const file = makeFile('app/Foo.php', `<?php assert($value > 0);`);
|
|
119
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
120
|
+
expect(findings.filter((f) => f.id === 'HAVOC-DESER-006')).toHaveLength(0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('skips blade.php files', async () => {
|
|
124
|
+
const file = makeFile('resources/views/foo.blade.php',
|
|
125
|
+
`<?php unserialize($_GET['x']); eval($x); ?>`);
|
|
126
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
127
|
+
expect(findings).toHaveLength(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('detects all issues in unsafe fixture', async () => {
|
|
131
|
+
const file = await fixture('deserialization/unsafe.php');
|
|
132
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
133
|
+
expect(findings.length).toBeGreaterThanOrEqual(7);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('returns no findings for safe fixture', async () => {
|
|
137
|
+
const file = await fixture('deserialization/safe.php');
|
|
138
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
139
|
+
expect(findings).toHaveLength(0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('findings include file path and line number', async () => {
|
|
143
|
+
const file = makeFile('app/Foo.php', `<?php\neval($x);\n`);
|
|
144
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
145
|
+
expect(findings[0].file).toBe('app/Foo.php');
|
|
146
|
+
expect(findings[0].line).toBe(2);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('findings include CWE identifier', async () => {
|
|
150
|
+
const file = makeFile('app/Foo.php', `<?php eval($x);`);
|
|
151
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
152
|
+
expect(findings[0].cwe).toBeDefined();
|
|
153
|
+
expect(findings[0].cwe).toMatch(/^CWE-/);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ─── OpenRedirectAnalyzer ─────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
describe('OpenRedirectAnalyzer', () => {
|
|
160
|
+
const analyzer = new OpenRedirectAnalyzer();
|
|
161
|
+
|
|
162
|
+
it('has correct name', () => {
|
|
163
|
+
expect(analyzer.name).toBe('OpenRedirectAnalyzer');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('has non-empty description', () => {
|
|
167
|
+
expect(analyzer.description.length).toBeGreaterThan(0);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('returns empty array for empty file list', async () => {
|
|
171
|
+
const result = await analyzer.analyze([], DEFAULT_CONFIG);
|
|
172
|
+
expect(result).toHaveLength(0);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('detects HIGH: redirect($request->input())', async () => {
|
|
176
|
+
const file = makeFile('app/Http/Controllers/Foo.php',
|
|
177
|
+
`<?php return redirect($request->input('url'));`);
|
|
178
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
179
|
+
const found = findings.find((f) => f.id === 'HAVOC-REDIRECT-001');
|
|
180
|
+
expect(found).toBeDefined();
|
|
181
|
+
expect(found!.severity).toBe(Severity.High);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('detects HIGH: Redirect::to($request->get())', async () => {
|
|
185
|
+
const file = makeFile('app/Http/Controllers/Foo.php',
|
|
186
|
+
`<?php return \\Redirect::to($request->get('redirect'));`);
|
|
187
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
188
|
+
const found = findings.find((f) => f.id === 'HAVOC-REDIRECT-001');
|
|
189
|
+
expect(found).toBeDefined();
|
|
190
|
+
expect(found!.severity).toBe(Severity.High);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('detects HIGH: redirect($_GET[]) with common param', async () => {
|
|
194
|
+
const file = makeFile('app/Foo.php', `<?php return redirect($_GET['next']);`);
|
|
195
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
196
|
+
const found = findings.find((f) => f.id === 'HAVOC-REDIRECT-001');
|
|
197
|
+
expect(found).toBeDefined();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('detects MEDIUM: redirect($url) — named variable', async () => {
|
|
201
|
+
const file = makeFile('app/Http/Controllers/Foo.php',
|
|
202
|
+
`<?php return redirect($url);`);
|
|
203
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
204
|
+
const found = findings.find((f) => f.id === 'HAVOC-REDIRECT-002');
|
|
205
|
+
expect(found).toBeDefined();
|
|
206
|
+
expect(found!.severity).toBe(Severity.Medium);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('detects MEDIUM: Redirect::to($redirect)', async () => {
|
|
210
|
+
const file = makeFile('app/Http/Controllers/Foo.php',
|
|
211
|
+
`<?php return \\Redirect::to($redirect);`);
|
|
212
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
213
|
+
const found = findings.find((f) => f.id === 'HAVOC-REDIRECT-002');
|
|
214
|
+
expect(found).toBeDefined();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('does NOT flag redirect()->intended()', async () => {
|
|
218
|
+
const file = makeFile('app/Http/Controllers/Foo.php',
|
|
219
|
+
`<?php return redirect()->intended('/dashboard');`);
|
|
220
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
221
|
+
expect(findings).toHaveLength(0);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('does NOT flag back()', async () => {
|
|
225
|
+
const file = makeFile('app/Http/Controllers/Foo.php',
|
|
226
|
+
`<?php return back();`);
|
|
227
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
228
|
+
expect(findings).toHaveLength(0);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('does NOT flag redirect() to string literal', async () => {
|
|
232
|
+
const file = makeFile('app/Http/Controllers/Foo.php',
|
|
233
|
+
`<?php return redirect('/home');`);
|
|
234
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
235
|
+
expect(findings).toHaveLength(0);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('does NOT flag redirect(route())', async () => {
|
|
239
|
+
const file = makeFile('app/Http/Controllers/Foo.php',
|
|
240
|
+
`<?php return redirect(route('dashboard'));`);
|
|
241
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
242
|
+
expect(findings).toHaveLength(0);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('skips blade.php files', async () => {
|
|
246
|
+
const file = makeFile('resources/views/foo.blade.php',
|
|
247
|
+
`<?php return redirect($request->input('url')); ?>`);
|
|
248
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
249
|
+
expect(findings).toHaveLength(0);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('detects issues in unsafe fixture', async () => {
|
|
253
|
+
const file = await fixture('redirect/unsafe.php');
|
|
254
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
255
|
+
expect(findings.length).toBeGreaterThanOrEqual(3);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('returns no findings for safe fixture', async () => {
|
|
259
|
+
const file = await fixture('redirect/safe.php');
|
|
260
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
261
|
+
expect(findings).toHaveLength(0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('findings include CWE-601', async () => {
|
|
265
|
+
const file = makeFile('app/Foo.php',
|
|
266
|
+
`<?php return redirect($request->input('url'));`);
|
|
267
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
268
|
+
expect(findings[0].cwe).toBe('CWE-601');
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ─── SecurityHeaderAnalyzer ───────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
describe('SecurityHeaderAnalyzer', () => {
|
|
275
|
+
const analyzer = new SecurityHeaderAnalyzer();
|
|
276
|
+
|
|
277
|
+
it('has correct name', () => {
|
|
278
|
+
expect(analyzer.name).toBe('SecurityHeaderAnalyzer');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('has non-empty description', () => {
|
|
282
|
+
expect(analyzer.description.length).toBeGreaterThan(0);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('returns empty array for empty file list', async () => {
|
|
286
|
+
const result = await analyzer.analyze([], DEFAULT_CONFIG);
|
|
287
|
+
expect(result).toHaveLength(0);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('detects missing X-Frame-Options', async () => {
|
|
291
|
+
const file = makeFile('app/Http/Kernel.php', `<?php // no headers`);
|
|
292
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
293
|
+
const found = findings.find((f) => f.title.includes('X-Frame-Options'));
|
|
294
|
+
expect(found).toBeDefined();
|
|
295
|
+
expect(found!.severity).toBe(Severity.Medium);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('detects missing X-Content-Type-Options', async () => {
|
|
299
|
+
const file = makeFile('app/Http/Kernel.php', `<?php // no headers`);
|
|
300
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
301
|
+
const found = findings.find((f) => f.title.includes('X-Content-Type-Options'));
|
|
302
|
+
expect(found).toBeDefined();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('detects missing Strict-Transport-Security', async () => {
|
|
306
|
+
const file = makeFile('app/Http/Kernel.php', `<?php // no headers`);
|
|
307
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
308
|
+
const found = findings.find((f) => f.title.includes('Strict-Transport-Security'));
|
|
309
|
+
expect(found).toBeDefined();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('detects missing Content-Security-Policy', async () => {
|
|
313
|
+
const file = makeFile('app/Http/Kernel.php', `<?php // no headers`);
|
|
314
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
315
|
+
const found = findings.find((f) => f.title.includes('Content-Security-Policy'));
|
|
316
|
+
expect(found).toBeDefined();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('detects missing HTTPS enforcement', async () => {
|
|
320
|
+
const file = makeFile('app/Providers/AppServiceProvider.php',
|
|
321
|
+
`<?php class AppServiceProvider extends ServiceProvider { public function boot() {} }`);
|
|
322
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
323
|
+
const found = findings.find((f) => f.id === 'HAVOC-HEADER-003');
|
|
324
|
+
expect(found).toBeDefined();
|
|
325
|
+
expect(found!.severity).toBe(Severity.Medium);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('does NOT flag X-Frame-Options when header is set', async () => {
|
|
329
|
+
const file = makeFile('app/Http/Middleware/Security.php',
|
|
330
|
+
`<?php $response->headers->set('X-Frame-Options', 'SAMEORIGIN');`);
|
|
331
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
332
|
+
const found = findings.find((f) => f.title.includes('X-Frame-Options'));
|
|
333
|
+
expect(found).toBeUndefined();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('does NOT flag HTTPS when URL::forceScheme is present', async () => {
|
|
337
|
+
const file = makeFile('app/Providers/AppServiceProvider.php',
|
|
338
|
+
`<?php URL::forceScheme('https');`);
|
|
339
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
340
|
+
const found = findings.find((f) => f.id === 'HAVOC-HEADER-003');
|
|
341
|
+
expect(found).toBeUndefined();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('returns no security header findings when all headers are set', async () => {
|
|
345
|
+
const middleware = await fixture('security-headers/app/Http/Middleware/SecurityHeaders.php');
|
|
346
|
+
const provider = await fixture('security-headers/app/Providers/AppServiceProvider.php');
|
|
347
|
+
const findings = await analyzer.analyze([middleware, provider], DEFAULT_CONFIG);
|
|
348
|
+
const headerFindings = findings.filter((f) => f.id.startsWith('HAVOC-HEADER'));
|
|
349
|
+
expect(headerFindings).toHaveLength(0);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('missing Permissions-Policy flagged as INFO', async () => {
|
|
353
|
+
const file = makeFile('app/Http/Middleware/Security.php', `<?php
|
|
354
|
+
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
|
|
355
|
+
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
|
356
|
+
$response->headers->set('Strict-Transport-Security', 'max-age=31536000');
|
|
357
|
+
$response->headers->set('X-XSS-Protection', '1; mode=block');
|
|
358
|
+
$response->headers->set('Content-Security-Policy', "default-src 'self'");
|
|
359
|
+
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
360
|
+
URL::forceScheme('https');
|
|
361
|
+
`);
|
|
362
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
363
|
+
const found = findings.find((f) => f.title.includes('Permissions-Policy'));
|
|
364
|
+
expect(found).toBeDefined();
|
|
365
|
+
expect(found!.severity).toBe(Severity.Info);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('findings include file path', async () => {
|
|
369
|
+
const file = makeFile('app/Http/Kernel.php', `<?php // bare kernel`);
|
|
370
|
+
const findings = await analyzer.analyze([file], DEFAULT_CONFIG);
|
|
371
|
+
expect(findings[0]?.file).toBe('app/Http/Kernel.php');
|
|
372
|
+
});
|
|
373
|
+
});
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { resolve, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import {
|
|
5
|
+
RouteParser,
|
|
6
|
+
buildRouteMap,
|
|
7
|
+
getRouteMiddleware,
|
|
8
|
+
isAuthorizationMiddleware,
|
|
9
|
+
isAuthenticationMiddleware,
|
|
10
|
+
isWebhookHandler,
|
|
11
|
+
} from '../src/parsers/RouteParser.js';
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const FIXTURES = resolve(__dirname, 'fixtures');
|
|
15
|
+
|
|
16
|
+
// ─── Middleware Classification ────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
describe('isAuthorizationMiddleware', () => {
|
|
19
|
+
it('detects can: prefix', () => {
|
|
20
|
+
expect(isAuthorizationMiddleware('can:view-admin')).toBe(true);
|
|
21
|
+
expect(isAuthorizationMiddleware('can:manage-team')).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('detects permission: prefix (Spatie)', () => {
|
|
25
|
+
expect(isAuthorizationMiddleware('permission:edit-posts')).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('detects role: prefix (Spatie)', () => {
|
|
29
|
+
expect(isAuthorizationMiddleware('role:admin')).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns false for auth middleware', () => {
|
|
33
|
+
expect(isAuthorizationMiddleware('auth')).toBe(false);
|
|
34
|
+
expect(isAuthorizationMiddleware('auth:sanctum')).toBe(false);
|
|
35
|
+
expect(isAuthorizationMiddleware('verified')).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('isAuthenticationMiddleware', () => {
|
|
40
|
+
it('detects plain auth', () => {
|
|
41
|
+
expect(isAuthenticationMiddleware('auth')).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('detects auth:sanctum', () => {
|
|
45
|
+
expect(isAuthenticationMiddleware('auth:sanctum')).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('detects verified', () => {
|
|
49
|
+
expect(isAuthenticationMiddleware('verified')).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns false for can: middleware', () => {
|
|
53
|
+
expect(isAuthenticationMiddleware('can:view-admin')).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ─── Webhook Detection ────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
describe('isWebhookHandler', () => {
|
|
60
|
+
it('detects X-Signature header check', () => {
|
|
61
|
+
const body = `$sig = $request->header('X-Stripe-Signature'); hash_equals($sig, $expected);`;
|
|
62
|
+
expect(isWebhookHandler(body)).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('detects hash_hmac', () => {
|
|
66
|
+
expect(isWebhookHandler('$computed = hash_hmac("sha256", $payload, $secret);')).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('detects hash_equals', () => {
|
|
70
|
+
expect(isWebhookHandler('return hash_equals($sig, $expected);')).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('classifies Stripe and X-Hub signatures correctly', () => {
|
|
74
|
+
expect(isWebhookHandler('use Stripe\\Webhook; $event = Webhook::constructEvent($payload, $sig, $secret);')).toBe(false);
|
|
75
|
+
expect(isWebhookHandler('$sig = $request->header(\'X-Hub-Signature\');')).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns false for plain controller body', () => {
|
|
79
|
+
expect(isWebhookHandler('return Post::all();')).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ─── RouteParser: Simple Routes ──────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
describe('RouteParser — simple routes', () => {
|
|
86
|
+
const parser = new RouteParser();
|
|
87
|
+
|
|
88
|
+
it('parses a GET route with array controller syntax', () => {
|
|
89
|
+
const content = `Route::get('/dashboard', [DashboardController::class, 'index']);`;
|
|
90
|
+
const result = parser.parseContent(content, 'routes/web.php');
|
|
91
|
+
expect(result.routes).toHaveLength(1);
|
|
92
|
+
expect(result.routes[0].controller).toBe('DashboardController');
|
|
93
|
+
expect(result.routes[0].action).toBe('index');
|
|
94
|
+
expect(result.routes[0].method).toBe('GET');
|
|
95
|
+
expect(result.routes[0].middleware).toHaveLength(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('parses a POST route with @syntax', () => {
|
|
99
|
+
const content = `Route::post('/login', 'AuthController@login');`;
|
|
100
|
+
const result = parser.parseContent(content);
|
|
101
|
+
expect(result.routes).toHaveLength(1);
|
|
102
|
+
expect(result.routes[0].controller).toBe('AuthController');
|
|
103
|
+
expect(result.routes[0].action).toBe('login');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('parses inline middleware on a single route', () => {
|
|
107
|
+
const content = `Route::get('/profile', [ProfileController::class, 'show'])->middleware('auth');`;
|
|
108
|
+
const result = parser.parseContent(content);
|
|
109
|
+
expect(result.routes[0].middleware).toContain('auth');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('parses inline middleware array on a single route', () => {
|
|
113
|
+
const content = `Route::delete('/post/{id}', [PostController::class, 'destroy'])->middleware(['auth', 'can:delete-post']);`;
|
|
114
|
+
const result = parser.parseContent(content);
|
|
115
|
+
expect(result.routes[0].middleware).toContain('auth');
|
|
116
|
+
expect(result.routes[0].middleware).toContain('can:delete-post');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ─── RouteParser: Resource Routes ────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
describe('RouteParser — resource routes', () => {
|
|
123
|
+
const parser = new RouteParser();
|
|
124
|
+
|
|
125
|
+
it('expands resource route into 7 actions', () => {
|
|
126
|
+
const content = `Route::resource('posts', PostController::class);`;
|
|
127
|
+
const result = parser.parseContent(content);
|
|
128
|
+
expect(result.routes).toHaveLength(7);
|
|
129
|
+
const actions = result.routes.map((r) => r.action);
|
|
130
|
+
expect(actions).toContain('index');
|
|
131
|
+
expect(actions).toContain('store');
|
|
132
|
+
expect(actions).toContain('destroy');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('propagates middleware to all resource actions', () => {
|
|
136
|
+
const content = `Route::resource('posts', PostController::class)->middleware('can:manage-posts');`;
|
|
137
|
+
const result = parser.parseContent(content);
|
|
138
|
+
expect(result.routes.every((r) => r.middleware.includes('can:manage-posts'))).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('expands apiResource into 5 actions', () => {
|
|
142
|
+
const content = `Route::apiResource('articles', ArticleController::class);`;
|
|
143
|
+
const result = parser.parseContent(content);
|
|
144
|
+
expect(result.routes).toHaveLength(5);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ─── RouteParser: Grouped Routes ─────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
describe('RouteParser — grouped routes', () => {
|
|
151
|
+
const parser = new RouteParser();
|
|
152
|
+
|
|
153
|
+
it('inherits group middleware into child routes', () => {
|
|
154
|
+
const content = `
|
|
155
|
+
Route::middleware(['auth'])->group(function () {
|
|
156
|
+
Route::get('/dashboard', [DashboardController::class, 'index']);
|
|
157
|
+
Route::get('/profile', [ProfileController::class, 'show']);
|
|
158
|
+
});
|
|
159
|
+
`;
|
|
160
|
+
const result = parser.parseContent(content);
|
|
161
|
+
expect(result.routes).toHaveLength(2);
|
|
162
|
+
expect(result.routes.every((r) => r.middleware.includes('auth'))).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('handles nested middleware groups', () => {
|
|
166
|
+
const content = `
|
|
167
|
+
Route::middleware(['auth'])->group(function () {
|
|
168
|
+
Route::middleware(['can:admin'])->group(function () {
|
|
169
|
+
Route::get('/admin', [AdminController::class, 'dashboard']);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
`;
|
|
173
|
+
const result = parser.parseContent(content);
|
|
174
|
+
expect(result.routes).toHaveLength(1);
|
|
175
|
+
expect(result.routes[0].middleware).toContain('auth');
|
|
176
|
+
expect(result.routes[0].middleware).toContain('can:admin');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('does not apply group middleware to routes outside the group', () => {
|
|
180
|
+
const content = `
|
|
181
|
+
Route::middleware(['auth'])->group(function () {
|
|
182
|
+
Route::get('/protected', [ProtectedController::class, 'index']);
|
|
183
|
+
});
|
|
184
|
+
Route::get('/', [HomeController::class, 'index']);
|
|
185
|
+
`;
|
|
186
|
+
const result = parser.parseContent(content);
|
|
187
|
+
const home = result.routes.find((r) => r.controller === 'HomeController');
|
|
188
|
+
expect(home?.middleware).toHaveLength(0);
|
|
189
|
+
const protected_ = result.routes.find((r) => r.controller === 'ProtectedController');
|
|
190
|
+
expect(protected_?.middleware).toContain('auth');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ─── RouteParser: fixture file ────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
describe('RouteParser — fixture web.php', () => {
|
|
197
|
+
const parser = new RouteParser();
|
|
198
|
+
|
|
199
|
+
it('parses the fixture web.php without error', async () => {
|
|
200
|
+
const result = await parser.parseFile(resolve(FIXTURES, 'routes/web.php'), FIXTURES);
|
|
201
|
+
expect(result.routes.length).toBeGreaterThan(0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('extracts PostController routes from fixture', async () => {
|
|
205
|
+
const result = await parser.parseFile(resolve(FIXTURES, 'routes/web.php'), FIXTURES);
|
|
206
|
+
const postRoutes = result.routes.filter((r) => r.controller === 'PostController');
|
|
207
|
+
expect(postRoutes.length).toBeGreaterThan(0);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('fixture group routes have auth middleware', async () => {
|
|
211
|
+
const result = await parser.parseFile(resolve(FIXTURES, 'routes/web.php'), FIXTURES);
|
|
212
|
+
const postRoutes = result.routes.filter((r) => r.controller === 'PostController' && r.action !== 'showUserPost');
|
|
213
|
+
// Routes inside the auth group should have auth middleware
|
|
214
|
+
const groupRoutes = postRoutes.filter((r) => r.middleware.includes('auth'));
|
|
215
|
+
expect(groupRoutes.length).toBeGreaterThan(0);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ─── buildRouteMap / getRouteMiddleware ───────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
describe('buildRouteMap and getRouteMiddleware', () => {
|
|
222
|
+
const parser = new RouteParser();
|
|
223
|
+
|
|
224
|
+
it('builds a map from parsed route files', () => {
|
|
225
|
+
const content = `Route::get('/admin', [AdminController::class, 'dashboard'])->middleware(['auth', 'can:admin']);`;
|
|
226
|
+
const parsed = parser.parseContent(content);
|
|
227
|
+
const map = buildRouteMap([parsed]);
|
|
228
|
+
expect(map.has('AdminController::dashboard')).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('getRouteMiddleware returns middleware for known action', () => {
|
|
232
|
+
const content = `Route::get('/admin', [AdminController::class, 'dashboard'])->middleware(['auth', 'can:admin']);`;
|
|
233
|
+
const parsed = parser.parseContent(content);
|
|
234
|
+
const map = buildRouteMap([parsed]);
|
|
235
|
+
const mw = getRouteMiddleware(map, 'AdminController', 'dashboard');
|
|
236
|
+
expect(mw).toContain('auth');
|
|
237
|
+
expect(mw).toContain('can:admin');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('getRouteMiddleware returns empty array for unknown action', () => {
|
|
241
|
+
const parsed = parser.parseContent('');
|
|
242
|
+
const map = buildRouteMap([parsed]);
|
|
243
|
+
const mw = getRouteMiddleware(map, 'UnknownController', 'index');
|
|
244
|
+
expect(mw).toHaveLength(0);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('deduplicates middleware from multiple routes to the same action', () => {
|
|
248
|
+
const content = `
|
|
249
|
+
Route::get('/posts', [PostController::class, 'index'])->middleware('auth');
|
|
250
|
+
Route::get('/posts/all', [PostController::class, 'index'])->middleware('auth');
|
|
251
|
+
`;
|
|
252
|
+
const parsed = parser.parseContent(content);
|
|
253
|
+
const map = buildRouteMap([parsed]);
|
|
254
|
+
const mw = getRouteMiddleware(map, 'PostController', 'index');
|
|
255
|
+
expect(mw.filter((m) => m === 'auth').length).toBe(1);
|
|
256
|
+
});
|
|
257
|
+
});
|