@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,259 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { createRequire } from 'node:module';
3
+ import { resolve, relative } from 'node:path';
4
+ import { glob } from 'glob';
5
+ import { ParsedFile } from '../types/index.js';
6
+
7
+ interface PhpNode {
8
+ kind: string;
9
+ name?: string | PhpNode;
10
+ isAbstract?: boolean;
11
+ isFinal?: boolean;
12
+ isStatic?: boolean;
13
+ visibility?: string;
14
+ body?: PhpNode[] | PhpNode | null;
15
+ children?: PhpNode[];
16
+ arguments?: PhpNode[];
17
+ items?: PhpNode[];
18
+ properties?: PhpNode[];
19
+ value?: PhpNode | string | number | boolean | null;
20
+ loc?: { start: { line: number }; end: { line: number } };
21
+ extends?: PhpNode | null;
22
+ traits?: PhpNode[];
23
+ key?: PhpNode | null;
24
+ }
25
+
26
+ export interface PhpMethodInfo {
27
+ name: string;
28
+ visibility: 'public' | 'protected' | 'private';
29
+ isStatic: boolean;
30
+ line: number;
31
+ bodyText: string;
32
+ parameters: string[];
33
+ }
34
+
35
+ export interface PhpPropertyInfo {
36
+ name: string;
37
+ visibility: 'public' | 'protected' | 'private';
38
+ isStatic: boolean;
39
+ line: number;
40
+ initializer?: string;
41
+ }
42
+
43
+ export interface PhpClassInfo {
44
+ name: string;
45
+ isAbstract: boolean;
46
+ isTrait: boolean;
47
+ methods: PhpMethodInfo[];
48
+ properties: PhpPropertyInfo[];
49
+ traitUses: string[];
50
+ importedTraits: string[];
51
+ parent?: string;
52
+ line: number;
53
+ }
54
+
55
+ export interface ParsedPhpFile extends ParsedFile {
56
+ classes: PhpClassInfo[];
57
+ useStatements: string[];
58
+ namespace?: string;
59
+ }
60
+
61
+ export const PHP_FILE_PATTERNS = {
62
+ CONTROLLERS: 'app/Http/Controllers/**/*.php',
63
+ MODELS: 'app/Models/**/*.php',
64
+ ROUTES_WEB: 'routes/web.php',
65
+ ROUTES_API: 'routes/api.php',
66
+ ROUTES_ALL: 'routes/**/*.php',
67
+ BLADE_TEMPLATES: 'resources/views/**/*.blade.php',
68
+ CONFIG: 'config/**/*.php',
69
+ ALL_PHP: '**/*.php',
70
+ } as const;
71
+
72
+ function nodeName(n: string | PhpNode | undefined | null): string {
73
+ if (!n) return '';
74
+ if (typeof n === 'string') return n;
75
+ if (typeof n === 'object') {
76
+ if (typeof n.name === 'string') return n.name;
77
+ if (typeof n.name === 'object') return nodeName(n.name);
78
+ }
79
+ return '';
80
+ }
81
+
82
+ function normalizeVisibility(v: string | undefined): 'public' | 'protected' | 'private' {
83
+ if (v === 'protected') return 'protected';
84
+ if (v === 'private') return 'private';
85
+ return 'public';
86
+ }
87
+
88
+ export class PhpParser {
89
+ private engine: { parseCode(code: string, filename: string): PhpNode };
90
+
91
+ constructor() {
92
+ const _require = createRequire(import.meta.url);
93
+ const mod = _require('php-parser');
94
+ const Engine = mod.Engine ?? mod.default?.Engine ?? mod;
95
+ this.engine = new Engine({
96
+ parser: { extractDoc: true, suppressErrors: true, php7: true },
97
+ ast: { withPositions: true },
98
+ }) as { parseCode(code: string, filename: string): PhpNode };
99
+ }
100
+
101
+ async parseFile(filePath: string, rootDir?: string): Promise<ParsedPhpFile> {
102
+ const content = await readFile(filePath, 'utf-8').catch(() => '');
103
+ const relativePath = rootDir ? relative(rootDir, filePath) : filePath;
104
+
105
+ try {
106
+ const ast = this.engine.parseCode(content, filePath);
107
+ const extracted = this.extractFromAst(ast, content);
108
+ return { path: relativePath, content, ast, ...extracted };
109
+ } catch {
110
+ return { path: relativePath, content, ast: null, classes: [], useStatements: [] };
111
+ }
112
+ }
113
+
114
+ async parseDirectory(dir: string, exclude: string[] = []): Promise<ParsedPhpFile[]> {
115
+ const absDir = resolve(dir);
116
+ const files = await glob(`${absDir}/${PHP_FILE_PATTERNS.ALL_PHP}`, {
117
+ ignore: exclude.map((e) => resolve(absDir, e)),
118
+ nodir: true,
119
+ });
120
+ return Promise.all(files.map((f) => this.parseFile(f, absDir)));
121
+ }
122
+
123
+ async parseByPattern(rootDir: string, pattern: string, exclude: string[] = []): Promise<ParsedPhpFile[]> {
124
+ const absRoot = resolve(rootDir);
125
+ const files = await glob(`${absRoot}/${pattern}`, {
126
+ ignore: exclude.map((e) => resolve(absRoot, e)),
127
+ nodir: true,
128
+ });
129
+ return Promise.all(files.map((f) => this.parseFile(f, absRoot)));
130
+ }
131
+
132
+ async parseBladeFiles(rootDir: string): Promise<ParsedFile[]> {
133
+ const absRoot = resolve(rootDir);
134
+ const files = await glob(`${absRoot}/${PHP_FILE_PATTERNS.BLADE_TEMPLATES}`, { nodir: true });
135
+ return Promise.all(
136
+ files.map(async (f) => ({
137
+ path: relative(absRoot, f),
138
+ content: await readFile(f, 'utf-8').catch(() => ''),
139
+ ast: null,
140
+ })),
141
+ );
142
+ }
143
+
144
+ private extractFromAst(ast: PhpNode, source: string): { classes: PhpClassInfo[]; useStatements: string[]; namespace?: string } {
145
+ const classes: PhpClassInfo[] = [];
146
+ const useStatements: string[] = [];
147
+ let namespace: string | undefined;
148
+
149
+ const processNodes = (nodes: PhpNode[]) => {
150
+ for (const node of nodes) {
151
+ if (node.kind === 'namespace') {
152
+ namespace = nodeName(node.name);
153
+ processNodes((node.children ?? []) as PhpNode[]);
154
+ } else if (node.kind === 'usegroup') {
155
+ for (const item of (node.items ?? []) as PhpNode[]) {
156
+ const name = nodeName(item.name);
157
+ if (name) useStatements.push(name);
158
+ }
159
+ } else if (node.kind === 'class' || node.kind === 'trait') {
160
+ classes.push(this.extractClass(node, source));
161
+ }
162
+ }
163
+ };
164
+
165
+ processNodes((ast.children ?? []) as PhpNode[]);
166
+ return { classes, useStatements, namespace };
167
+ }
168
+
169
+ private extractClass(node: PhpNode, source: string): PhpClassInfo {
170
+ const name = nodeName(node.name);
171
+ const isAbstract = node.isAbstract ?? false;
172
+ const isTrait = node.kind === 'trait';
173
+ const line = node.loc?.start.line ?? 1;
174
+ const methods: PhpMethodInfo[] = [];
175
+ const properties: PhpPropertyInfo[] = [];
176
+ const importedTraits: string[] = [];
177
+
178
+ const body = (node.body ?? []) as PhpNode[];
179
+ for (const member of body) {
180
+ if (member.kind === 'method') {
181
+ methods.push(this.extractMethod(member, source));
182
+ } else if (member.kind === 'propertystatement') {
183
+ const prop = this.extractProperty(member);
184
+ if (prop) properties.push(prop);
185
+ } else if (member.kind === 'traituse') {
186
+ for (const trait of (member.traits ?? []) as PhpNode[]) {
187
+ importedTraits.push(nodeName(trait));
188
+ }
189
+ }
190
+ }
191
+
192
+ const parentNode = node.extends;
193
+ const parent = parentNode ? nodeName((parentNode as PhpNode).name ?? parentNode) : undefined;
194
+
195
+ return { name, isAbstract, isTrait, methods, properties, traitUses: [], importedTraits, parent, line };
196
+ }
197
+
198
+ private extractMethod(node: PhpNode, source: string): PhpMethodInfo {
199
+ const name = nodeName(node.name);
200
+ const visibility = normalizeVisibility(node.visibility);
201
+ const isStatic = node.isStatic ?? false;
202
+ const line = node.loc?.start.line ?? 1;
203
+
204
+ const bodyNode = node.body as PhpNode | null;
205
+ let bodyText = '';
206
+ if (bodyNode?.loc) {
207
+ const lines = source.split('\n');
208
+ bodyText = lines.slice(bodyNode.loc.start.line - 1, bodyNode.loc.end.line).join('\n');
209
+ }
210
+
211
+ const params = ((node as PhpNode & { arguments?: PhpNode[] }).arguments ?? [])
212
+ .map((p) => nodeName(p.name));
213
+
214
+ return { name, visibility, isStatic, line, bodyText, parameters: params };
215
+ }
216
+
217
+ private extractProperty(node: PhpNode): PhpPropertyInfo | null {
218
+ // php-parser uses "properties" for propertystatement children
219
+ const props = (node.properties ?? node.items ?? []) as PhpNode[];
220
+ if (props.length === 0) return null;
221
+
222
+ const first = props[0];
223
+ const name = nodeName(first.name);
224
+ if (!name) return null;
225
+
226
+ const visibility = normalizeVisibility(node.visibility);
227
+ const isStatic = node.isStatic ?? false;
228
+ const line = node.loc?.start.line ?? 1;
229
+ const initializer = first.value != null ? this.stringifyNode(first.value as PhpNode) : undefined;
230
+
231
+ return { name, visibility, isStatic, line, initializer };
232
+ }
233
+
234
+ private stringifyNode(node: PhpNode): string {
235
+ if (!node) return '';
236
+ switch (node.kind) {
237
+ case 'string': return `"${node.value as string}"`;
238
+ case 'number': return String(node.value);
239
+ case 'boolean': return String(node.value);
240
+ case 'nullkeyword': return 'null';
241
+ case 'array': {
242
+ const items = (node.items ?? []) as PhpNode[];
243
+ const parts = items.map((item) => {
244
+ // php-parser wraps array values in "entry" nodes
245
+ const entryVal = (item as PhpNode & { value?: PhpNode }).value ?? item;
246
+ const val = this.stringifyNode(entryVal as PhpNode);
247
+ const entryKey = (item as PhpNode & { key?: PhpNode | null }).key;
248
+ return entryKey ? `${this.stringifyNode(entryKey)} => ${val}` : val;
249
+ });
250
+ return `[${parts.join(', ')}]`;
251
+ }
252
+ case 'identifier':
253
+ case 'name':
254
+ return nodeName(node);
255
+ default:
256
+ return '';
257
+ }
258
+ }
259
+ }
@@ -0,0 +1,384 @@
1
+ /**
2
+ * HAVOC Route Parser
3
+ *
4
+ * Parses Laravel route files (routes/web.php, routes/api.php, etc.) to extract
5
+ * route definitions and their associated middleware — including group-inherited
6
+ * middleware. This data is used by the AuthorizationCoverageAnalyzer to eliminate
7
+ * false positives where auth/authorization is handled at the route level.
8
+ */
9
+
10
+ import { readFile } from 'node:fs/promises';
11
+ import { resolve, relative } from 'node:path';
12
+ import { glob } from 'glob';
13
+
14
+ // ─── Types ────────────────────────────────────────────────────────────────────
15
+
16
+ export interface RouteInfo {
17
+ /** HTTP method (GET, POST, PUT, PATCH, DELETE, ANY) or 'resource' */
18
+ method: string;
19
+ /** Route URI */
20
+ uri: string;
21
+ /** Controller class name (short, without namespace) */
22
+ controller: string;
23
+ /** Controller action/method name */
24
+ action: string;
25
+ /** All effective middleware applied to this route (including group inheritance) */
26
+ middleware: string[];
27
+ }
28
+
29
+ export interface ParsedRouteFile {
30
+ /** Source file path (relative) */
31
+ path: string;
32
+ /** All extracted route definitions */
33
+ routes: RouteInfo[];
34
+ }
35
+
36
+ // ─── Middleware Classification ────────────────────────────────────────────────
37
+
38
+ /** Middleware that provide AUTHORIZATION (i.e., permission checks — clears finding) */
39
+ const AUTHORIZATION_MIDDLEWARE_PATTERNS = [
40
+ /^can:/,
41
+ /^permission:/,
42
+ /^role:/,
43
+ ];
44
+
45
+ /** Middleware that provide AUTHENTICATION only (downgrades severity but does not clear) */
46
+ const AUTHENTICATION_MIDDLEWARE_PATTERNS = [
47
+ /^auth$/,
48
+ /^auth:/,
49
+ /^sanctum$/,
50
+ /^verified$/,
51
+ ];
52
+
53
+ export function isAuthorizationMiddleware(mw: string): boolean {
54
+ return AUTHORIZATION_MIDDLEWARE_PATTERNS.some((re) => re.test(mw.trim()));
55
+ }
56
+
57
+ export function isAuthenticationMiddleware(mw: string): boolean {
58
+ return AUTHENTICATION_MIDDLEWARE_PATTERNS.some((re) => re.test(mw.trim()));
59
+ }
60
+
61
+ export function hasAuthorizationMiddleware(middleware: string[]): boolean {
62
+ return middleware.some(isAuthorizationMiddleware);
63
+ }
64
+
65
+ export function hasAuthenticationMiddleware(middleware: string[]): boolean {
66
+ return middleware.some(isAuthenticationMiddleware);
67
+ }
68
+
69
+ // ─── Webhook Detection ────────────────────────────────────────────────────────
70
+
71
+ const WEBHOOK_PATTERNS = [
72
+ /\$request->header\s*\(\s*['"]X-[^'"]*Signature/i,
73
+ /hash_hmac\s*\(/,
74
+ /hash_equals\s*\(/,
75
+ /SignatureVerif/i,
76
+ /validateSignature/i,
77
+ /verifySignature/i,
78
+ /StripeSignature/i,
79
+ /WebhookSignature/i,
80
+ ];
81
+
82
+ export function isWebhookHandler(bodyText: string): boolean {
83
+ return WEBHOOK_PATTERNS.some((re) => re.test(bodyText));
84
+ }
85
+
86
+ // ─── Internal Helpers ─────────────────────────────────────────────────────────
87
+
88
+ /**
89
+ * Parses middleware from a middleware() call argument string.
90
+ * Handles both string and array forms:
91
+ * 'auth'
92
+ * ['auth', 'can:view-admin', 'verified']
93
+ */
94
+ function parseMiddlewareArg(arg: string): string[] {
95
+ arg = arg.trim();
96
+
97
+ // Array form: ['auth', 'can:view-admin']
98
+ if (arg.startsWith('[')) {
99
+ const inner = arg.slice(1, arg.lastIndexOf(']'));
100
+ return inner.match(/['"]([^'"]+)['"]/g)?.map((s) => s.replace(/['"]/g, '')) ?? [];
101
+ }
102
+
103
+ // String form: 'auth' or "auth"
104
+ const m = arg.match(/^['"]([^'"]+)['"]$/);
105
+ if (m) return [m[1]];
106
+
107
+ return [];
108
+ }
109
+
110
+ /**
111
+ * Extract the controller class name (short name) and action from a route action string.
112
+ *
113
+ * Supported formats:
114
+ * [PostController::class, 'index']
115
+ * 'PostController@index'
116
+ * PostController::class (resource)
117
+ */
118
+ function parseControllerAction(actionStr: string): { controller: string; action: string } | null {
119
+ actionStr = actionStr.trim();
120
+
121
+ // Array syntax: [PostController::class, 'index']
122
+ const arrayMatch = actionStr.match(/\[?\s*([A-Za-z_\\]+)(?:::class)?\s*,\s*['"]([^'"]+)['"]\s*\]?/);
123
+ if (arrayMatch) {
124
+ const parts = arrayMatch[1].split('\\');
125
+ return { controller: parts[parts.length - 1], action: arrayMatch[2] };
126
+ }
127
+
128
+ // String syntax: 'PostController@index'
129
+ const atMatch = actionStr.match(/['"]?([A-Za-z_\\]+)@([A-Za-z_]+)['"]?/);
130
+ if (atMatch) {
131
+ const parts = atMatch[1].split('\\');
132
+ return { controller: parts[parts.length - 1], action: atMatch[2] };
133
+ }
134
+
135
+ // Resource: PostController::class (no explicit action)
136
+ const resourceMatch = actionStr.match(/([A-Za-z_\\]+)::class/);
137
+ if (resourceMatch) {
138
+ const parts = resourceMatch[1].split('\\');
139
+ return { controller: parts[parts.length - 1], action: '*' };
140
+ }
141
+
142
+ return null;
143
+ }
144
+
145
+ /** Standard Laravel resource controller actions */
146
+ const RESOURCE_ACTIONS = ['index', 'create', 'store', 'show', 'edit', 'update', 'destroy'];
147
+
148
+ // ─── Route Parser ─────────────────────────────────────────────────────────────
149
+
150
+ /**
151
+ * Regex-based Laravel route file parser.
152
+ *
153
+ * Handles:
154
+ * - Route::get/post/put/patch/delete/any('uri', ...)
155
+ * - Route::resource('resource', Controller::class)
156
+ * - Route::middleware([...])->group(function () { ... })
157
+ * - Nested groups
158
+ */
159
+ export class RouteParser {
160
+ /**
161
+ * Parse a single route file's content and return extracted routes.
162
+ */
163
+ parseContent(content: string, filePath: string = ''): ParsedRouteFile {
164
+ const routes: RouteInfo[] = [];
165
+ this.parseBlock(content, [], routes);
166
+ return { path: filePath, routes };
167
+ }
168
+
169
+ /**
170
+ * Parse a route file from disk.
171
+ */
172
+ async parseFile(filePath: string, rootDir?: string): Promise<ParsedRouteFile> {
173
+ const content = await readFile(filePath, 'utf-8').catch(() => '');
174
+ const relativePath = rootDir ? relative(rootDir, filePath) : filePath;
175
+ return this.parseContent(content, relativePath);
176
+ }
177
+
178
+ /**
179
+ * Parse all route files in a Laravel project's routes/ directory.
180
+ */
181
+ async parseDirectory(projectRoot: string): Promise<ParsedRouteFile[]> {
182
+ const absRoot = resolve(projectRoot);
183
+ const files = await glob(`${absRoot}/routes/**/*.php`, { nodir: true });
184
+ return Promise.all(files.map((f) => this.parseFile(f, absRoot)));
185
+ }
186
+
187
+ /**
188
+ * Recursively parse a block of PHP route code, tracking inherited middleware.
189
+ */
190
+ private parseBlock(content: string, inheritedMiddleware: string[], routes: RouteInfo[]): void {
191
+ let pos = 0;
192
+ while (pos < content.length) {
193
+ const routeIdx = content.indexOf('Route::', pos);
194
+ if (routeIdx === -1) break;
195
+
196
+ const chain = this.extractChain(content, routeIdx);
197
+ if (!chain) { pos = routeIdx + 7; continue; }
198
+
199
+ const { text, end } = chain;
200
+
201
+ // Check if this is a group
202
+ const groupMiddleware = this.extractGroupMiddleware(text);
203
+ if (groupMiddleware !== null) {
204
+ const groupBody = this.extractGroupBody(content, routeIdx);
205
+ if (groupBody) {
206
+ const combined = [...inheritedMiddleware, ...groupMiddleware];
207
+ this.parseBlock(groupBody.body, combined, routes);
208
+ pos = groupBody.end;
209
+ continue;
210
+ }
211
+ }
212
+
213
+ const extracted = this.extractRoute(text, inheritedMiddleware);
214
+ if (extracted.length > 0) {
215
+ routes.push(...extracted);
216
+ }
217
+
218
+ pos = end;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Extract the full chained call text starting at a Route:: position.
224
+ * Stops at semicolons not inside brackets/parens.
225
+ */
226
+ private extractChain(content: string, start: number): { text: string; end: number } | null {
227
+ let depth = 0;
228
+ let i = start;
229
+ let inString = false;
230
+ let stringChar = '';
231
+
232
+ while (i < content.length) {
233
+ const ch = content[i];
234
+
235
+ if (inString) {
236
+ if (ch === stringChar && content[i - 1] !== '\\') inString = false;
237
+ } else if (ch === '"' || ch === "'") {
238
+ inString = true;
239
+ stringChar = ch;
240
+ } else if (ch === '(' || ch === '[') {
241
+ depth++;
242
+ } else if (ch === ')' || ch === ']') {
243
+ depth--;
244
+ } else if (ch === '{' && depth === 0) {
245
+ // Entering a closure body — stop here (group handling takes over)
246
+ break;
247
+ } else if (ch === '}' && depth < 0) {
248
+ break;
249
+ } else if (ch === ';' && depth === 0) {
250
+ return { text: content.slice(start, i + 1), end: i + 1 };
251
+ }
252
+ i++;
253
+ }
254
+
255
+ if (i > start) {
256
+ return { text: content.slice(start, i), end: i };
257
+ }
258
+ return null;
259
+ }
260
+
261
+ /**
262
+ * If the chain is a Route::middleware(...)->group(...), extract the middleware list.
263
+ * Returns null if this is not a group.
264
+ */
265
+ private extractGroupMiddleware(chain: string): string[] | null {
266
+ const mwMatch = chain.match(/Route::(?:\w+\([^)]*\)->)*middleware\s*\(([^)]+)\)/);
267
+ if (!mwMatch) return null;
268
+ if (!chain.includes('->group(')) return null;
269
+ return parseMiddlewareArg(mwMatch[1]);
270
+ }
271
+
272
+ /**
273
+ * Find the closure body of a ->group(function () { ... }) call.
274
+ */
275
+ private extractGroupBody(content: string, routeStart: number): { body: string; end: number } | null {
276
+ const groupIdx = content.indexOf('->group(', routeStart);
277
+ if (groupIdx === -1) return null;
278
+
279
+ const braceIdx = content.indexOf('{', groupIdx);
280
+ if (braceIdx === -1) return null;
281
+
282
+ let depth = 1;
283
+ let i = braceIdx + 1;
284
+ while (i < content.length && depth > 0) {
285
+ const ch = content[i];
286
+ if (ch === '{') depth++;
287
+ else if (ch === '}') depth--;
288
+ i++;
289
+ }
290
+
291
+ return { body: content.slice(braceIdx + 1, i - 1), end: i };
292
+ }
293
+
294
+ /**
295
+ * Extract route info from a single Route::method() chain.
296
+ */
297
+ private extractRoute(chain: string, inheritedMiddleware: string[]): RouteInfo[] {
298
+ const results: RouteInfo[] = [];
299
+
300
+ // Extract inline middleware from ->middleware(...) in this chain
301
+ const inlineMiddleware: string[] = [];
302
+ const mwRegex = /->middleware\s*\(([^)]+)\)/g;
303
+ let mwMatch;
304
+ while ((mwMatch = mwRegex.exec(chain)) !== null) {
305
+ inlineMiddleware.push(...parseMiddlewareArg(mwMatch[1]));
306
+ }
307
+ const allMiddleware = [...inheritedMiddleware, ...inlineMiddleware];
308
+
309
+ // Route::resource('posts', PostController::class)
310
+ const resourceMatch = chain.match(/Route::resource\s*\(\s*['"]([^'"]+)['"]\s*,\s*([^,)]+)/);
311
+ if (resourceMatch) {
312
+ const uri = resourceMatch[1];
313
+ const ctrlRaw = resourceMatch[2].trim();
314
+ const parsed = parseControllerAction(ctrlRaw)
315
+ ?? { controller: ctrlRaw.replace(/::class.*/, '').split('\\').pop() ?? ctrlRaw, action: '*' };
316
+ for (const action of RESOURCE_ACTIONS) {
317
+ results.push({ method: 'resource', uri, controller: parsed.controller, action, middleware: allMiddleware });
318
+ }
319
+ return results;
320
+ }
321
+
322
+ // Route::apiResource(...)
323
+ const apiResourceMatch = chain.match(/Route::apiResource\s*\(\s*['"]([^'"]+)['"]\s*,\s*([^,)]+)/);
324
+ if (apiResourceMatch) {
325
+ const uri = apiResourceMatch[1];
326
+ const ctrlRaw = apiResourceMatch[2].trim();
327
+ const parsed = parseControllerAction(ctrlRaw)
328
+ ?? { controller: ctrlRaw.replace(/::class.*/, '').split('\\').pop() ?? ctrlRaw, action: '*' };
329
+ for (const action of ['index', 'store', 'show', 'update', 'destroy']) {
330
+ results.push({ method: 'apiResource', uri, controller: parsed.controller, action, middleware: allMiddleware });
331
+ }
332
+ return results;
333
+ }
334
+
335
+ // Route::get/post/put/patch/delete/any(...)
336
+ const verbMatch = chain.match(/Route::(get|post|put|patch|delete|any|options)\s*\(\s*['"]([^'"]+)['"]\s*,\s*([\s\S]+?)\s*\)/);
337
+ if (verbMatch) {
338
+ const method = verbMatch[1].toUpperCase();
339
+ const uri = verbMatch[2];
340
+ const actionStr = verbMatch[3];
341
+ const parsed = parseControllerAction(actionStr);
342
+ if (parsed) {
343
+ results.push({ method, uri, controller: parsed.controller, action: parsed.action, middleware: allMiddleware });
344
+ }
345
+ }
346
+
347
+ return results;
348
+ }
349
+ }
350
+
351
+ // ─── Route Map Helpers ────────────────────────────────────────────────────────
352
+
353
+ /**
354
+ * Build a lookup map: "ControllerName::action" → RouteInfo[]
355
+ * Used by analyzers for O(1) route lookups.
356
+ */
357
+ export function buildRouteMap(routeFiles: ParsedRouteFile[]): Map<string, RouteInfo[]> {
358
+ const map = new Map<string, RouteInfo[]>();
359
+
360
+ for (const file of routeFiles) {
361
+ for (const route of file.routes) {
362
+ const key = `${route.controller}::${route.action}`;
363
+ if (!map.has(key)) map.set(key, []);
364
+ map.get(key)!.push(route);
365
+ }
366
+ }
367
+
368
+ return map;
369
+ }
370
+
371
+ /**
372
+ * Get all middleware for a given controller action from the route map.
373
+ * Returns an empty array if the action is not found in any route.
374
+ */
375
+ export function getRouteMiddleware(
376
+ routeMap: Map<string, RouteInfo[]>,
377
+ controller: string,
378
+ action: string,
379
+ ): string[] {
380
+ const key = `${controller}::${action}`;
381
+ const routes = routeMap.get(key) ?? [];
382
+ if (routes.length === 0) return [];
383
+ return [...new Set(routes.flatMap((r) => r.middleware))];
384
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * HAVOC Rule Definitions
3
+ *
4
+ * Rules define patterns and thresholds for each analyzer.
5
+ * Full rule set implemented in Sprint 2.
6
+ */
7
+
8
+ export interface Rule {
9
+ id: string;
10
+ analyzer: string;
11
+ description: string;
12
+ cwe?: string;
13
+ }
14
+
15
+ // Rule registry — populated in Sprint 2
16
+ export const rules: Rule[] = [];