@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,263 @@
|
|
|
1
|
+
import { Analyzer, Finding, HavocConfig, ParsedFile, Severity } from '../types/index.js';
|
|
2
|
+
|
|
3
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const ANALYZER_NAME = 'SecurityHeaderAnalyzer';
|
|
6
|
+
|
|
7
|
+
const FINDING_MISSING_HEADER_MEDIUM = 'HAVOC-HEADER-001';
|
|
8
|
+
const FINDING_MISSING_HEADER_LOW = 'HAVOC-HEADER-002';
|
|
9
|
+
const FINDING_NO_HTTPS = 'HAVOC-HEADER-003';
|
|
10
|
+
const FINDING_MISSING_HEADER_INFO = 'HAVOC-HEADER-004';
|
|
11
|
+
|
|
12
|
+
interface SecurityHeader {
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
cwe: string;
|
|
16
|
+
severity: Severity;
|
|
17
|
+
findingId: string;
|
|
18
|
+
recommendation: string;
|
|
19
|
+
/** Patterns that indicate this header is already set */
|
|
20
|
+
detectionPatterns: RegExp[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const SECURITY_HEADERS: SecurityHeader[] = [
|
|
24
|
+
{
|
|
25
|
+
name: 'X-Frame-Options',
|
|
26
|
+
description:
|
|
27
|
+
'The `X-Frame-Options` header (or `Content-Security-Policy: frame-ancestors`) is not set. ' +
|
|
28
|
+
'Without this header, the application may be vulnerable to clickjacking attacks.',
|
|
29
|
+
cwe: 'CWE-1021',
|
|
30
|
+
severity: Severity.Medium,
|
|
31
|
+
findingId: FINDING_MISSING_HEADER_MEDIUM,
|
|
32
|
+
recommendation:
|
|
33
|
+
'Add `X-Frame-Options: SAMEORIGIN` or use `Content-Security-Policy: frame-ancestors \'self\'` ' +
|
|
34
|
+
'in a security headers middleware.',
|
|
35
|
+
detectionPatterns: [
|
|
36
|
+
/X-Frame-Options/i,
|
|
37
|
+
/frame-ancestors/i,
|
|
38
|
+
/PreventClickjacking/i,
|
|
39
|
+
/FrameOptions/i,
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'X-Content-Type-Options',
|
|
44
|
+
description:
|
|
45
|
+
'The `X-Content-Type-Options: nosniff` header is not set. ' +
|
|
46
|
+
'Without it, browsers may MIME-sniff responses, potentially executing malicious content.',
|
|
47
|
+
cwe: 'CWE-693',
|
|
48
|
+
severity: Severity.Medium,
|
|
49
|
+
findingId: FINDING_MISSING_HEADER_MEDIUM,
|
|
50
|
+
recommendation:
|
|
51
|
+
'Add `X-Content-Type-Options: nosniff` in a security headers middleware.',
|
|
52
|
+
detectionPatterns: [
|
|
53
|
+
/X-Content-Type-Options/i,
|
|
54
|
+
/nosniff/i,
|
|
55
|
+
/ContentTypeOptions/i,
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'Strict-Transport-Security',
|
|
60
|
+
description:
|
|
61
|
+
'The `Strict-Transport-Security` (HSTS) header is not set. ' +
|
|
62
|
+
'Without HSTS, the application is vulnerable to SSL-stripping attacks.',
|
|
63
|
+
cwe: 'CWE-319',
|
|
64
|
+
severity: Severity.Medium,
|
|
65
|
+
findingId: FINDING_MISSING_HEADER_MEDIUM,
|
|
66
|
+
recommendation:
|
|
67
|
+
'Add `Strict-Transport-Security: max-age=31536000; includeSubDomains` ' +
|
|
68
|
+
'in a security headers middleware. Ensure HTTPS is enforced.',
|
|
69
|
+
detectionPatterns: [
|
|
70
|
+
/Strict-Transport-Security/i,
|
|
71
|
+
/HSTS/i,
|
|
72
|
+
/StrictTransportSecurity/i,
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'Content-Security-Policy',
|
|
77
|
+
description:
|
|
78
|
+
'No `Content-Security-Policy` header was detected. ' +
|
|
79
|
+
'CSP helps mitigate XSS and data-injection attacks.',
|
|
80
|
+
cwe: 'CWE-693',
|
|
81
|
+
severity: Severity.Medium,
|
|
82
|
+
findingId: FINDING_MISSING_HEADER_MEDIUM,
|
|
83
|
+
recommendation:
|
|
84
|
+
'Define a `Content-Security-Policy` header in a security headers middleware. ' +
|
|
85
|
+
'Start restrictively and relax only as needed.',
|
|
86
|
+
detectionPatterns: [
|
|
87
|
+
/Content-Security-Policy/i,
|
|
88
|
+
/ContentSecurityPolicy/i,
|
|
89
|
+
/CSP/,
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: 'X-XSS-Protection',
|
|
94
|
+
description:
|
|
95
|
+
'The `X-XSS-Protection` header is not set. ' +
|
|
96
|
+
'While modern browsers have deprecated this header in favour of CSP, it still provides ' +
|
|
97
|
+
'protection in older browsers.',
|
|
98
|
+
cwe: 'CWE-79',
|
|
99
|
+
severity: Severity.Low,
|
|
100
|
+
findingId: FINDING_MISSING_HEADER_LOW,
|
|
101
|
+
recommendation:
|
|
102
|
+
'Add `X-XSS-Protection: 1; mode=block` in a security headers middleware. ' +
|
|
103
|
+
'Note: this header is deprecated; prefer a strong Content-Security-Policy.',
|
|
104
|
+
detectionPatterns: [
|
|
105
|
+
/X-XSS-Protection/i,
|
|
106
|
+
/XssProtection/i,
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'Referrer-Policy',
|
|
111
|
+
description:
|
|
112
|
+
'No `Referrer-Policy` header was detected. ' +
|
|
113
|
+
'Without this header, full URLs (including query strings with sensitive data) may be sent ' +
|
|
114
|
+
'in the `Referer` header to third parties.',
|
|
115
|
+
cwe: 'CWE-116',
|
|
116
|
+
severity: Severity.Low,
|
|
117
|
+
findingId: FINDING_MISSING_HEADER_LOW,
|
|
118
|
+
recommendation:
|
|
119
|
+
'Add `Referrer-Policy: strict-origin-when-cross-origin` in a security headers middleware.',
|
|
120
|
+
detectionPatterns: [
|
|
121
|
+
/Referrer-Policy/i,
|
|
122
|
+
/ReferrerPolicy/i,
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: 'Permissions-Policy',
|
|
127
|
+
description:
|
|
128
|
+
'No `Permissions-Policy` header was detected. ' +
|
|
129
|
+
'This header lets you control which browser features (camera, microphone, geolocation) ' +
|
|
130
|
+
'the application may use.',
|
|
131
|
+
cwe: 'CWE-693',
|
|
132
|
+
severity: Severity.Info,
|
|
133
|
+
findingId: FINDING_MISSING_HEADER_INFO,
|
|
134
|
+
recommendation:
|
|
135
|
+
'Add a `Permissions-Policy` header to restrict unused browser features: ' +
|
|
136
|
+
'`Permissions-Policy: camera=(), microphone=(), geolocation=()`.',
|
|
137
|
+
detectionPatterns: [
|
|
138
|
+
/Permissions-Policy/i,
|
|
139
|
+
/Feature-Policy/i,
|
|
140
|
+
/PermissionsPolicy/i,
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
/** Patterns that indicate HTTPS enforcement */
|
|
146
|
+
const HTTPS_ENFORCEMENT_PATTERNS = [
|
|
147
|
+
/URL\s*::\s*forceScheme\s*\(\s*['"]https['"]/i,
|
|
148
|
+
/forceHttps\s*\(/i,
|
|
149
|
+
/FORCE_HTTPS/i,
|
|
150
|
+
/->forceScheme\s*\(\s*['"]https['"]/i,
|
|
151
|
+
/SecureHeaders/i,
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
/** Files that are relevant to check for security configuration */
|
|
155
|
+
const RELEVANT_FILE_PATTERNS = [
|
|
156
|
+
/app\/Http\/Middleware/,
|
|
157
|
+
/app\\Http\\Middleware/,
|
|
158
|
+
/bootstrap\/app\.php/,
|
|
159
|
+
/bootstrap\\app\.php/,
|
|
160
|
+
/app\/Http\/Kernel\.php/,
|
|
161
|
+
/app\\Http\\Kernel\.php/,
|
|
162
|
+
/app\/Providers\/AppServiceProvider\.php/,
|
|
163
|
+
/app\\Providers\\AppServiceProvider\.php/,
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
function isRelevantFile(path: string): boolean {
|
|
167
|
+
return RELEVANT_FILE_PATTERNS.some((re) => re.test(path));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── Analyzer ─────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
export class SecurityHeaderAnalyzer implements Analyzer {
|
|
173
|
+
readonly name = ANALYZER_NAME;
|
|
174
|
+
readonly description =
|
|
175
|
+
'Checks for missing security headers middleware and HTTPS enforcement in Laravel applications';
|
|
176
|
+
|
|
177
|
+
async analyze(files: ParsedFile[], _config: HavocConfig): Promise<Finding[]> {
|
|
178
|
+
const findings: Finding[] = [];
|
|
179
|
+
|
|
180
|
+
// Collect all content from relevant configuration files
|
|
181
|
+
const relevantContent = files
|
|
182
|
+
.filter((f) => isRelevantFile(f.path))
|
|
183
|
+
.map((f) => f.content)
|
|
184
|
+
.join('\n');
|
|
185
|
+
|
|
186
|
+
// Also check all PHP files for header-related content
|
|
187
|
+
const allPhpContent = files
|
|
188
|
+
.filter((f) => f.path.endsWith('.php') && !f.path.endsWith('.blade.php'))
|
|
189
|
+
.map((f) => f.content)
|
|
190
|
+
.join('\n');
|
|
191
|
+
|
|
192
|
+
const combinedContent = relevantContent + '\n' + allPhpContent;
|
|
193
|
+
|
|
194
|
+
// If no PHP files found, skip (not a PHP project)
|
|
195
|
+
if (files.filter((f) => f.path.endsWith('.php') && !f.path.endsWith('.blade.php')).length === 0) {
|
|
196
|
+
return findings;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Check each security header ─────────────────────────────────────────
|
|
200
|
+
for (const header of SECURITY_HEADERS) {
|
|
201
|
+
const isDetected = header.detectionPatterns.some((re) => re.test(combinedContent));
|
|
202
|
+
|
|
203
|
+
if (!isDetected) {
|
|
204
|
+
// Find the best file to attach the finding to
|
|
205
|
+
const kernelFile = files.find(
|
|
206
|
+
(f) =>
|
|
207
|
+
f.path.includes('Kernel.php') ||
|
|
208
|
+
f.path.includes('bootstrap/app.php') ||
|
|
209
|
+
f.path.includes('bootstrap\\app.php'),
|
|
210
|
+
);
|
|
211
|
+
const targetFile = kernelFile ?? files.find((f) => f.path.endsWith('.php'));
|
|
212
|
+
|
|
213
|
+
if (!targetFile) continue;
|
|
214
|
+
|
|
215
|
+
findings.push({
|
|
216
|
+
id: header.findingId,
|
|
217
|
+
severity: header.severity,
|
|
218
|
+
analyzer: ANALYZER_NAME,
|
|
219
|
+
title: `Missing security header: ${header.name}`,
|
|
220
|
+
description: header.description,
|
|
221
|
+
file: targetFile.path,
|
|
222
|
+
line: 1,
|
|
223
|
+
recommendation: header.recommendation,
|
|
224
|
+
cwe: header.cwe,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── Check HTTPS enforcement ────────────────────────────────────────────
|
|
230
|
+
const httpsEnforced = HTTPS_ENFORCEMENT_PATTERNS.some((re) => re.test(combinedContent));
|
|
231
|
+
|
|
232
|
+
if (!httpsEnforced) {
|
|
233
|
+
const serviceProviderFile = files.find(
|
|
234
|
+
(f) =>
|
|
235
|
+
f.path.includes('AppServiceProvider.php') ||
|
|
236
|
+
f.path.includes('Kernel.php') ||
|
|
237
|
+
f.path.includes('bootstrap/app.php') ||
|
|
238
|
+
f.path.includes('bootstrap\\app.php'),
|
|
239
|
+
);
|
|
240
|
+
const targetFile = serviceProviderFile ?? files.find((f) => f.path.endsWith('.php'));
|
|
241
|
+
|
|
242
|
+
if (targetFile) {
|
|
243
|
+
findings.push({
|
|
244
|
+
id: FINDING_NO_HTTPS,
|
|
245
|
+
severity: Severity.Medium,
|
|
246
|
+
analyzer: ANALYZER_NAME,
|
|
247
|
+
title: 'HTTPS enforcement not detected',
|
|
248
|
+
description:
|
|
249
|
+
'No HTTPS enforcement was detected in `AppServiceProvider` or application middleware. ' +
|
|
250
|
+
'Without enforcing HTTPS, users may access the application over unencrypted HTTP.',
|
|
251
|
+
file: targetFile.path,
|
|
252
|
+
line: 1,
|
|
253
|
+
recommendation:
|
|
254
|
+
'Add `URL::forceScheme(\'https\')` in `AppServiceProvider::boot()` for production environments, ' +
|
|
255
|
+
'or configure `FORCE_HTTPS=true` in your environment and handle it in middleware.',
|
|
256
|
+
cwe: 'CWE-319',
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return findings;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { Analyzer, Finding, HavocConfig, ParsedFile, Severity } from '../types/index.js';
|
|
2
|
+
|
|
3
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const ANALYZER_NAME = 'SessionSecurityAnalyzer';
|
|
6
|
+
|
|
7
|
+
const FINDING_SECURE_COOKIE = 'HAVOC-SESS-001';
|
|
8
|
+
const FINDING_HTTP_ONLY = 'HAVOC-SESS-002';
|
|
9
|
+
const FINDING_SAME_SITE = 'HAVOC-SESS-003';
|
|
10
|
+
const FINDING_FILE_DRIVER = 'HAVOC-SESS-004';
|
|
11
|
+
const FINDING_CSRF_EXCLUSION = 'HAVOC-SESS-005';
|
|
12
|
+
const FINDING_SESSION_LIFETIME = 'HAVOC-SESS-006';
|
|
13
|
+
const FINDING_COOKIE_ENCRYPTION = 'HAVOC-SESS-007';
|
|
14
|
+
|
|
15
|
+
const SESSION_LIFETIME_THRESHOLD = 480; // minutes
|
|
16
|
+
|
|
17
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function isSessionConfig(path: string): boolean {
|
|
20
|
+
return path === 'config/session.php' || path.endsWith('/config/session.php');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isBootstrapApp(path: string): boolean {
|
|
24
|
+
return path === 'bootstrap/app.php' || path.endsWith('/bootstrap/app.php');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isMiddlewareFile(path: string): boolean {
|
|
28
|
+
return path.includes('Middleware') && path.endsWith('.php');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function findLineNumber(content: string, matchIndex: number): number {
|
|
32
|
+
return content.slice(0, matchIndex).split('\n').length;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extract a config value from a PHP config file.
|
|
37
|
+
*
|
|
38
|
+
* Handles:
|
|
39
|
+
* - scalar booleans: `'key' => true`
|
|
40
|
+
* - scalar strings: `'key' => 'value'`
|
|
41
|
+
* - env() calls with inner commas: `'key' => env('VAR', 'default')`
|
|
42
|
+
* - integer literals: `'key' => 120`
|
|
43
|
+
*
|
|
44
|
+
* Returns the raw matched value string, or null if the key isn't found.
|
|
45
|
+
*/
|
|
46
|
+
function extractConfigValue(content: string, key: string): string | null {
|
|
47
|
+
// Match the key, then capture either:
|
|
48
|
+
// - an env(...) call (balancing parentheses up to closing paren)
|
|
49
|
+
// - a quoted string
|
|
50
|
+
// - a bare token (true/false/null/number)
|
|
51
|
+
const keyPattern = new RegExp(`['"]${key}['"]\\s*=>\\s*`, 'i');
|
|
52
|
+
const keyMatch = keyPattern.exec(content);
|
|
53
|
+
if (!keyMatch) return null;
|
|
54
|
+
|
|
55
|
+
const afterKey = content.slice(keyMatch.index + keyMatch[0].length);
|
|
56
|
+
|
|
57
|
+
// env(...) call — capture to the matching close paren
|
|
58
|
+
const envCallMatch = /^env\s*\(/.exec(afterKey);
|
|
59
|
+
if (envCallMatch) {
|
|
60
|
+
let depth = 0;
|
|
61
|
+
let i = afterKey.indexOf('(');
|
|
62
|
+
for (; i < afterKey.length; i++) {
|
|
63
|
+
if (afterKey[i] === '(') depth++;
|
|
64
|
+
else if (afterKey[i] === ')') {
|
|
65
|
+
depth--;
|
|
66
|
+
if (depth === 0) return afterKey.slice(0, i + 1).trim();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return afterKey.slice(0, 60).trim(); // fallback
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Quoted string: 'value' or "value"
|
|
73
|
+
const quotedMatch = /^(['"])(.*?)\1/.exec(afterKey);
|
|
74
|
+
if (quotedMatch) return quotedMatch[0].trim();
|
|
75
|
+
|
|
76
|
+
// Bare token: true, false, null, or integer — stop at comma or newline
|
|
77
|
+
const bareMatch = /^[^\s,\n\r]+/.exec(afterKey);
|
|
78
|
+
if (bareMatch) return bareMatch[0].trim();
|
|
79
|
+
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Unwrap env(...) call to extract the default value (second argument).
|
|
85
|
+
* e.g., `env('SESSION_SECURE_COOKIE', false)` → 'false'
|
|
86
|
+
*/
|
|
87
|
+
function extractEnvDefault(envCall: string): string | null {
|
|
88
|
+
const match = /env\s*\([^,)]+,\s*(.+?)\s*\)$/.exec(envCall.trim());
|
|
89
|
+
if (!match) return null;
|
|
90
|
+
return match[1].replace(/['"]/g, '').trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Analyzer ─────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
export class SessionSecurityAnalyzer implements Analyzer {
|
|
96
|
+
readonly name = ANALYZER_NAME;
|
|
97
|
+
readonly description = 'Analyzes session and cookie security configuration for vulnerabilities';
|
|
98
|
+
|
|
99
|
+
async analyze(files: ParsedFile[], _config: HavocConfig): Promise<Finding[]> {
|
|
100
|
+
const findings: Finding[] = [];
|
|
101
|
+
|
|
102
|
+
for (const file of files) {
|
|
103
|
+
if (!file.path.endsWith('.php')) continue;
|
|
104
|
+
|
|
105
|
+
if (isSessionConfig(file.path)) {
|
|
106
|
+
findings.push(...this.analyzeSessionConfig(file));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (isBootstrapApp(file.path) || isMiddlewareFile(file.path)) {
|
|
110
|
+
findings.push(...this.analyzeCsrfExclusions(file));
|
|
111
|
+
findings.push(...this.analyzeCookieEncryption(file));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return findings;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private analyzeSessionConfig(file: ParsedFile): Finding[] {
|
|
119
|
+
const findings: Finding[] = [];
|
|
120
|
+
const content = file.content;
|
|
121
|
+
|
|
122
|
+
// Check 'secure' flag — should be true (or env-driven default true) in production
|
|
123
|
+
const secureValue = extractConfigValue(content, 'secure');
|
|
124
|
+
if (secureValue !== null) {
|
|
125
|
+
let isInsecure = false;
|
|
126
|
+
|
|
127
|
+
if (secureValue === 'false') {
|
|
128
|
+
isInsecure = true;
|
|
129
|
+
} else if (/^env\s*\(/.test(secureValue)) {
|
|
130
|
+
const defaultVal = extractEnvDefault(secureValue);
|
|
131
|
+
if (defaultVal === 'false') isInsecure = true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (isInsecure) {
|
|
135
|
+
const idx = content.search(/'secure'\s*=>/i);
|
|
136
|
+
findings.push({
|
|
137
|
+
id: FINDING_SECURE_COOKIE,
|
|
138
|
+
severity: Severity.High,
|
|
139
|
+
analyzer: ANALYZER_NAME,
|
|
140
|
+
title: 'Session cookie `secure` flag is disabled',
|
|
141
|
+
description:
|
|
142
|
+
`\`'secure' => false\` in \`${file.path}\`. ` +
|
|
143
|
+
`Session cookies without the Secure flag can be transmitted over unencrypted HTTP connections.`,
|
|
144
|
+
file: file.path,
|
|
145
|
+
line: idx !== -1 ? findLineNumber(content, idx) : 1,
|
|
146
|
+
recommendation:
|
|
147
|
+
"Set `'secure' => env('SESSION_SECURE_COOKIE', true)` to ensure cookies are only sent over HTTPS.",
|
|
148
|
+
cwe: 'CWE-614',
|
|
149
|
+
snippet: `'secure' => false`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Check 'http_only' — should be true
|
|
155
|
+
const httpOnlyValue = extractConfigValue(content, 'http_only');
|
|
156
|
+
if (httpOnlyValue !== null && httpOnlyValue === 'false') {
|
|
157
|
+
const idx = content.search(/'http_only'\s*=>/i);
|
|
158
|
+
findings.push({
|
|
159
|
+
id: FINDING_HTTP_ONLY,
|
|
160
|
+
severity: Severity.High,
|
|
161
|
+
analyzer: ANALYZER_NAME,
|
|
162
|
+
title: 'Session cookie `http_only` flag is disabled',
|
|
163
|
+
description:
|
|
164
|
+
`\`'http_only' => false\` in \`${file.path}\`. ` +
|
|
165
|
+
`Without the HttpOnly flag, session cookies can be accessed by client-side JavaScript, enabling XSS-based session theft.`,
|
|
166
|
+
file: file.path,
|
|
167
|
+
line: idx !== -1 ? findLineNumber(content, idx) : 1,
|
|
168
|
+
recommendation: "Set `'http_only' => true` to prevent JavaScript access to session cookies.",
|
|
169
|
+
cwe: 'CWE-1004',
|
|
170
|
+
snippet: `'http_only' => false`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Check 'same_site' — should be 'lax' or 'strict'
|
|
175
|
+
const sameSiteValue = extractConfigValue(content, 'same_site');
|
|
176
|
+
if (sameSiteValue !== null) {
|
|
177
|
+
const cleaned = sameSiteValue.replace(/['"]/g, '').toLowerCase().trim();
|
|
178
|
+
if (cleaned === 'none' || cleaned === 'null' || cleaned === '') {
|
|
179
|
+
const idx = content.search(/'same_site'\s*=>/i);
|
|
180
|
+
findings.push({
|
|
181
|
+
id: FINDING_SAME_SITE,
|
|
182
|
+
severity: Severity.Medium,
|
|
183
|
+
analyzer: ANALYZER_NAME,
|
|
184
|
+
title: `Session cookie \`same_site\` is set to \`${cleaned || 'null'}\``,
|
|
185
|
+
description:
|
|
186
|
+
`\`'same_site' => '${cleaned}'\` in \`${file.path}\`. ` +
|
|
187
|
+
`A permissive SameSite policy increases CSRF attack surface.`,
|
|
188
|
+
file: file.path,
|
|
189
|
+
line: idx !== -1 ? findLineNumber(content, idx) : 1,
|
|
190
|
+
recommendation: "Set `'same_site' => 'lax'` or `'strict'` to mitigate CSRF attacks.",
|
|
191
|
+
cwe: 'CWE-352',
|
|
192
|
+
snippet: `'same_site' => '${cleaned}'`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Check 'driver' — warn if 'file' (should be redis/database in production)
|
|
198
|
+
const driverValue = extractConfigValue(content, 'driver');
|
|
199
|
+
if (driverValue !== null) {
|
|
200
|
+
let driverStr = driverValue.replace(/['"]/g, '').trim();
|
|
201
|
+
// If env-driven, extract default
|
|
202
|
+
if (/^env\s*\(/.test(driverValue)) {
|
|
203
|
+
const def = extractEnvDefault(driverValue);
|
|
204
|
+
driverStr = def ?? driverStr;
|
|
205
|
+
}
|
|
206
|
+
if (driverStr === 'file') {
|
|
207
|
+
const idx = content.search(/'driver'\s*=>/i);
|
|
208
|
+
findings.push({
|
|
209
|
+
id: FINDING_FILE_DRIVER,
|
|
210
|
+
severity: Severity.Medium,
|
|
211
|
+
analyzer: ANALYZER_NAME,
|
|
212
|
+
title: 'Session driver is set to `file`',
|
|
213
|
+
description:
|
|
214
|
+
`\`'driver' => 'file'\` in \`${file.path}\`. ` +
|
|
215
|
+
`File-based session storage is not suitable for production — it doesn't scale across multiple servers.`,
|
|
216
|
+
file: file.path,
|
|
217
|
+
line: idx !== -1 ? findLineNumber(content, idx) : 1,
|
|
218
|
+
recommendation:
|
|
219
|
+
"Use `'driver' => env('SESSION_DRIVER', 'redis')` and configure Redis or database session storage in production.",
|
|
220
|
+
cwe: 'CWE-16',
|
|
221
|
+
snippet: `'driver' => 'file'`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Check session lifetime > 480 minutes
|
|
227
|
+
const lifetimeValue = extractConfigValue(content, 'lifetime');
|
|
228
|
+
if (lifetimeValue !== null) {
|
|
229
|
+
const numStr = lifetimeValue.replace(/[^0-9]/g, '');
|
|
230
|
+
const lifetimeNum = parseInt(numStr, 10);
|
|
231
|
+
if (!isNaN(lifetimeNum) && lifetimeNum > SESSION_LIFETIME_THRESHOLD) {
|
|
232
|
+
const idx = content.search(/'lifetime'\s*=>/i);
|
|
233
|
+
findings.push({
|
|
234
|
+
id: FINDING_SESSION_LIFETIME,
|
|
235
|
+
severity: Severity.Medium,
|
|
236
|
+
analyzer: ANALYZER_NAME,
|
|
237
|
+
title: `Session lifetime is excessively long (${lifetimeNum} minutes)`,
|
|
238
|
+
description:
|
|
239
|
+
`Session lifetime of ${lifetimeNum} minutes in \`${file.path}\` exceeds the recommended maximum of ${SESSION_LIFETIME_THRESHOLD} minutes (8 hours). ` +
|
|
240
|
+
`Long-lived sessions increase the window of opportunity for session hijacking.`,
|
|
241
|
+
file: file.path,
|
|
242
|
+
line: idx !== -1 ? findLineNumber(content, idx) : 1,
|
|
243
|
+
recommendation:
|
|
244
|
+
`Set the session lifetime to ${SESSION_LIFETIME_THRESHOLD} minutes or less and implement idle timeout.`,
|
|
245
|
+
cwe: 'CWE-613',
|
|
246
|
+
snippet: `'lifetime' => ${lifetimeNum}`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return findings;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private analyzeCsrfExclusions(file: ParsedFile): Finding[] {
|
|
255
|
+
const findings: Finding[] = [];
|
|
256
|
+
const content = file.content;
|
|
257
|
+
|
|
258
|
+
// Gate to VerifyCsrfToken-specific files: check by filename or class/use declarations
|
|
259
|
+
const isCsrfMiddleware =
|
|
260
|
+
file.path.includes('VerifyCsrfToken') ||
|
|
261
|
+
/class\s+VerifyCsrfToken/.test(content) ||
|
|
262
|
+
(/VerifyCsrfToken/.test(content) && !content.includes('class EncryptCookies'));
|
|
263
|
+
|
|
264
|
+
if (isCsrfMiddleware) {
|
|
265
|
+
const hasExceptArray = /\$except\s*=\s*\[([^\]]+)\]/.exec(content);
|
|
266
|
+
if (hasExceptArray && hasExceptArray[1].trim() !== '') {
|
|
267
|
+
const idx = content.search(/\$except\s*=\s*\[/);
|
|
268
|
+
findings.push({
|
|
269
|
+
id: FINDING_CSRF_EXCLUSION,
|
|
270
|
+
severity: Severity.High,
|
|
271
|
+
analyzer: ANALYZER_NAME,
|
|
272
|
+
title: 'CSRF token verification is excluded for some routes',
|
|
273
|
+
description:
|
|
274
|
+
`\`$except\` array with routes is defined in \`${file.path}\`. ` +
|
|
275
|
+
`Excluding routes from CSRF verification leaves them vulnerable to cross-site request forgery attacks.`,
|
|
276
|
+
file: file.path,
|
|
277
|
+
line: idx !== -1 ? findLineNumber(content, idx) : 1,
|
|
278
|
+
recommendation:
|
|
279
|
+
'Remove CSRF exclusions unless absolutely necessary. For API routes, use token-based auth instead.',
|
|
280
|
+
cwe: 'CWE-352',
|
|
281
|
+
snippet: hasExceptArray[0].slice(0, 80),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Check bootstrap/app.php for withoutMiddleware(VerifyCsrfToken)
|
|
287
|
+
const withoutCsrf = /withoutMiddleware\s*\(\s*(?:[^)]*VerifyCsrfToken[^)]*)\)/.exec(content);
|
|
288
|
+
if (withoutCsrf) {
|
|
289
|
+
findings.push({
|
|
290
|
+
id: FINDING_CSRF_EXCLUSION,
|
|
291
|
+
severity: Severity.High,
|
|
292
|
+
analyzer: ANALYZER_NAME,
|
|
293
|
+
title: 'CSRF middleware explicitly disabled for routes',
|
|
294
|
+
description:
|
|
295
|
+
`\`withoutMiddleware(VerifyCsrfToken::class)\` found in \`${file.path}\`. ` +
|
|
296
|
+
`Disabling CSRF verification exposes endpoints to cross-site request forgery.`,
|
|
297
|
+
file: file.path,
|
|
298
|
+
line: findLineNumber(content, withoutCsrf.index),
|
|
299
|
+
recommendation:
|
|
300
|
+
'Use proper API authentication (Sanctum, Passport) for routes that need to skip web CSRF middleware.',
|
|
301
|
+
cwe: 'CWE-352',
|
|
302
|
+
snippet: withoutCsrf[0].slice(0, 80),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return findings;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private analyzeCookieEncryption(file: ParsedFile): Finding[] {
|
|
310
|
+
const findings: Finding[] = [];
|
|
311
|
+
const content = file.content;
|
|
312
|
+
|
|
313
|
+
// Only check EncryptCookies middleware files
|
|
314
|
+
const isEncryptCookiesFile =
|
|
315
|
+
file.path.includes('EncryptCookies') ||
|
|
316
|
+
/class\s+EncryptCookies/.test(content);
|
|
317
|
+
|
|
318
|
+
if (isEncryptCookiesFile) {
|
|
319
|
+
const exceptMatch = /\$except\s*=\s*\[([^\]]+)\]/.exec(content);
|
|
320
|
+
if (exceptMatch && exceptMatch[1].trim() !== '') {
|
|
321
|
+
const idx = content.search(/\$except\s*=\s*\[/);
|
|
322
|
+
findings.push({
|
|
323
|
+
id: FINDING_COOKIE_ENCRYPTION,
|
|
324
|
+
severity: Severity.Medium,
|
|
325
|
+
analyzer: ANALYZER_NAME,
|
|
326
|
+
title: 'Sensitive cookies may be excluded from encryption',
|
|
327
|
+
description:
|
|
328
|
+
`The \`$except\` array in \`${file.path}\` excludes some cookies from encryption. ` +
|
|
329
|
+
`Unencrypted cookies can be read or tampered with by the client.`,
|
|
330
|
+
file: file.path,
|
|
331
|
+
line: idx !== -1 ? findLineNumber(content, idx) : 1,
|
|
332
|
+
recommendation:
|
|
333
|
+
'Only exclude cookies that are explicitly designed to be readable client-side. Remove sensitive cookie names from `$except`.',
|
|
334
|
+
cwe: 'CWE-311',
|
|
335
|
+
snippet: exceptMatch[0].slice(0, 80),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return findings;
|
|
341
|
+
}
|
|
342
|
+
}
|