@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,213 @@
|
|
|
1
|
+
import { Analyzer, Finding, HavocConfig, ParsedFile, Severity } from '../types/index.js';
|
|
2
|
+
import type { ParsedPhpFile, PhpMethodInfo } from '../parsers/PhpParser.js';
|
|
3
|
+
import {
|
|
4
|
+
buildRouteMap,
|
|
5
|
+
getRouteMiddleware,
|
|
6
|
+
hasAuthorizationMiddleware,
|
|
7
|
+
hasAuthenticationMiddleware,
|
|
8
|
+
isWebhookHandler,
|
|
9
|
+
} from '../parsers/RouteParser.js';
|
|
10
|
+
import type { ParsedRouteFile } from '../parsers/RouteParser.js';
|
|
11
|
+
|
|
12
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const EXCLUDED_METHOD_NAMES = new Set([
|
|
15
|
+
'__construct', '__destruct', 'middleware', 'callAction',
|
|
16
|
+
'authorize', 'authorizeResource', 'authorizeForUser',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const AUTHORIZATION_PATTERNS = [
|
|
20
|
+
/\$this->authorize\s*\(/,
|
|
21
|
+
/Gate::authorize\s*\(/,
|
|
22
|
+
/Gate::allows\s*\(/,
|
|
23
|
+
/Gate::denies\s*\(/,
|
|
24
|
+
/Gate::check\s*\(/,
|
|
25
|
+
/Gate::any\s*\(/,
|
|
26
|
+
/Gate::none\s*\(/,
|
|
27
|
+
/->can\s*\(/,
|
|
28
|
+
/->cannot\s*\(/,
|
|
29
|
+
/->cant\s*\(/,
|
|
30
|
+
/\$this->middleware\s*\(\s*['"]can:/,
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const FORM_REQUEST_PATTERNS = [/extends\s+FormRequest/];
|
|
34
|
+
|
|
35
|
+
const FINDING_ID = 'HAVOC-AUTH-001';
|
|
36
|
+
const COVERAGE_FINDING_ID = 'HAVOC-AUTH-002';
|
|
37
|
+
const ANALYZER_NAME = 'AuthorizationCoverageAnalyzer';
|
|
38
|
+
|
|
39
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function isControllerFile(path: string): boolean {
|
|
42
|
+
return path.includes('Http/Controllers') || path.includes('Http\\Controllers');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isPublicAction(method: PhpMethodInfo): boolean {
|
|
46
|
+
return (
|
|
47
|
+
method.visibility === 'public' &&
|
|
48
|
+
!EXCLUDED_METHOD_NAMES.has(method.name) &&
|
|
49
|
+
!method.name.startsWith('_')
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hasInlineAuthorization(method: PhpMethodInfo): boolean {
|
|
54
|
+
return AUTHORIZATION_PATTERNS.some((re) => re.test(method.bodyText));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Authorization Coverage Result ───────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Describes the authorization status of a single controller action.
|
|
61
|
+
*/
|
|
62
|
+
export type AuthorizationStatus =
|
|
63
|
+
| 'authorized' // Has authorization — not flagged
|
|
64
|
+
| 'auth-only' // Has authentication middleware only — LOW finding
|
|
65
|
+
| 'webhook' // Signature-verified webhook — not flagged
|
|
66
|
+
| 'unprotected'; // No auth at all — HIGH finding
|
|
67
|
+
|
|
68
|
+
function determineStatus(
|
|
69
|
+
method: PhpMethodInfo,
|
|
70
|
+
hasFormRequest: boolean,
|
|
71
|
+
routeMiddleware: string[],
|
|
72
|
+
): AuthorizationStatus {
|
|
73
|
+
// Inline authorization or FormRequest → fully authorized
|
|
74
|
+
if (hasInlineAuthorization(method) || hasFormRequest) return 'authorized';
|
|
75
|
+
|
|
76
|
+
// Webhook pattern detected → no auth needed
|
|
77
|
+
if (isWebhookHandler(method.bodyText)) return 'webhook';
|
|
78
|
+
|
|
79
|
+
// Route-level authorization middleware (can:*, permission:*, role:*) → authorized
|
|
80
|
+
if (hasAuthorizationMiddleware(routeMiddleware)) return 'authorized';
|
|
81
|
+
|
|
82
|
+
// Route-level authentication-only middleware → downgrade to LOW
|
|
83
|
+
if (hasAuthenticationMiddleware(routeMiddleware)) return 'auth-only';
|
|
84
|
+
|
|
85
|
+
return 'unprotected';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Analyzer ─────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
export class AuthorizationCoverageAnalyzer implements Analyzer {
|
|
91
|
+
readonly name = ANALYZER_NAME;
|
|
92
|
+
readonly description = 'Checks that all controller action methods have authorization in place';
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Optional route data to reduce false positives.
|
|
96
|
+
* When provided, controllers protected by route-level middleware are handled correctly.
|
|
97
|
+
*/
|
|
98
|
+
private routeFiles: ParsedRouteFile[];
|
|
99
|
+
|
|
100
|
+
constructor(routeFiles: ParsedRouteFile[] = []) {
|
|
101
|
+
this.routeFiles = routeFiles;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Set (or replace) route data after construction.
|
|
106
|
+
* Useful when the scan pipeline discovers routes after initialization.
|
|
107
|
+
*/
|
|
108
|
+
setRouteFiles(routeFiles: ParsedRouteFile[]): void {
|
|
109
|
+
this.routeFiles = routeFiles;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async analyze(files: ParsedFile[], _config: HavocConfig): Promise<Finding[]> {
|
|
113
|
+
const findings: Finding[] = [];
|
|
114
|
+
let totalActions = 0;
|
|
115
|
+
let coveredActions = 0;
|
|
116
|
+
|
|
117
|
+
const routeMap = buildRouteMap(this.routeFiles);
|
|
118
|
+
|
|
119
|
+
for (const file of files) {
|
|
120
|
+
if (!isControllerFile(file.path)) continue;
|
|
121
|
+
|
|
122
|
+
const phpFile = file as ParsedPhpFile;
|
|
123
|
+
if (!phpFile.classes) continue;
|
|
124
|
+
|
|
125
|
+
const hasFormRequest = FORM_REQUEST_PATTERNS.some((re) => re.test(file.content));
|
|
126
|
+
|
|
127
|
+
for (const cls of phpFile.classes) {
|
|
128
|
+
for (const method of cls.methods) {
|
|
129
|
+
if (!isPublicAction(method)) continue;
|
|
130
|
+
totalActions++;
|
|
131
|
+
|
|
132
|
+
const routeMiddleware = getRouteMiddleware(routeMap, cls.name, method.name);
|
|
133
|
+
const status = determineStatus(method, hasFormRequest, routeMiddleware);
|
|
134
|
+
|
|
135
|
+
switch (status) {
|
|
136
|
+
case 'authorized':
|
|
137
|
+
case 'webhook':
|
|
138
|
+
coveredActions++;
|
|
139
|
+
break;
|
|
140
|
+
|
|
141
|
+
case 'auth-only':
|
|
142
|
+
// Authentication only — controller is behind a login wall but has no
|
|
143
|
+
// fine-grained authorization. Flag as LOW (not HIGH).
|
|
144
|
+
// NOTE: does NOT count toward coveredActions — authentication ≠ authorization.
|
|
145
|
+
findings.push({
|
|
146
|
+
id: FINDING_ID,
|
|
147
|
+
severity: Severity.Low,
|
|
148
|
+
analyzer: ANALYZER_NAME,
|
|
149
|
+
title: `No authorization check in authenticated action: ${cls.name}::${method.name}()`,
|
|
150
|
+
description:
|
|
151
|
+
`The method \`${method.name}()\` in \`${cls.name}\` is behind authentication ` +
|
|
152
|
+
`middleware but has no authorization check. Any authenticated user can access it.`,
|
|
153
|
+
file: file.path,
|
|
154
|
+
line: method.line,
|
|
155
|
+
recommendation:
|
|
156
|
+
'Add `$this->authorize(\'action\', Model::class)` or apply `can:permission` middleware ' +
|
|
157
|
+
'to restrict access to authorized users only.',
|
|
158
|
+
cwe: 'CWE-862',
|
|
159
|
+
snippet: method.bodyText.slice(0, 200),
|
|
160
|
+
});
|
|
161
|
+
break;
|
|
162
|
+
|
|
163
|
+
case 'unprotected':
|
|
164
|
+
default:
|
|
165
|
+
findings.push({
|
|
166
|
+
id: FINDING_ID,
|
|
167
|
+
severity: Severity.High,
|
|
168
|
+
analyzer: ANALYZER_NAME,
|
|
169
|
+
title: `Unprotected controller action: ${cls.name}::${method.name}()`,
|
|
170
|
+
description:
|
|
171
|
+
`The method \`${method.name}()\` in \`${cls.name}\` does not appear to perform ` +
|
|
172
|
+
`any authorization checks. Missing \`$this->authorize()\`, Gate checks, ` +
|
|
173
|
+
`policy-backed FormRequest, or route middleware.`,
|
|
174
|
+
file: file.path,
|
|
175
|
+
line: method.line,
|
|
176
|
+
recommendation:
|
|
177
|
+
'Add `$this->authorize(\'action\', Model::class)` at the top of the method, ' +
|
|
178
|
+
'or use a policy-backed FormRequest, or apply `can:permission` middleware to the route.',
|
|
179
|
+
cwe: 'CWE-862',
|
|
180
|
+
snippet: method.bodyText.slice(0, 200),
|
|
181
|
+
});
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (totalActions > 0) {
|
|
189
|
+
const coveragePercent = Math.round((coveredActions / totalActions) * 100);
|
|
190
|
+
const minCoverage = (_config.analyzers?.authorizationCoverage?.minCoverage ?? 80);
|
|
191
|
+
|
|
192
|
+
if (coveragePercent < minCoverage) {
|
|
193
|
+
findings.push({
|
|
194
|
+
id: COVERAGE_FINDING_ID,
|
|
195
|
+
severity: Severity.Medium,
|
|
196
|
+
analyzer: ANALYZER_NAME,
|
|
197
|
+
title: `Authorization coverage is ${coveragePercent}% (minimum: ${minCoverage}%)`,
|
|
198
|
+
description:
|
|
199
|
+
`Only ${coveredActions} of ${totalActions} controller actions have detectable ` +
|
|
200
|
+
`authorization checks (inline Gate/policy or can:* route middleware). Target: ${minCoverage}%.`,
|
|
201
|
+
file: 'app/Http/Controllers',
|
|
202
|
+
line: 1,
|
|
203
|
+
recommendation:
|
|
204
|
+
'Review unprotected controller actions and add appropriate authorization. ' +
|
|
205
|
+
'Consider using Laravel policies for consistent authorization.',
|
|
206
|
+
cwe: 'CWE-862',
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return findings;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { isTestFile, isFakeCredential } from './exclusions.js';
|
|
2
|
+
import { Analyzer, Finding, HavocConfig, ParsedFile, Severity } from '../types/index.js';
|
|
3
|
+
|
|
4
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
const ANALYZER_NAME = 'CredentialExposureAnalyzer';
|
|
7
|
+
|
|
8
|
+
const FINDING_ENV_FILE_COMMITTED = 'HAVOC-CRED-001';
|
|
9
|
+
const FINDING_HARDCODED_SECRET = 'HAVOC-CRED-002';
|
|
10
|
+
const FINDING_APP_DEBUG = 'HAVOC-CRED-003';
|
|
11
|
+
const FINDING_APP_ENV = 'HAVOC-CRED-004';
|
|
12
|
+
const FINDING_DEBUG_CONFIG = 'HAVOC-CRED-005';
|
|
13
|
+
const FINDING_DB_CREDENTIALS = 'HAVOC-CRED-006';
|
|
14
|
+
const FINDING_PRIVATE_KEY = 'HAVOC-CRED-007';
|
|
15
|
+
const FINDING_CONFIG_HARDCODED = 'HAVOC-CRED-008';
|
|
16
|
+
|
|
17
|
+
const SENSITIVE_CONFIG_KEYS = [
|
|
18
|
+
'password', 'secret', 'key', 'token', 'api_key', 'apikey',
|
|
19
|
+
'access_key', 'private_key', 'client_secret', 'client_id',
|
|
20
|
+
'app_id', 'app_secret', 'encryption_key', 'signing_secret',
|
|
21
|
+
'webhook_secret',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// Secret patterns
|
|
25
|
+
const SECRET_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
|
|
26
|
+
{ pattern: /['"]sk_live_[0-9a-zA-Z]{24,}['"]/, label: 'Stripe live secret key' },
|
|
27
|
+
{ pattern: /['"]pk_live_[0-9a-zA-Z]{24,}['"]/, label: 'Stripe live publishable key' },
|
|
28
|
+
{ pattern: /['"]AKIA[0-9A-Z]{16}['"]/, label: 'AWS Access Key ID' },
|
|
29
|
+
{ pattern: /(?:password|passwd|pwd)\s*=\s*['"][^'"]{3,}['"]/, label: 'Hardcoded password' },
|
|
30
|
+
{ pattern: /(?:api_key|apikey|api_secret)\s*=\s*['"][^'"]{8,}['"]/, label: 'Hardcoded API key' },
|
|
31
|
+
{ pattern: /(?:access_token|auth_token)\s*=\s*['"][^'"]{8,}['"]/, label: 'Hardcoded access token' },
|
|
32
|
+
{ pattern: /ghp_[0-9a-zA-Z]{36}/, label: 'GitHub personal access token' },
|
|
33
|
+
{ pattern: /xox[baprs]-[0-9a-zA-Z-]{10,}/, label: 'Slack token' },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const DB_CREDENTIAL_PATTERNS: Array<RegExp> = [
|
|
37
|
+
/new\s+PDO\s*\(\s*['"][^'"]+['"],\s*['"][^'"]+['"],\s*['"][^'"]{3,}['"]/,
|
|
38
|
+
/mysqli_connect\s*\([^)]*,\s*['"][^'"]+['"],\s*['"][^'"]{3,}['"]/,
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const PRIVATE_KEY_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
|
|
42
|
+
{ pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/, label: 'Private key (PEM)' },
|
|
43
|
+
{ pattern: /'jwt_secret'\s*=>\s*'[^']{16,}'/, label: 'JWT secret in config' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
function isPhpFile(path: string): boolean {
|
|
49
|
+
return path.endsWith('.php') && !path.endsWith('.blade.php');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isEnvFile(path: string): boolean {
|
|
53
|
+
const filename = path.split('/').pop() ?? '';
|
|
54
|
+
return filename === '.env' || /^\.env\.[a-z]+$/.test(filename);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isProductionEnvConfig(path: string): boolean {
|
|
58
|
+
return /\.env\.(production|prod|staging|live)/.test(path);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isLocalEnvFile(path: string): boolean {
|
|
62
|
+
return /\.env\.(local|testing|test|development|dev)/.test(path) || path === '.env.example';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isAppConfig(path: string): boolean {
|
|
66
|
+
return path === 'config/app.php' || path.endsWith('/config/app.php');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isConfigFile(path: string): boolean {
|
|
70
|
+
return /(?:^|\/)config\/[^/]+\.php$/.test(path);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function findLineNumber(content: string, matchIndex: number): number {
|
|
74
|
+
return content.slice(0, matchIndex).split('\n').length;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── Analyzer ─────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
export class CredentialExposureAnalyzer implements Analyzer {
|
|
80
|
+
readonly name = ANALYZER_NAME;
|
|
81
|
+
readonly description = 'Detects hardcoded credentials, secrets, and insecure configuration exposure';
|
|
82
|
+
|
|
83
|
+
async analyze(files: ParsedFile[], _config: HavocConfig): Promise<Finding[]> {
|
|
84
|
+
const findings: Finding[] = [];
|
|
85
|
+
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
// 1. .env files committed to the repo
|
|
88
|
+
if (isEnvFile(file.path)) {
|
|
89
|
+
findings.push({
|
|
90
|
+
id: FINDING_ENV_FILE_COMMITTED,
|
|
91
|
+
severity: Severity.Critical,
|
|
92
|
+
analyzer: ANALYZER_NAME,
|
|
93
|
+
title: '`.env` file committed to repository',
|
|
94
|
+
description:
|
|
95
|
+
`The file \`${file.path}\` was found in the scanned path. ` +
|
|
96
|
+
`.env files typically contain secrets and should never be committed to version control.`,
|
|
97
|
+
file: file.path,
|
|
98
|
+
line: 1,
|
|
99
|
+
recommendation:
|
|
100
|
+
'Add `.env` to `.gitignore`. Use `.env.example` with placeholder values instead.',
|
|
101
|
+
cwe: 'CWE-312',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 2. Hardcoded secrets in PHP source
|
|
106
|
+
if (isPhpFile(file.path)) {
|
|
107
|
+
this.checkHardcodedSecrets(file, findings);
|
|
108
|
+
this.checkDbCredentials(file, findings);
|
|
109
|
+
this.checkPrivateKeys(file, findings);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 3. APP_DEBUG=true in non-local env configs
|
|
113
|
+
if (isEnvFile(file.path) && !isLocalEnvFile(file.path)) {
|
|
114
|
+
const debugIdx = file.content.search(/^APP_DEBUG\s*=\s*true/im);
|
|
115
|
+
if (debugIdx !== -1) {
|
|
116
|
+
findings.push({
|
|
117
|
+
id: FINDING_APP_DEBUG,
|
|
118
|
+
severity: Severity.High,
|
|
119
|
+
analyzer: ANALYZER_NAME,
|
|
120
|
+
title: '`APP_DEBUG=true` in non-local environment config',
|
|
121
|
+
description:
|
|
122
|
+
`\`APP_DEBUG=true\` was found in \`${file.path}\`. ` +
|
|
123
|
+
`Debug mode exposes stack traces and sensitive application internals to end users.`,
|
|
124
|
+
file: file.path,
|
|
125
|
+
line: findLineNumber(file.content, debugIdx),
|
|
126
|
+
recommendation: 'Set `APP_DEBUG=false` in all non-local environment configs.',
|
|
127
|
+
cwe: 'CWE-209',
|
|
128
|
+
snippet: 'APP_DEBUG=true',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 4. APP_ENV=local or testing in production-looking configs
|
|
133
|
+
if (isProductionEnvConfig(file.path)) {
|
|
134
|
+
const envMatch = /^APP_ENV\s*=\s*(local|testing)/im.exec(file.content);
|
|
135
|
+
if (envMatch) {
|
|
136
|
+
findings.push({
|
|
137
|
+
id: FINDING_APP_ENV,
|
|
138
|
+
severity: Severity.Medium,
|
|
139
|
+
analyzer: ANALYZER_NAME,
|
|
140
|
+
title: `\`APP_ENV=${envMatch[1]}\` in production environment config`,
|
|
141
|
+
description:
|
|
142
|
+
`\`APP_ENV=${envMatch[1]}\` was found in \`${file.path}\`. ` +
|
|
143
|
+
`Production configs should use \`APP_ENV=production\`.`,
|
|
144
|
+
file: file.path,
|
|
145
|
+
line: findLineNumber(file.content, envMatch.index),
|
|
146
|
+
recommendation: 'Set `APP_ENV=production` in production environment configs.',
|
|
147
|
+
cwe: 'CWE-16',
|
|
148
|
+
snippet: `APP_ENV=${envMatch[1]}`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 5. Config files with hardcoded credentials
|
|
155
|
+
if (isPhpFile(file.path) && isConfigFile(file.path)) {
|
|
156
|
+
this.checkConfigHardcodedCredentials(file, findings);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 6. Verbose error display in config/app.php
|
|
160
|
+
if (isPhpFile(file.path) && isAppConfig(file.path)) {
|
|
161
|
+
const debugConfigIdx = file.content.search(/'debug'\s*=>\s*true/);
|
|
162
|
+
if (debugConfigIdx !== -1) {
|
|
163
|
+
findings.push({
|
|
164
|
+
id: FINDING_DEBUG_CONFIG,
|
|
165
|
+
severity: Severity.High,
|
|
166
|
+
analyzer: ANALYZER_NAME,
|
|
167
|
+
title: '`debug => true` hardcoded in `config/app.php`',
|
|
168
|
+
description:
|
|
169
|
+
`\`'debug' => true\` is hardcoded in \`${file.path}\`. ` +
|
|
170
|
+
`This enables debug mode regardless of the APP_DEBUG environment variable.`,
|
|
171
|
+
file: file.path,
|
|
172
|
+
line: findLineNumber(file.content, debugConfigIdx),
|
|
173
|
+
recommendation:
|
|
174
|
+
"Use `'debug' => (bool) env('APP_DEBUG', false)` to read the value from the environment.",
|
|
175
|
+
cwe: 'CWE-209',
|
|
176
|
+
snippet: "'debug' => true",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const envHardcoded = /'env'\s*=>\s*'(local|testing)'/.exec(file.content);
|
|
181
|
+
if (envHardcoded) {
|
|
182
|
+
findings.push({
|
|
183
|
+
id: FINDING_APP_ENV,
|
|
184
|
+
severity: Severity.Medium,
|
|
185
|
+
analyzer: ANALYZER_NAME,
|
|
186
|
+
title: `\`'env' => '${envHardcoded[1]}'\` hardcoded in \`config/app.php\``,
|
|
187
|
+
description:
|
|
188
|
+
`The application environment is hardcoded as \`${envHardcoded[1]}\` in \`${file.path}\`. `,
|
|
189
|
+
file: file.path,
|
|
190
|
+
line: findLineNumber(file.content, envHardcoded.index),
|
|
191
|
+
recommendation: "Use `'env' => env('APP_ENV', 'production')` to read from the environment.",
|
|
192
|
+
cwe: 'CWE-16',
|
|
193
|
+
snippet: `'env' => '${envHardcoded[1]}'`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return findings;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private checkConfigHardcodedCredentials(file: ParsedFile, findings: Finding[]): void {
|
|
203
|
+
for (const key of SENSITIVE_CONFIG_KEYS) {
|
|
204
|
+
const regex = new RegExp(`['"]\\b${key}\\b['"]\\s*=>\\s*'([^']{3,})'`, 'gi');
|
|
205
|
+
let match: RegExpExecArray | null;
|
|
206
|
+
while ((match = regex.exec(file.content)) !== null) {
|
|
207
|
+
const value = match[1];
|
|
208
|
+
// Skip ORM/framework config keys that aren't credentials
|
|
209
|
+
if (/pivot_key|morph_key|foreign_key|primary_key|route_key/.test(match[0])) continue;
|
|
210
|
+
if (/^(null|true|false|your-|xxx|placeholder|changeme)/i.test(value)) continue;
|
|
211
|
+
const before = file.content.slice(Math.max(0, match.index - 80), match.index);
|
|
212
|
+
if (/env\s*\([^)]*,\s*$/.test(before)) continue;
|
|
213
|
+
|
|
214
|
+
findings.push({
|
|
215
|
+
id: FINDING_CONFIG_HARDCODED,
|
|
216
|
+
severity: Severity.High,
|
|
217
|
+
analyzer: ANALYZER_NAME,
|
|
218
|
+
title: `Hardcoded \`${key}\` in config file \`${file.path}\``,
|
|
219
|
+
description:
|
|
220
|
+
`The config key \`${key}\` in \`${file.path}\` has a hardcoded value instead of ` +
|
|
221
|
+
`using \`env()\`. Credentials in source control are exposed to anyone with repo access.`,
|
|
222
|
+
file: file.path,
|
|
223
|
+
line: findLineNumber(file.content, match.index),
|
|
224
|
+
recommendation:
|
|
225
|
+
`Use \`'${key}' => env('${key.toUpperCase()}')\` to read from environment variables.`,
|
|
226
|
+
cwe: 'CWE-798',
|
|
227
|
+
snippet: match[0].slice(0, 60),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private checkHardcodedSecrets(file: ParsedFile, findings: Finding[]): void {
|
|
234
|
+
// Skip test files — they often contain fake credentials
|
|
235
|
+
if (isTestFile(file.path)) return;
|
|
236
|
+
for (const { pattern, label } of SECRET_PATTERNS) {
|
|
237
|
+
const regex = new RegExp(pattern.source, 'g');
|
|
238
|
+
let match: RegExpExecArray | null;
|
|
239
|
+
while ((match = regex.exec(file.content)) !== null) {
|
|
240
|
+
// Skip if value comes from env() call
|
|
241
|
+
// Skip fake/test credentials
|
|
242
|
+
if (isFakeCredential(match[0])) continue;
|
|
243
|
+
const before = file.content.slice(Math.max(0, match.index - 40), match.index);
|
|
244
|
+
if (/env\s*\(/.test(before)) continue;
|
|
245
|
+
|
|
246
|
+
findings.push({
|
|
247
|
+
id: FINDING_HARDCODED_SECRET,
|
|
248
|
+
severity: Severity.Critical,
|
|
249
|
+
analyzer: ANALYZER_NAME,
|
|
250
|
+
title: `Hardcoded ${label} detected`,
|
|
251
|
+
description:
|
|
252
|
+
`A ${label} appears to be hardcoded in \`${file.path}\`. ` +
|
|
253
|
+
`Hardcoded credentials can be extracted from source code and version control history.`,
|
|
254
|
+
file: file.path,
|
|
255
|
+
line: findLineNumber(file.content, match.index),
|
|
256
|
+
recommendation:
|
|
257
|
+
'Store secrets in environment variables and access them via `env()`. Never commit secrets to source control.',
|
|
258
|
+
cwe: 'CWE-798',
|
|
259
|
+
snippet: match[0].slice(0, 60),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private checkDbCredentials(file: ParsedFile, findings: Finding[]): void {
|
|
266
|
+
for (const pattern of DB_CREDENTIAL_PATTERNS) {
|
|
267
|
+
const regex = new RegExp(pattern.source, 'gs');
|
|
268
|
+
let match: RegExpExecArray | null;
|
|
269
|
+
while ((match = regex.exec(file.content)) !== null) {
|
|
270
|
+
findings.push({
|
|
271
|
+
id: FINDING_DB_CREDENTIALS,
|
|
272
|
+
severity: Severity.Critical,
|
|
273
|
+
analyzer: ANALYZER_NAME,
|
|
274
|
+
title: 'Database credentials hardcoded in source',
|
|
275
|
+
description:
|
|
276
|
+
`Database credentials appear to be hardcoded in \`${file.path}\`. ` +
|
|
277
|
+
`This exposes credentials to anyone with read access to the source code.`,
|
|
278
|
+
file: file.path,
|
|
279
|
+
line: findLineNumber(file.content, match.index),
|
|
280
|
+
recommendation:
|
|
281
|
+
'Use environment variables for database credentials: `DB_HOST`, `DB_USERNAME`, `DB_PASSWORD`.',
|
|
282
|
+
cwe: 'CWE-312',
|
|
283
|
+
snippet: match[0].slice(0, 60),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private checkPrivateKeys(file: ParsedFile, findings: Finding[]): void {
|
|
290
|
+
for (const { pattern, label } of PRIVATE_KEY_PATTERNS) {
|
|
291
|
+
const regex = new RegExp(pattern.source, 'g');
|
|
292
|
+
let match: RegExpExecArray | null;
|
|
293
|
+
while ((match = regex.exec(file.content)) !== null) {
|
|
294
|
+
findings.push({
|
|
295
|
+
id: FINDING_PRIVATE_KEY,
|
|
296
|
+
severity: Severity.Critical,
|
|
297
|
+
analyzer: ANALYZER_NAME,
|
|
298
|
+
title: `${label} found inline in source code`,
|
|
299
|
+
description:
|
|
300
|
+
`A ${label} was found inline in \`${file.path}\`. ` +
|
|
301
|
+
`Private keys and JWT secrets must never be stored in source code.`,
|
|
302
|
+
file: file.path,
|
|
303
|
+
line: findLineNumber(file.content, match.index),
|
|
304
|
+
recommendation:
|
|
305
|
+
'Store private keys in environment variables or a secrets manager. Never commit them to source control.',
|
|
306
|
+
cwe: 'CWE-321',
|
|
307
|
+
snippet: match[0].slice(0, 60),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { Analyzer, Finding, HavocConfig, ParsedFile, Severity } from '../types/index.js';
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
const FINDING_VULNERABLE_DEP = 'HAVOC-DEP-001';
|
|
10
|
+
const FINDING_COMPOSER_UNAVAILABLE = 'HAVOC-DEP-002';
|
|
11
|
+
const ANALYZER_NAME = 'DependencyAuditAnalyzer';
|
|
12
|
+
|
|
13
|
+
const SEVERITY_MAP: Record<string, Severity> = {
|
|
14
|
+
critical: Severity.Critical,
|
|
15
|
+
high: Severity.High,
|
|
16
|
+
medium: Severity.Medium,
|
|
17
|
+
moderate: Severity.Medium,
|
|
18
|
+
low: Severity.Low,
|
|
19
|
+
info: Severity.Info,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
interface ComposerAdvisory {
|
|
23
|
+
advisoryId: string;
|
|
24
|
+
packageName: string;
|
|
25
|
+
title: string;
|
|
26
|
+
link: string;
|
|
27
|
+
cve: string | null;
|
|
28
|
+
severity: string;
|
|
29
|
+
affectedVersions: string;
|
|
30
|
+
sources: { name: string; remoteId: string }[];
|
|
31
|
+
reportedAt: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ComposerAuditResult {
|
|
35
|
+
advisories: Record<string, ComposerAdvisory[]>;
|
|
36
|
+
abandoned: Record<string, string>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mapSeverity(severity: string): Severity {
|
|
40
|
+
return SEVERITY_MAP[severity.toLowerCase()] ?? Severity.Medium;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class DependencyAuditAnalyzer implements Analyzer {
|
|
44
|
+
readonly name = ANALYZER_NAME;
|
|
45
|
+
readonly description = 'Audits composer.json for known vulnerable PHP dependencies';
|
|
46
|
+
|
|
47
|
+
private readonly composerRunner: (projectPath: string) => Promise<ComposerAuditResult | null>;
|
|
48
|
+
|
|
49
|
+
constructor(composerRunner?: (projectPath: string) => Promise<ComposerAuditResult | null>) {
|
|
50
|
+
this.composerRunner = composerRunner ?? defaultComposerRunner;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async analyze(files: ParsedFile[], config: HavocConfig): Promise<Finding[]> {
|
|
54
|
+
const findings: Finding[] = [];
|
|
55
|
+
|
|
56
|
+
const composerFile = files.find((f) => f.path === 'composer.json' || f.path.endsWith('/composer.json'));
|
|
57
|
+
if (!composerFile) return findings;
|
|
58
|
+
|
|
59
|
+
const projectRoot = (config as HavocConfig & { projectRoot?: string }).projectRoot ?? '.';
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const auditResult = await this.composerRunner(projectRoot);
|
|
63
|
+
if (!auditResult) {
|
|
64
|
+
findings.push({
|
|
65
|
+
id: FINDING_COMPOSER_UNAVAILABLE,
|
|
66
|
+
severity: Severity.Info,
|
|
67
|
+
analyzer: ANALYZER_NAME,
|
|
68
|
+
title: 'composer audit unavailable — dependency check skipped',
|
|
69
|
+
description:
|
|
70
|
+
'`composer` is not available in PATH or does not support the `audit` command (requires Composer 2.4+).',
|
|
71
|
+
file: composerFile.path,
|
|
72
|
+
line: 1,
|
|
73
|
+
recommendation: 'Install Composer 2.4+ and run `composer audit` manually.',
|
|
74
|
+
cwe: 'CWE-1104',
|
|
75
|
+
});
|
|
76
|
+
return findings;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const [packageName, advisories] of Object.entries(auditResult.advisories)) {
|
|
80
|
+
for (const advisory of advisories) {
|
|
81
|
+
findings.push({
|
|
82
|
+
id: FINDING_VULNERABLE_DEP,
|
|
83
|
+
severity: mapSeverity(advisory.severity),
|
|
84
|
+
analyzer: ANALYZER_NAME,
|
|
85
|
+
title: `Vulnerable dependency: ${packageName} — ${advisory.title}`,
|
|
86
|
+
description:
|
|
87
|
+
`Package \`${packageName}\` has a known vulnerability: ${advisory.title}. ` +
|
|
88
|
+
(advisory.cve ? `CVE: ${advisory.cve}. ` : '') +
|
|
89
|
+
`Affected versions: ${advisory.affectedVersions}.`,
|
|
90
|
+
file: 'composer.json',
|
|
91
|
+
line: 1,
|
|
92
|
+
recommendation: `Update \`${packageName}\` to a patched version. See: ${advisory.link}`,
|
|
93
|
+
cwe: advisory.cve ? `CVE: ${advisory.cve}` : 'CWE-1104',
|
|
94
|
+
snippet: `"${packageName}": "${advisory.affectedVersions}"`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
findings.push({
|
|
100
|
+
id: FINDING_COMPOSER_UNAVAILABLE,
|
|
101
|
+
severity: Severity.Info,
|
|
102
|
+
analyzer: ANALYZER_NAME,
|
|
103
|
+
title: 'composer audit failed — dependency check skipped',
|
|
104
|
+
description: `composer audit returned an error: ${(err as Error).message}`,
|
|
105
|
+
file: composerFile.path,
|
|
106
|
+
line: 1,
|
|
107
|
+
recommendation: 'Ensure composer is installed and `composer.lock` is present.',
|
|
108
|
+
cwe: 'CWE-1104',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return findings;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function defaultComposerRunner(projectPath: string): Promise<ComposerAuditResult | null> {
|
|
117
|
+
const composerLock = resolve(projectPath, 'composer.lock');
|
|
118
|
+
if (!existsSync(composerLock)) return null;
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const { stdout } = await execFileAsync('composer', ['audit', '--format=json', '--no-interaction'], {
|
|
122
|
+
cwd: projectPath,
|
|
123
|
+
timeout: 30_000,
|
|
124
|
+
});
|
|
125
|
+
return JSON.parse(stdout) as ComposerAuditResult;
|
|
126
|
+
} catch (err) {
|
|
127
|
+
const error = err as { code?: number; stdout?: string };
|
|
128
|
+
if (error.stdout) {
|
|
129
|
+
try {
|
|
130
|
+
return JSON.parse(error.stdout) as ComposerAuditResult;
|
|
131
|
+
} catch { /* not JSON */ }
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|